Add sort/filter controls to mod list

This commit is contained in:
Tyler Hallada 2022-03-17 01:11:59 -04:00
parent 351785713c
commit 914fbbb540
9 changed files with 356 additions and 298 deletions

View File

@ -3,7 +3,7 @@ import React from "react";
import useSWRImmutable from "swr/immutable";
import styles from "../styles/CellData.module.css";
import CellModList from "./CellModList";
import ModList from "./ModList";
import PluginList from "./PluginsList";
export interface Mod {
@ -123,7 +123,7 @@ const CellData: React.FC<Props> = ({ selectedCell, counts }) => {
</li>
</ul>
<PluginList selectedCell={selectedCell} />
<CellModList mods={data.mods} counts={counts} />
<ModList mods={data.mods} counts={counts} />
</>
)
);

View File

@ -1,115 +0,0 @@
import { format } from "date-fns";
import React, { useState } from "react";
import Link from "next/link";
import styles from "../styles/CellModList.module.css";
import type { Mod } from "./CellData";
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
type Props = {
mods: Mod[];
counts: Record<number, [number, number, number]> | null;
};
type ModWithCounts = Mod & {
total_downloads: number;
unique_downloads: number;
views: number;
};
const CellModList: React.FC<Props> = ({ mods, counts }) => {
const [includeTranslations, setIncludeTranslations] = useState(true);
const modsWithCounts: ModWithCounts[] = mods
.map((mod) => {
const modCounts = counts && counts[mod.nexus_mod_id];
return {
...mod,
total_downloads: modCounts ? modCounts[0] : 0,
unique_downloads: modCounts ? modCounts[1] : 0,
views: modCounts ? modCounts[2] : 0,
};
})
.filter((mod) => includeTranslations || !mod.is_translation);
let numberFmt = new Intl.NumberFormat("en-US");
return (
mods && (
<>
<h2>Nexus Mods ({modsWithCounts.length})</h2>
<label className={styles["include-translations"]}>
<input
type="checkbox"
checked={includeTranslations}
onChange={() => setIncludeTranslations(!includeTranslations)}
/>
Include translations
</label>
<ul className={styles["mod-list"]}>
{modsWithCounts
.sort((a, b) => b.unique_downloads - a.unique_downloads)
.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>
<div>
<a
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
>
View on Nexus Mods
</a>
</div>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.category_name}
</a>
</div>
<div>
<strong>Author:&nbsp;</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>
</li>
))}
</ul>
</>
)
);
};
export default CellModList;

269
components/ModList.tsx Normal file
View File

@ -0,0 +1,269 @@
import { format } from "date-fns";
import React, { useEffect, useRef, useState } from "react";
import MiniSearch from "minisearch";
import Link from "next/link";
import styles from "../styles/ModList.module.css";
import type { Mod } from "./CellData";
import type { File } from "../slices/plugins";
import { formatBytes } from "../lib/plugins";
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
type Props = {
mods: Mod[];
files?: File[];
counts: Record<number, [number, number, number]> | null;
};
type ModWithCounts = Mod & {
total_downloads: number;
unique_downloads: number;
views: number;
};
const ModList: React.FC<Props> = ({ mods, files, counts }) => {
const [includeTranslations, setIncludeTranslations] = useState(true);
const [sortBy, setSortBy] = useState<keyof ModWithCounts>("unique_downloads");
const [filter, setFilter] = useState<string>("");
const [category, setCategory] = useState<string>("All");
const [filterResults, setFilterResults] = useState<Set<number>>(new Set());
const modsWithCounts: ModWithCounts[] = mods
.map((mod) => {
const modCounts = counts && counts[mod.nexus_mod_id];
return {
...mod,
total_downloads: modCounts ? modCounts[0] : 0,
unique_downloads: modCounts ? modCounts[1] : 0,
views: modCounts ? modCounts[2] : 0,
};
})
.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") {
return bVal - aVal;
} else if (
typeof aVal === "string" &&
typeof bVal === "string" &&
["first_upload_at", "last_update_at"].includes(sortBy)
) {
return new Date(bVal).getTime() - new Date(aVal).getTime();
} else if (typeof aVal === "string" && typeof bVal === "string") {
return aVal.localeCompare(bVal);
}
return 0;
});
let numberFmt = new Intl.NumberFormat("en-US");
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(
new Set(modSearch.current.search(filter).map((result) => result.id))
);
}
}, [filter]);
return (
mods && (
<>
<h2>Nexus Mods ({modsWithCounts.length})</h2>
<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) =>
setSortBy(event.target.value as keyof ModWithCounts)
}
>
<option value="unique_downloads">Unique Downloads</option>
<option value="total_downloads">Total Downloads</option>
<option value="views">Views</option>
<option value="name">Name</option>
<option value="nexus_mod_id">ID</option>
<option value="author_name">Author</option>
<option value="first_upload_at">Upload Date</option>
<option value="last_update_at">Last Update</option>
</select>
</div>
<div className={styles["filter-row"]}>
<label htmlFor="filter">Filter:</label>
<input
type="search"
id="filter"
className={styles.filter}
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
</div>
<div className={styles["filter-row"]}>
<label htmlFor="category">Category:</label>
<select
name="category"
id="category"
className={styles["category"]}
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<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}
onChange={() => setIncludeTranslations(!includeTranslations)}
/>
</div>
<hr />
</div>
<ul className={styles["mod-list"]}>
{modsWithCounts.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>
<div>
<a
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
>
View on Nexus Mods
</a>
</div>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.category_name}
</a>
</div>
<div>
<strong>Author:&nbsp;</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>
<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}>
<div>
<strong>File:</strong> {file.name}
</div>
{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>
)}
<div>
<strong>Size:</strong> {formatBytes(file.size)}
</div>
{file.uploaded_at && (
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(file.uploaded_at), "d MMM y")}
</div>
)}
</li>
))}
</ul>
</li>
))}
</ul>
</>
)
);
};
export default ModList;

View File

@ -7,7 +7,7 @@ import {
PluginFile,
PluginsByHashWithMods,
} from "../slices/plugins";
import PluginModList from "./PluginModList";
import ModList from "./ModList";
import CellList from "./CellList";
import type { CellCoord } from "./ModData";
import PluginData, { Plugin as PluginProps } from "./PluginData";
@ -92,9 +92,7 @@ const PluginDetail: React.FC<Props> = ({ hash, counts }) => {
return (
<>
<PluginData plugin={buildPluginProps(data, plugin)} counts={counts} />
{data && (
<PluginModList mods={data.mods} files={data.files} counts={counts} />
)}
{data && <ModList mods={data.mods} files={data.files} counts={counts} />}
<CellList
cells={
(plugin?.parsed?.cells.filter(

View File

@ -1,154 +0,0 @@
import { format } from "date-fns";
import React, { useState } from "react";
import Link from "next/link";
import styles from "../styles/PluginModList.module.css";
import type { Mod } from "./CellData";
import type { File } from "../slices/plugins";
import { formatBytes } from "../lib/plugins";
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
type Props = {
mods: Mod[];
files: File[];
counts: Record<number, [number, number, number]> | null;
};
type ModWithCounts = Mod & {
total_downloads: number;
unique_downloads: number;
views: number;
};
const PluginModList: React.FC<Props> = ({ mods, files, counts }) => {
const [includeTranslations, setIncludeTranslations] = useState(true);
const modsWithCounts: ModWithCounts[] = mods
.map((mod) => {
const modCounts = counts && counts[mod.nexus_mod_id];
return {
...mod,
total_downloads: modCounts ? modCounts[0] : 0,
unique_downloads: modCounts ? modCounts[1] : 0,
views: modCounts ? modCounts[2] : 0,
};
})
.filter((mod) => includeTranslations || !mod.is_translation);
let numberFmt = new Intl.NumberFormat("en-US");
return (
mods && (
<>
<h2>Nexus Mods ({modsWithCounts.length})</h2>
<label className={styles["include-translations"]}>
<input
type="checkbox"
checked={includeTranslations}
onChange={() => setIncludeTranslations(!includeTranslations)}
/>
Include translations
</label>
<ul className={styles["mod-list"]}>
{modsWithCounts
.sort((a, b) => b.unique_downloads - a.unique_downloads)
.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>
<div>
<a
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
>
View on Nexus Mods
</a>
</div>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.category_name}
</a>
</div>
<div>
<strong>Author:&nbsp;</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>
<ul className={styles["file-list"]}>
{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}>
<div>
<strong>File:</strong> {file.name}
</div>
{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>
)}
<div>
<strong>Size:</strong> {formatBytes(file.size)}
</div>
{file.uploaded_at && (
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(file.uploaded_at), "d MMM y")}
</div>
)}
</li>
))}
</ul>
</li>
))}
</ul>
</>
)
);
};
export default PluginModList;

View File

@ -44,7 +44,11 @@ const PluginsList: React.FC<Props> = ({ selectedCell }) => {
</button>
</div>
)}
<ol className={styles["plugin-list"]}>
<ol
className={`${styles["plugin-list"]} ${
plugins.length > 0 ? styles["bottom-spacing"] : ""
}`}
>
{plugins.map((plugin) => (
<li key={plugin.filename} title={plugin.filename}>
<input

View File

@ -1,21 +0,0 @@
.mod-list {
list-style-type: none;
padding: 0;
margin-top: 0;
}
.mod-list-item {
margin-bottom: 12px;
}
.mod-title {
margin-bottom: 8px;
}
.include-translations {
margin-bottom: 12px;
}
.include-translations input {
margin-right: 8px;
}

73
styles/ModList.module.css Normal file
View File

@ -0,0 +1,73 @@
.mod-list {
list-style-type: none;
padding: 0;
margin-top: 0;
}
.mod-list-item {
margin-bottom: 12px;
}
.mod-title {
margin-bottom: 8px;
}
.file-list {
list-style-type: none;
padding-left: 8px;
margin-top: 0;
}
.file-list li {
margin-top: 8px;
}
.filters {
margin-bottom: 12px;
display: flex;
flex-direction: column;
}
.filters hr {
margin: 0;
width: 100%;
}
.filters hr:first-child {
margin-bottom: 8px;
}
.filters hr:last-child {
margin-top: 8px;
}
.filter-row {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 4px;
margin-bottom: 4px;
}
.filter-row label,
.filter-row input,
.filter-row select {
flex: 1;
}
.sort-by {
min-width: 175px;
}
.category {
min-width: 175px;
width: 175px;
}
.filter {
width: 175px;
}
.include-translations input {
margin-right: 8px;
}

View File

@ -1,7 +1,7 @@
.plugin-list {
list-style-type: none;
padding: 0;
margin-top: 0;
margin: 0;
}
.plugin-list li {
@ -10,6 +10,10 @@
overflow-x: hidden;
}
.bottom-spacing {
margin-bottom: 12px;
}
.plugin-error {
color: #ff0000;
}