2022-03-18 23:51:30 +00:00
|
|
|
/* eslint-disable @next/next/no-img-element */
|
2022-03-17 05:11:59 +00:00
|
|
|
import { format } from "date-fns";
|
2022-09-03 06:51:57 +00:00
|
|
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
2022-03-17 05:11:59 +00:00
|
|
|
import MiniSearch from "minisearch";
|
|
|
|
import Link from "next/link";
|
2022-03-18 05:06:20 +00:00
|
|
|
import useSWRImmutable from "swr/immutable";
|
2022-08-28 06:27:53 +00:00
|
|
|
import ReactPaginate from "react-paginate";
|
2022-03-17 05:11:59 +00:00
|
|
|
|
|
|
|
import styles from "../styles/ModList.module.css";
|
|
|
|
import type { Mod } from "./CellData";
|
|
|
|
import type { File } from "../slices/plugins";
|
|
|
|
import { formatBytes } from "../lib/plugins";
|
2022-03-18 05:06:20 +00:00
|
|
|
import { jsonFetcher } from "../lib/api";
|
2022-03-19 00:08:31 +00:00
|
|
|
import {
|
|
|
|
ModWithCounts,
|
|
|
|
setSortBy,
|
|
|
|
setSortAsc,
|
|
|
|
setFilter,
|
|
|
|
setCategory,
|
|
|
|
setIncludeTranslations,
|
|
|
|
} from "../slices/modListFilters";
|
|
|
|
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
2022-09-03 06:51:57 +00:00
|
|
|
import { DownloadCountsContext } from "./DownloadCountsProvider";
|
|
|
|
import { GamesContext } from "./GamesProvider";
|
2022-03-17 05:11:59 +00:00
|
|
|
|
|
|
|
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
|
2022-08-28 06:27:53 +00:00
|
|
|
const PAGE_SIZE = 50;
|
2022-03-17 05:11:59 +00:00
|
|
|
|
|
|
|
type Props = {
|
|
|
|
mods: Mod[];
|
|
|
|
files?: File[];
|
|
|
|
};
|
|
|
|
|
2022-09-03 06:51:57 +00:00
|
|
|
const ModList: React.FC<Props> = ({ mods, files }) => {
|
|
|
|
const {
|
|
|
|
games,
|
|
|
|
getGameNameById,
|
|
|
|
error: gamesError,
|
|
|
|
} = useContext(GamesContext);
|
|
|
|
const counts = useContext(DownloadCountsContext);
|
2022-03-19 00:08:31 +00:00
|
|
|
const dispatch = useAppDispatch();
|
|
|
|
const { sortBy, sortAsc, filter, category, includeTranslations } =
|
|
|
|
useAppSelector((state) => state.modListFilters);
|
2022-03-17 05:11:59 +00:00
|
|
|
const [filterResults, setFilterResults] = useState<Set<number>>(new Set());
|
2022-08-28 06:27:53 +00:00
|
|
|
const [page, setPage] = useState<number>(0);
|
2022-03-17 05:11:59 +00:00
|
|
|
|
2022-03-18 05:06:20 +00:00
|
|
|
const { data: cellCounts, error: cellCountsError } = useSWRImmutable(
|
|
|
|
`https://mods.modmapper.com/mod_cell_counts.json`,
|
|
|
|
(_) => jsonFetcher<Record<string, number>>(_)
|
|
|
|
);
|
|
|
|
|
2022-03-17 05:11:59 +00:00
|
|
|
const modsWithCounts: ModWithCounts[] = mods
|
|
|
|
.map((mod) => {
|
2022-09-03 06:51:57 +00:00
|
|
|
const gameName = getGameNameById(mod.game_id);
|
|
|
|
const gameDownloadCounts = gameName && counts[gameName].counts;
|
|
|
|
const modCounts =
|
|
|
|
gameDownloadCounts && gameDownloadCounts[mod.nexus_mod_id];
|
2022-03-17 05:11:59 +00:00
|
|
|
return {
|
|
|
|
...mod,
|
|
|
|
total_downloads: modCounts ? modCounts[0] : 0,
|
|
|
|
unique_downloads: modCounts ? modCounts[1] : 0,
|
|
|
|
views: modCounts ? modCounts[2] : 0,
|
2022-03-18 05:06:20 +00:00
|
|
|
exterior_cells_edited: cellCounts
|
|
|
|
? cellCounts[mod.nexus_mod_id] ?? 0
|
|
|
|
: 0,
|
2022-03-17 05:11:59 +00:00
|
|
|
};
|
|
|
|
})
|
|
|
|
.filter(
|
|
|
|
(mod) =>
|
|
|
|
(includeTranslations || !mod.is_translation) &&
|
|
|
|
(!filter || filterResults.has(mod.id)) &&
|
|
|
|
(category === "All" || mod.category_name === category)
|
|
|
|
)
|
|
|
|
.sort((a, b) => {
|
|
|
|
const aVal = a[sortBy];
|
|
|
|
const bVal = b[sortBy];
|
|
|
|
if (typeof aVal === "number" && typeof bVal === "number") {
|
2022-03-18 23:51:30 +00:00
|
|
|
return sortAsc ? aVal - bVal : bVal - aVal;
|
2022-03-17 05:11:59 +00:00
|
|
|
} else if (
|
|
|
|
typeof aVal === "string" &&
|
|
|
|
typeof bVal === "string" &&
|
|
|
|
["first_upload_at", "last_update_at"].includes(sortBy)
|
|
|
|
) {
|
2022-03-18 23:51:30 +00:00
|
|
|
const aTime = new Date(aVal).getTime();
|
|
|
|
const bTime = new Date(bVal).getTime();
|
|
|
|
return sortAsc ? aTime - bTime : bTime - aTime;
|
2022-03-17 05:11:59 +00:00
|
|
|
} else if (typeof aVal === "string" && typeof bVal === "string") {
|
2022-03-18 23:51:30 +00:00
|
|
|
return sortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
2022-03-17 05:11:59 +00:00
|
|
|
}
|
|
|
|
return 0;
|
|
|
|
});
|
|
|
|
|
|
|
|
let numberFmt = new Intl.NumberFormat("en-US");
|
|
|
|
|
2022-09-03 06:51:57 +00:00
|
|
|
const renderDownloadCountsLoading = () => (
|
|
|
|
<div>Loading live download counts...</div>
|
|
|
|
);
|
|
|
|
const renderDownloadCountsError = (error: Error) => (
|
|
|
|
<div>{`Error loading live download counts: ${error.message}`}</div>
|
|
|
|
);
|
|
|
|
const renderGamesError = (error?: Error) =>
|
|
|
|
error ? (
|
|
|
|
<div>{`Error loading games: ${error.message}`}</div>
|
|
|
|
) : (
|
|
|
|
<div>Error loading games</div>
|
|
|
|
);
|
|
|
|
|
2022-03-17 05:11:59 +00:00
|
|
|
const modSearch = useRef<MiniSearch<Mod> | null>(
|
|
|
|
null
|
|
|
|
) as React.MutableRefObject<MiniSearch<Mod>>;
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
modSearch.current = new MiniSearch({
|
|
|
|
fields: ["name"],
|
|
|
|
storeFields: ["name", "id"],
|
|
|
|
searchOptions: {
|
|
|
|
fields: ["name"],
|
|
|
|
fuzzy: 0.2,
|
|
|
|
prefix: true,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
modSearch.current.addAll(mods);
|
|
|
|
}, [mods]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (modSearch.current) {
|
|
|
|
setFilterResults(
|
2022-03-19 00:08:31 +00:00
|
|
|
new Set(
|
|
|
|
modSearch.current.search(filter ?? "").map((result) => result.id)
|
|
|
|
)
|
2022-03-17 05:11:59 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}, [filter]);
|
|
|
|
|
2022-08-28 06:27:53 +00:00
|
|
|
useEffect(() => {
|
|
|
|
setPage(0);
|
|
|
|
}, [filterResults, category, includeTranslations, sortBy, sortAsc]);
|
|
|
|
|
2022-08-29 01:51:56 +00:00
|
|
|
const renderPagination = () => (
|
|
|
|
<ReactPaginate
|
|
|
|
breakLabel="..."
|
|
|
|
nextLabel=">"
|
|
|
|
forcePage={page}
|
|
|
|
onPageChange={(event) => {
|
|
|
|
setPage(event.selected);
|
2022-08-29 03:43:44 +00:00
|
|
|
document.getElementById("nexus-mods")?.scrollIntoView();
|
2022-08-29 01:51:56 +00:00
|
|
|
}}
|
|
|
|
pageRangeDisplayed={3}
|
|
|
|
marginPagesDisplayed={2}
|
|
|
|
pageCount={Math.ceil(modsWithCounts.length / PAGE_SIZE)}
|
|
|
|
previousLabel="<"
|
|
|
|
renderOnZeroPageCount={() => null}
|
|
|
|
className={styles.pagination}
|
|
|
|
activeClassName={styles["active-page"]}
|
|
|
|
hrefBuilder={() => "#"}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
|
2022-03-17 05:11:59 +00:00
|
|
|
return (
|
|
|
|
mods && (
|
|
|
|
<>
|
2022-08-29 03:43:44 +00:00
|
|
|
<h2 id="nexus-mods">Nexus Mods ({modsWithCounts.length})</h2>
|
2022-03-17 05:11:59 +00:00
|
|
|
<div className={styles.filters}>
|
|
|
|
<hr />
|
|
|
|
<div className={styles["filter-row"]}>
|
|
|
|
<label htmlFor="sort-by">Sort by:</label>
|
|
|
|
<select
|
|
|
|
name="sort-by"
|
|
|
|
id="sort-by"
|
|
|
|
className={styles["sort-by"]}
|
|
|
|
value={sortBy}
|
|
|
|
onChange={(event) =>
|
2022-03-19 00:08:31 +00:00
|
|
|
dispatch(setSortBy(event.target.value as keyof ModWithCounts))
|
2022-03-17 05:11:59 +00:00
|
|
|
}
|
|
|
|
>
|
|
|
|
<option value="name">Name</option>
|
|
|
|
<option value="author_name">Author</option>
|
|
|
|
<option value="first_upload_at">Upload Date</option>
|
|
|
|
<option value="last_update_at">Last Update</option>
|
2022-03-18 05:06:20 +00:00
|
|
|
<option value="total_downloads">Total Downloads</option>
|
|
|
|
<option value="unique_downloads">Unique Downloads</option>
|
|
|
|
<option value="views">Views</option>
|
|
|
|
<option value="exterior_cells_edited">
|
|
|
|
Exterior Cells Edited
|
|
|
|
</option>
|
|
|
|
<option value="nexus_mod_id">ID</option>
|
2022-03-17 05:11:59 +00:00
|
|
|
</select>
|
2022-03-18 23:51:30 +00:00
|
|
|
<div className={styles["sort-direction"]}>
|
|
|
|
<button
|
|
|
|
title="Sort ascending"
|
2022-03-19 00:08:31 +00:00
|
|
|
onClick={() => dispatch(setSortAsc(true))}
|
2022-03-18 23:51:30 +00:00
|
|
|
>
|
|
|
|
<img
|
|
|
|
alt="Sort ascending"
|
|
|
|
src={
|
|
|
|
sortAsc
|
|
|
|
? "/img/arrow-selected.svg"
|
|
|
|
: "/img/arrow-disabled.svg"
|
|
|
|
}
|
|
|
|
className={styles.asc}
|
|
|
|
/>
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
title="Sort descending"
|
2022-03-19 00:08:31 +00:00
|
|
|
onClick={() => dispatch(setSortAsc(false))}
|
2022-03-18 23:51:30 +00:00
|
|
|
>
|
|
|
|
<img
|
|
|
|
alt="Sort descending"
|
|
|
|
src={
|
|
|
|
!sortAsc
|
|
|
|
? "/img/arrow-selected.svg"
|
|
|
|
: "/img/arrow-disabled.svg"
|
|
|
|
}
|
|
|
|
className={styles.desc}
|
|
|
|
/>
|
|
|
|
</button>
|
|
|
|
</div>
|
2022-03-17 05:11:59 +00:00
|
|
|
</div>
|
|
|
|
<div className={styles["filter-row"]}>
|
|
|
|
<label htmlFor="filter">Filter:</label>
|
|
|
|
<input
|
|
|
|
type="search"
|
|
|
|
id="filter"
|
|
|
|
className={styles.filter}
|
|
|
|
value={filter}
|
2022-03-19 00:08:31 +00:00
|
|
|
onChange={(event) => dispatch(setFilter(event.target.value))}
|
2022-03-17 05:11:59 +00:00
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
<div className={styles["filter-row"]}>
|
|
|
|
<label htmlFor="category">Category:</label>
|
|
|
|
<select
|
|
|
|
name="category"
|
|
|
|
id="category"
|
|
|
|
className={styles["category"]}
|
|
|
|
value={category}
|
2022-03-19 00:08:31 +00:00
|
|
|
onChange={(event) => dispatch(setCategory(event.target.value))}
|
2022-03-17 05:11:59 +00:00
|
|
|
>
|
|
|
|
<option value="All">All</option>
|
|
|
|
{(
|
|
|
|
Array.from(
|
|
|
|
mods
|
|
|
|
.reduce((categories, mod) => {
|
|
|
|
categories.add(mod.category_name);
|
|
|
|
return categories;
|
|
|
|
}, new Set())
|
|
|
|
.values()
|
|
|
|
) as string[]
|
|
|
|
)
|
|
|
|
.sort()
|
|
|
|
.map((category) => (
|
|
|
|
<option key={category} value={category}>
|
|
|
|
{category}
|
|
|
|
</option>
|
|
|
|
))}
|
|
|
|
</select>
|
|
|
|
</div>
|
|
|
|
<div className={styles["filter-row"]}>
|
|
|
|
<label htmlFor="include-translations">Include translations:</label>
|
|
|
|
<input
|
|
|
|
type="checkbox"
|
|
|
|
id="include-translations"
|
|
|
|
className={styles["include-translations"]}
|
|
|
|
checked={includeTranslations}
|
2022-03-19 00:08:31 +00:00
|
|
|
onChange={() =>
|
|
|
|
dispatch(setIncludeTranslations(!includeTranslations))
|
|
|
|
}
|
2022-03-17 05:11:59 +00:00
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
<hr />
|
|
|
|
</div>
|
2022-08-29 01:51:56 +00:00
|
|
|
{renderPagination()}
|
2022-03-17 05:11:59 +00:00
|
|
|
<ul className={styles["mod-list"]}>
|
2022-09-03 06:51:57 +00:00
|
|
|
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) &&
|
|
|
|
renderDownloadCountsLoading()}
|
|
|
|
{(!games || gamesError) && renderGamesError(gamesError)}
|
|
|
|
{counts.skyrim.error &&
|
|
|
|
renderDownloadCountsError(counts.skyrim.error)}
|
|
|
|
{counts.skyrimspecialedition.error &&
|
|
|
|
renderDownloadCountsError(counts.skyrimspecialedition.error)}
|
2022-08-28 06:27:53 +00:00
|
|
|
{modsWithCounts
|
|
|
|
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
|
|
|
|
.map((mod) => (
|
|
|
|
<li key={mod.id} className={styles["mod-list-item"]}>
|
|
|
|
<div className={styles["mod-title"]}>
|
|
|
|
<strong>
|
|
|
|
<Link href={`/?mod=${mod.nexus_mod_id}`}>
|
|
|
|
<a>{mod.name}</a>
|
|
|
|
</Link>
|
|
|
|
</strong>
|
|
|
|
</div>
|
2022-03-18 05:06:20 +00:00
|
|
|
<div>
|
2022-08-28 06:27:53 +00:00
|
|
|
<a
|
|
|
|
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
|
|
|
|
target="_blank"
|
|
|
|
rel="noreferrer noopener"
|
|
|
|
>
|
|
|
|
View on Nexus Mods
|
|
|
|
</a>
|
2022-03-18 05:06:20 +00:00
|
|
|
</div>
|
2022-08-28 06:27:53 +00:00
|
|
|
<div>
|
|
|
|
<strong>Category: </strong>
|
|
|
|
<a
|
|
|
|
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
|
|
|
|
target="_blank"
|
|
|
|
rel="noreferrer noopener"
|
|
|
|
>
|
|
|
|
{mod.category_name}
|
|
|
|
</a>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<strong>Author: </strong>
|
|
|
|
<a
|
|
|
|
href={`${NEXUS_MODS_URL}/users/${mod.author_id}`}
|
|
|
|
target="_blank"
|
|
|
|
rel="noreferrer noopener"
|
|
|
|
>
|
|
|
|
{mod.author_name}
|
|
|
|
</a>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<strong>Uploaded:</strong>{" "}
|
|
|
|
{format(new Date(mod.first_upload_at), "d MMM y")}
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<strong>Last Update:</strong>{" "}
|
|
|
|
{format(new Date(mod.last_update_at), "d MMM y")}
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<strong>Total Downloads:</strong>{" "}
|
|
|
|
{numberFmt.format(mod.total_downloads)}
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<strong>Unique Downloads:</strong>{" "}
|
|
|
|
{numberFmt.format(mod.unique_downloads)}
|
|
|
|
</div>
|
|
|
|
{cellCounts && (
|
|
|
|
<div>
|
|
|
|
<strong>Exterior Cells Edited:</strong>{" "}
|
|
|
|
{numberFmt.format(mod.exterior_cells_edited)}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
<ul className={styles["file-list"]}>
|
|
|
|
{files &&
|
|
|
|
files
|
|
|
|
.filter((file) => file.mod_id === mod.id)
|
|
|
|
.sort((a, b) => b.nexus_file_id - a.nexus_file_id)
|
|
|
|
.map((file) => (
|
|
|
|
<li key={file.id}>
|
2022-03-17 05:11:59 +00:00
|
|
|
<div>
|
2022-08-28 06:27:53 +00:00
|
|
|
<strong>File:</strong> {file.name}
|
2022-03-17 05:11:59 +00:00
|
|
|
</div>
|
2022-08-28 06:27:53 +00:00
|
|
|
{file.mod_version && (
|
|
|
|
<div>
|
|
|
|
<strong>Version:</strong> {file.mod_version}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
{file.version && file.mod_version !== file.version && (
|
|
|
|
<div>
|
|
|
|
<strong>File Version:</strong> {file.version}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
{file.category && (
|
|
|
|
<div>
|
|
|
|
<strong>Category:</strong> {file.category}
|
|
|
|
</div>
|
|
|
|
)}
|
2022-03-17 05:11:59 +00:00
|
|
|
<div>
|
2022-08-28 06:27:53 +00:00
|
|
|
<strong>Size:</strong> {formatBytes(file.size)}
|
2022-03-17 05:11:59 +00:00
|
|
|
</div>
|
2022-08-28 06:27:53 +00:00
|
|
|
{file.uploaded_at && (
|
|
|
|
<div>
|
|
|
|
<strong>Uploaded:</strong>{" "}
|
|
|
|
{format(new Date(file.uploaded_at), "d MMM y")}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</li>
|
|
|
|
))}
|
|
|
|
</ul>
|
|
|
|
</li>
|
|
|
|
))}
|
2022-03-17 05:11:59 +00:00
|
|
|
</ul>
|
2022-08-29 01:51:56 +00:00
|
|
|
{renderPagination()}
|
2022-03-17 05:11:59 +00:00
|
|
|
</>
|
|
|
|
)
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default ModList;
|