Merge pull request #6 from thallada/skyrim-le

Support for multiple games
This commit is contained in:
Tyler Hallada 2022-09-03 15:49:03 -04:00 committed by GitHub
commit 8adb07e70f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 539 additions and 226 deletions

View File

@ -1,28 +1,37 @@
import { format } from "date-fns"; import { format } from "date-fns";
import React, { useCallback, useState } from "react"; import React, { useCallback, useContext, useState } from "react";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { Mod, File, NEXUS_MODS_URL } from "./ModData"; import { Mod, File, NEXUS_MODS_URL } from "./ModData";
import styles from "../styles/AddModData.module.css"; import styles from "../styles/AddModData.module.css";
import { jsonFetcher } from "../lib/api"; import { jsonFetcher } from "../lib/api";
import { DownloadCountsContext } from "./DownloadCountsProvider";
import { GamesContext } from "./GamesProvider";
import type { SelectedMod } from "./AddModDialog";
type Props = { type Props = {
selectedMod: number; selectedMod: SelectedMod;
selectedPlugin: string | null; selectedPlugin: string | null;
setSelectedPlugin: (plugin: string) => void; setSelectedPlugin: (plugin: string) => void;
counts: Record<number, [number, number, number]> | null;
}; };
const AddModData: React.FC<Props> = ({ const AddModData: React.FC<Props> = ({
selectedMod, selectedMod,
selectedPlugin, selectedPlugin,
setSelectedPlugin, setSelectedPlugin,
counts,
}) => { }) => {
const {
games,
getGameNameById,
error: gamesError,
} = useContext(GamesContext);
const counts = useContext(DownloadCountsContext);
const [selectedFile, setSelectedFile] = useState<number | null>(null); const [selectedFile, setSelectedFile] = useState<number | null>(null);
const { data: modData, error: modError } = useSWRImmutable( const { data: modData, error: modError } = useSWRImmutable(
selectedMod ? `https://mods.modmapper.com/${selectedMod}.json` : null, selectedMod
? `https://mods.modmapper.com/${selectedMod.game}/${selectedMod.id}.json`
: null,
(_) => jsonFetcher<Mod>(_) (_) => jsonFetcher<Mod>(_)
); );
const { data: fileData, error: fileError } = useSWRImmutable( const { data: fileData, error: fileError } = useSWRImmutable(
@ -54,11 +63,27 @@ const AddModData: React.FC<Props> = ({
return <div className={styles.status}>Mod could not be found.</div>; return <div className={styles.status}>Mod could not be found.</div>;
let numberFmt = new Intl.NumberFormat("en-US"); let numberFmt = new Intl.NumberFormat("en-US");
const modCounts = counts && counts[modData.nexus_mod_id]; const gameName = getGameNameById(modData.game_id);
const gameDownloadCounts = gameName && counts[gameName].counts;
const modCounts =
gameDownloadCounts && gameDownloadCounts[modData.nexus_mod_id];
const total_downloads = modCounts ? modCounts[0] : 0; const total_downloads = modCounts ? modCounts[0] : 0;
const unique_downloads = modCounts ? modCounts[1] : 0; const unique_downloads = modCounts ? modCounts[1] : 0;
const views = modCounts ? modCounts[2] : 0; const views = modCounts ? modCounts[2] : 0;
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>
);
if (selectedMod && modData) { if (selectedMod && modData) {
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
@ -97,6 +122,12 @@ const AddModData: React.FC<Props> = ({
<strong>Uploaded:</strong>{" "} <strong>Uploaded:</strong>{" "}
{format(new Date(modData.first_upload_at), "d MMM y")} {format(new Date(modData.first_upload_at), "d MMM y")}
</div> </div>
{(!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)}
<div> <div>
<strong>Last Update:</strong>{" "} <strong>Last Update:</strong>{" "}
{format(new Date(modData.last_update_at), "d MMM y")} {format(new Date(modData.last_update_at), "d MMM y")}

View File

@ -1,5 +1,5 @@
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import React, { useCallback, useEffect, useState, useRef } from "react"; import React, { useCallback, useState, useRef } from "react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
@ -10,12 +10,13 @@ import { updateFetchedPlugin, PluginsByHashWithMods } from "../slices/plugins";
import styles from "../styles/AddModDialog.module.css"; import styles from "../styles/AddModDialog.module.css";
import EscapeListener from "./EscapeListener"; import EscapeListener from "./EscapeListener";
type Props = { export interface SelectedMod {
counts: Record<number, [number, number, number]> | null; id: number;
}; game: string;
}
const AddModDialog: React.FC<Props> = ({ counts }) => { const AddModDialog: React.FC = () => {
const [selectedMod, setSelectedMod] = useState<number | null>(null); const [selectedMod, setSelectedMod] = useState<SelectedMod | null>(null);
const [selectedPlugin, setSelectedPlugin] = useState<string | null>(null); const [selectedPlugin, setSelectedPlugin] = useState<string | null>(null);
const [dialogShown, setDialogShown] = useState(false); const [dialogShown, setDialogShown] = useState(false);
const searchInput = useRef<HTMLInputElement | null>(null); const searchInput = useRef<HTMLInputElement | null>(null);
@ -45,12 +46,14 @@ const AddModDialog: React.FC<Props> = ({ counts }) => {
<dialog open={dialogShown} className={styles.dialog}> <dialog open={dialogShown} className={styles.dialog}>
<h3>Add mod</h3> <h3>Add mod</h3>
<SearchBar <SearchBar
counts={counts}
sidebarOpen={false} sidebarOpen={false}
placeholder="Search mods…" placeholder="Search mods…"
onSelectResult={(selectedItem) => { onSelectResult={(selectedItem) => {
if (selectedItem) { if (selectedItem) {
setSelectedMod(selectedItem.id); setSelectedMod({
id: selectedItem.id,
game: selectedItem.game,
});
} }
}} }}
inputRef={searchInput} inputRef={searchInput}
@ -60,7 +63,6 @@ const AddModDialog: React.FC<Props> = ({ counts }) => {
selectedMod={selectedMod} selectedMod={selectedMod}
selectedPlugin={selectedPlugin} selectedPlugin={selectedPlugin}
setSelectedPlugin={setSelectedPlugin} setSelectedPlugin={setSelectedPlugin}
counts={counts}
/> />
)} )}
<menu> <menu>

View File

@ -40,10 +40,9 @@ export interface Cell {
type Props = { type Props = {
selectedCell: { x: number; y: number }; selectedCell: { x: number; y: number };
counts: Record<number, [number, number, number]> | null;
}; };
const CellData: React.FC<Props> = ({ selectedCell, counts }) => { const CellData: React.FC<Props> = ({ selectedCell }) => {
const { data, error } = useSWRImmutable( const { data, error } = useSWRImmutable(
`https://cells.modmapper.com/${selectedCell.x}/${selectedCell.y}.json`, `https://cells.modmapper.com/${selectedCell.x}/${selectedCell.y}.json`,
(_) => jsonFetcher<Cell>(_) (_) => jsonFetcher<Cell>(_)
@ -113,7 +112,7 @@ const CellData: React.FC<Props> = ({ selectedCell, counts }) => {
</ul> </ul>
<ParsedPluginsList selectedCell={selectedCell} /> <ParsedPluginsList selectedCell={selectedCell} />
<FetchedPluginsList selectedCell={selectedCell} /> <FetchedPluginsList selectedCell={selectedCell} />
<ModList mods={data.mods} counts={counts} /> <ModList mods={data.mods} />
</> </>
) )
); );

View File

@ -0,0 +1,86 @@
import React, { createContext, useEffect, useState } from "react";
import useSWRImmutable from "swr/immutable";
import { csvFetcher } from "../lib/api";
type DownloadCounts = Record<number, [number, number, number]>;
interface GameDownloadCounts {
skyrim: {
counts: DownloadCounts | null;
error?: any;
};
skyrimspecialedition: {
counts: DownloadCounts | null;
error?: any;
};
}
type DownloadCountsContext = GameDownloadCounts;
const SSE_LIVE_DOWNLOAD_COUNTS_URL =
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
const LE_LIVE_DOWNLOAD_COUNTS_URL =
"https://staticstats.nexusmods.com/live_download_counts/mods/110.csv";
export const DownloadCountsContext = createContext<DownloadCountsContext>({
skyrim: {
counts: null,
},
skyrimspecialedition: {
counts: null,
},
});
function parseCountsCSV(csv: string): DownloadCounts {
const counts: Record<number, [number, number, number]> = {};
for (const line of csv.split("\n")) {
const nums = line.split(",").map((count) => parseInt(count, 10));
counts[nums[0]] = [nums[1], nums[2], nums[3]];
}
return counts;
}
const DownloadCountsProvider: React.FC = ({ children }) => {
const [skyrimCounts, setSkyrimCounts] = useState<DownloadCounts | null>(null);
const [skyrimspecialeditionCounts, setSkyrimspecialeditionCounts] =
useState<DownloadCounts | null>(null);
// The live download counts are not really immutable, but I'd still rather load them once per session
const { data: skyrimspecialeditionCSV, error: skyrimspecialeditionError } =
useSWRImmutable(SSE_LIVE_DOWNLOAD_COUNTS_URL, csvFetcher);
const { data: skyrimCSV, error: skyrimError } = useSWRImmutable(
LE_LIVE_DOWNLOAD_COUNTS_URL,
csvFetcher
);
useEffect(() => {
if (skyrimCSV) {
setSkyrimCounts(parseCountsCSV(skyrimCSV));
}
}, [setSkyrimCounts, skyrimCSV]);
useEffect(() => {
if (skyrimspecialeditionCSV) {
setSkyrimspecialeditionCounts(parseCountsCSV(skyrimspecialeditionCSV));
}
}, [setSkyrimspecialeditionCounts, skyrimspecialeditionCSV]);
return (
<DownloadCountsContext.Provider
value={{
skyrim: {
counts: skyrimCounts,
error: skyrimError,
},
skyrimspecialedition: {
counts: skyrimspecialeditionCounts,
error: skyrimspecialeditionError,
},
}}
>
{children}
</DownloadCountsContext.Provider>
);
};
export default DownloadCountsProvider;

View File

@ -0,0 +1,53 @@
import React, { createContext, useCallback } from "react";
import useSWRImmutable from "swr/immutable";
import { jsonFetcher } from "../lib/api";
interface Game {
id: number;
name: GameName;
nexus_game_id: number;
}
export type GameName = "skyrim" | "skyrimspecialedition";
interface GamesContext {
games?: Game[] | null;
getGameNameById: (id: number) => GameName | undefined;
error?: any;
}
export const GamesContext = createContext<GamesContext>({
games: null,
getGameNameById: () => undefined,
});
const GamesProvider: React.FC = ({ children }) => {
const { data, error } = useSWRImmutable(
"https://mods.modmapper.com/games.json",
(_) => jsonFetcher<Game[]>(_, { notFoundOk: false })
);
const getGameNameById = useCallback(
(id: number): GameName | undefined => {
if (data) {
return data.find((game) => (game.id = id))?.name;
}
},
[data]
);
return (
<GamesContext.Provider
value={{
games: data,
getGameNameById,
error,
}}
>
{children}
</GamesContext.Provider>
);
};
export default GamesProvider;

View File

@ -11,7 +11,9 @@ import Sidebar from "./Sidebar";
import ToggleLayersControl from "./ToggleLayersControl"; import ToggleLayersControl from "./ToggleLayersControl";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import SearchProvider from "./SearchProvider"; import SearchProvider from "./SearchProvider";
import { csvFetcher, jsonFetcherWithLastModified } from "../lib/api"; import { jsonFetcherWithLastModified } from "../lib/api";
import DownloadCountsProvider from "./DownloadCountsProvider";
import GamesProvider from "./GamesProvider";
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? ""; mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
@ -25,9 +27,6 @@ colorGradient.setGradient(
); );
colorGradient.setMidpoint(360); colorGradient.setMidpoint(360);
const LIVE_DOWNLOAD_COUNTS_URL =
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
const Map: React.FC = () => { const Map: React.FC = () => {
const router = useRouter(); const router = useRouter();
const mapContainer = useRef<HTMLDivElement | null>( const mapContainer = useRef<HTMLDivElement | null>(
@ -69,15 +68,6 @@ const Map: React.FC = () => {
"https://cells.modmapper.com/edits.json", "https://cells.modmapper.com/edits.json",
(_) => jsonFetcherWithLastModified<Record<string, number>>(_) (_) => jsonFetcherWithLastModified<Record<string, number>>(_)
); );
// The live download counts are not really immutable, but I'd still rather load them once per session
const [counts, setCounts] = useState<Record<
number,
[number, number, number]
> | null>(null);
const { data: countsData, error: countsError } = useSWRImmutable(
LIVE_DOWNLOAD_COUNTS_URL,
csvFetcher
);
const selectMapCell = useCallback( const selectMapCell = useCallback(
(cell: { x: number; y: number }) => { (cell: { x: number; y: number }) => {
@ -822,17 +812,6 @@ const Map: React.FC = () => {
setHeatmapLoaded(true); setHeatmapLoaded(true);
}, [cellsData, mapLoaded, router, setHeatmapLoaded]); }, [cellsData, mapLoaded, router, setHeatmapLoaded]);
useEffect(() => {
if (countsData) {
const newCounts: Record<number, [number, number, number]> = {};
for (const line of countsData.split("\n")) {
const nums = line.split(",").map((count) => parseInt(count, 10));
newCounts[nums[0]] = [nums[1], nums[2], nums[3]];
}
setCounts(newCounts);
}
}, [setCounts, countsData]);
return ( return (
<> <>
<div <div
@ -842,60 +821,61 @@ const Map: React.FC = () => {
ref={mapWrapper} ref={mapWrapper}
> >
<div ref={mapContainer} className={styles["map-container"]}> <div ref={mapContainer} className={styles["map-container"]}>
<SearchProvider> <DownloadCountsProvider>
<Sidebar <GamesProvider>
selectedCell={selectedCell} <SearchProvider>
clearSelectedCell={() => router.push({ query: {} })} <Sidebar
setSelectedCells={setSelectedCells} selectedCell={selectedCell}
counts={counts} clearSelectedCell={() => router.push({ query: {} })}
countsError={countsError} setSelectedCells={setSelectedCells}
open={sidebarOpen} open={sidebarOpen}
setOpen={setSidebarOpenWithResize} setOpen={setSidebarOpenWithResize}
lastModified={cellsData && cellsData.lastModified} lastModified={cellsData && cellsData.lastModified}
onSelectFile={(selectedFile) => { onSelectFile={(selectedFile) => {
const { plugin, ...withoutPlugin } = router.query; const { plugin, ...withoutPlugin } = router.query;
if (selectedFile) { if (selectedFile) {
router.push({ router.push({
query: { ...withoutPlugin, file: selectedFile }, query: { ...withoutPlugin, file: selectedFile },
}); });
} else { } else {
const { file, ...withoutFile } = withoutPlugin; const { file, ...withoutFile } = withoutPlugin;
router.push({ query: { ...withoutFile } }); router.push({ query: { ...withoutFile } });
} }
}} }}
onSelectPlugin={(selectedPlugin) => { onSelectPlugin={(selectedPlugin) => {
if (selectedPlugin) { if (selectedPlugin) {
router.push({ router.push({
query: { ...router.query, plugin: selectedPlugin }, query: { ...router.query, plugin: selectedPlugin },
}); });
} else { } else {
const { plugin, ...withoutPlugin } = router.query; const { plugin, ...withoutPlugin } = router.query;
router.push({ query: { ...withoutPlugin } }); router.push({ query: { ...withoutPlugin } });
} }
}} }}
/> />
<ToggleLayersControl map={map} /> <ToggleLayersControl map={map} />
<SearchBar <SearchBar
counts={counts} sidebarOpen={sidebarOpen}
sidebarOpen={sidebarOpen} placeholder="Search mods or cells…"
placeholder="Search mods or cells…" onSelectResult={(selectedItem) => {
onSelectResult={(selectedItem) => { if (!selectedItem) return;
if (!selectedItem) return; if (
if ( selectedItem.x !== undefined &&
selectedItem.x !== undefined && selectedItem.y !== undefined
selectedItem.y !== undefined ) {
) { router.push({
router.push({ query: { cell: `${selectedItem.x},${selectedItem.y}` },
query: { cell: `${selectedItem.x},${selectedItem.y}` }, });
}); } else {
} else { router.push({ query: { mod: selectedItem.id } });
router.push({ query: { mod: selectedItem.id } }); }
} }}
}} includeCells
includeCells fixed
fixed />
/> </SearchProvider>
</SearchProvider> </GamesProvider>
</DownloadCountsProvider>
</div> </div>
</div> </div>
</> </>

View File

@ -1,6 +1,6 @@
import { format } from "date-fns"; import { format } from "date-fns";
import Head from "next/head"; import Head from "next/head";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useContext, useEffect, useState } from "react";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { useAppDispatch, useAppSelector } from "../lib/hooks"; import { useAppDispatch, useAppSelector } from "../lib/hooks";
@ -13,6 +13,8 @@ import {
updateFetchedPlugin, updateFetchedPlugin,
} from "../slices/plugins"; } from "../slices/plugins";
import Link from "next/link"; import Link from "next/link";
import { DownloadCountsContext } from "./DownloadCountsProvider";
import { GamesContext } from "./GamesProvider";
export interface CellCoord { export interface CellCoord {
x: number; x: number;
@ -77,31 +79,37 @@ export interface Mod {
files: ModFile[]; files: ModFile[];
} }
export const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition"; export const NEXUS_MODS_URL = "https://www.nexusmods.com";
type Props = { type Props = {
game: string;
selectedMod: number; selectedMod: number;
selectedFile: number; selectedFile: number;
selectedPlugin: string; selectedPlugin: string;
counts: Record<number, [number, number, number]> | null;
setSelectedCells: (cells: { x: number; y: number }[] | null) => void; setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
onSelectFile: (fileId: number) => void; onSelectFile: (fileId: number) => void;
onSelectPlugin: (hash: string) => void; onSelectPlugin: (hash: string) => void;
}; };
const ModData: React.FC<Props> = ({ const ModData: React.FC<Props> = ({
game,
selectedMod, selectedMod,
selectedFile, selectedFile,
selectedPlugin, selectedPlugin,
counts,
setSelectedCells, setSelectedCells,
onSelectFile, onSelectFile,
onSelectPlugin, onSelectPlugin,
}) => { }) => {
const {
games,
getGameNameById,
error: gamesError,
} = useContext(GamesContext);
const counts = useContext(DownloadCountsContext);
const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] = const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] =
useState<boolean>(false); useState<boolean>(false);
const { data: modData, error: modError } = useSWRImmutable( const { data: modData, error: modError } = useSWRImmutable(
`https://mods.modmapper.com/${selectedMod}.json`, `https://mods.modmapper.com/${game}/${selectedMod}.json`,
(_) => jsonFetcher<Mod>(_) (_) => jsonFetcher<Mod>(_)
); );
@ -149,6 +157,19 @@ const ModData: React.FC<Props> = ({
if (pluginData) setSelectedCells(pluginData.cells); if (pluginData) setSelectedCells(pluginData.cells);
}, [pluginData, setSelectedCells]); }, [pluginData, setSelectedCells]);
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>
);
if (modError && modError.status === 404) { if (modError && modError.status === 404) {
return <div>Mod could not be found.</div>; return <div>Mod could not be found.</div>;
} else if (modError) { } else if (modError) {
@ -160,7 +181,10 @@ const ModData: React.FC<Props> = ({
return <div className={styles.status}>Mod could not be found.</div>; return <div className={styles.status}>Mod could not be found.</div>;
let numberFmt = new Intl.NumberFormat("en-US"); let numberFmt = new Intl.NumberFormat("en-US");
const modCounts = counts && counts[modData.nexus_mod_id]; const gameName = getGameNameById(modData.game_id);
const gameDownloadCounts = gameName && counts[gameName].counts;
const modCounts =
gameDownloadCounts && gameDownloadCounts[modData.nexus_mod_id];
const total_downloads = modCounts ? modCounts[0] : 0; const total_downloads = modCounts ? modCounts[0] : 0;
const unique_downloads = modCounts ? modCounts[1] : 0; const unique_downloads = modCounts ? modCounts[1] : 0;
const views = modCounts ? modCounts[2] : 0; const views = modCounts ? modCounts[2] : 0;
@ -198,12 +222,16 @@ const ModData: React.FC<Props> = ({
<meta <meta
key="og:url" key="og:url"
property="og:url" property="og:url"
content={`https://modmapper.com/?mod=${modData.nexus_mod_id}`} content={`https://modmapper.com/?game=${getGameNameById(
modData.game_id
)}&mod=${modData.nexus_mod_id}`}
/> />
</Head> </Head>
<h1> <h1>
<a <a
href={`${NEXUS_MODS_URL}/mods/${modData.nexus_mod_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(modData.game_id)}/mods/${
modData.nexus_mod_id
}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className={styles.name} className={styles.name}
@ -214,7 +242,9 @@ const ModData: React.FC<Props> = ({
<div> <div>
<strong>Category:&nbsp;</strong> <strong>Category:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/mods/categories/${modData.category_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
modData.game_id
)}/mods/categories/${modData.category_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -225,7 +255,9 @@ const ModData: React.FC<Props> = ({
<div> <div>
<strong>Author:&nbsp;</strong> <strong>Author:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/users/${modData.author_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
modData.game_id
)}/users/${modData.author_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -240,6 +272,12 @@ const ModData: React.FC<Props> = ({
<strong>Last Update:</strong>{" "} <strong>Last Update:</strong>{" "}
{format(new Date(modData.last_update_at), "d MMM y")} {format(new Date(modData.last_update_at), "d MMM y")}
</div> </div>
{(!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)}
<div> <div>
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)} <strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
</div> </div>

View File

@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { format } from "date-fns"; import { format } from "date-fns";
import React, { useEffect, useRef, useState } from "react"; import React, { useContext, useEffect, useRef, useState } from "react";
import MiniSearch from "minisearch"; import MiniSearch from "minisearch";
import Link from "next/link"; import Link from "next/link";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
@ -16,23 +16,31 @@ import {
setSortBy, setSortBy,
setSortAsc, setSortAsc,
setFilter, setFilter,
setGame,
setCategory, setCategory,
setIncludeTranslations, setIncludeTranslations,
} from "../slices/modListFilters"; } from "../slices/modListFilters";
import { useAppDispatch, useAppSelector } from "../lib/hooks"; import { useAppDispatch, useAppSelector } from "../lib/hooks";
import { DownloadCountsContext } from "./DownloadCountsProvider";
import { GamesContext } from "./GamesProvider";
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition"; const NEXUS_MODS_URL = "https://www.nexusmods.com";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
type Props = { type Props = {
mods: Mod[]; mods: Mod[];
files?: File[]; files?: File[];
counts: Record<number, [number, number, number]> | null;
}; };
const ModList: React.FC<Props> = ({ mods, files, counts }) => { const ModList: React.FC<Props> = ({ mods, files }) => {
const {
games,
getGameNameById,
error: gamesError,
} = useContext(GamesContext);
const counts = useContext(DownloadCountsContext);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { sortBy, sortAsc, filter, category, includeTranslations } = const { sortBy, sortAsc, filter, category, game, includeTranslations } =
useAppSelector((state) => state.modListFilters); useAppSelector((state) => state.modListFilters);
const [filterResults, setFilterResults] = useState<Set<number>>(new Set()); const [filterResults, setFilterResults] = useState<Set<number>>(new Set());
const [page, setPage] = useState<number>(0); const [page, setPage] = useState<number>(0);
@ -44,7 +52,10 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
const modsWithCounts: ModWithCounts[] = mods const modsWithCounts: ModWithCounts[] = mods
.map((mod) => { .map((mod) => {
const modCounts = counts && counts[mod.nexus_mod_id]; const gameName = getGameNameById(mod.game_id);
const gameDownloadCounts = gameName && counts[gameName].counts;
const modCounts =
gameDownloadCounts && gameDownloadCounts[mod.nexus_mod_id];
return { return {
...mod, ...mod,
total_downloads: modCounts ? modCounts[0] : 0, total_downloads: modCounts ? modCounts[0] : 0,
@ -59,6 +70,7 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
(mod) => (mod) =>
(includeTranslations || !mod.is_translation) && (includeTranslations || !mod.is_translation) &&
(!filter || filterResults.has(mod.id)) && (!filter || filterResults.has(mod.id)) &&
(game === "All" || getGameNameById(mod.game_id) === game) &&
(category === "All" || mod.category_name === category) (category === "All" || mod.category_name === category)
) )
.sort((a, b) => { .sort((a, b) => {
@ -82,6 +94,19 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
let numberFmt = new Intl.NumberFormat("en-US"); let numberFmt = new Intl.NumberFormat("en-US");
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>
);
const modSearch = useRef<MiniSearch<Mod> | null>( const modSearch = useRef<MiniSearch<Mod> | null>(
null null
) as React.MutableRefObject<MiniSearch<Mod>>; ) as React.MutableRefObject<MiniSearch<Mod>>;
@ -203,6 +228,26 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
onChange={(event) => dispatch(setFilter(event.target.value))} onChange={(event) => dispatch(setFilter(event.target.value))}
/> />
</div> </div>
<div className={styles["filter-row"]}>
<label htmlFor="game">Game:</label>
<select
name="game"
id="game"
className={styles["game"]}
value={game}
onChange={(event) => dispatch(setGame(event.target.value))}
>
<option value="All">All</option>
{games
?.map((game) => game.name)
.sort()
.map((game) => (
<option key={game} value={game}>
{game}
</option>
))}
</select>
</div>
<div className={styles["filter-row"]}> <div className={styles["filter-row"]}>
<label htmlFor="category">Category:</label> <label htmlFor="category">Category:</label>
<select <select
@ -247,20 +292,33 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
</div> </div>
{renderPagination()} {renderPagination()}
<ul className={styles["mod-list"]}> <ul className={styles["mod-list"]}>
{(!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)}
{modsWithCounts {modsWithCounts
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE) .slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
.map((mod) => ( .map((mod) => (
<li key={mod.id} className={styles["mod-list-item"]}> <li key={mod.id} className={styles["mod-list-item"]}>
<div className={styles["mod-title"]}> <div className={styles["mod-title"]}>
<strong> <strong>
<Link href={`/?mod=${mod.nexus_mod_id}`}> <Link
href={`/?game=${getGameNameById(mod.game_id)}&mod=${
mod.nexus_mod_id
}`}
>
<a>{mod.name}</a> <a>{mod.name}</a>
</Link> </Link>
</strong> </strong>
</div> </div>
<div> <div>
<a <a
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
mod.game_id
)}/mods/${mod.nexus_mod_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -270,7 +328,9 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
<div> <div>
<strong>Category:&nbsp;</strong> <strong>Category:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
mod.game_id
)}/mods/categories/${mod.category_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -280,7 +340,9 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
<div> <div>
<strong>Author:&nbsp;</strong> <strong>Author:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/users/${mod.author_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
mod.game_id
)}/users/${mod.author_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >

View File

@ -16,10 +16,9 @@ export interface Plugin {
type Props = { type Props = {
plugin: Plugin; plugin: Plugin;
counts: Record<number, [number, number, number]> | null;
}; };
const PluginData: React.FC<Props> = ({ plugin, counts }) => { const PluginData: React.FC<Props> = ({ plugin }) => {
if (!plugin) { if (!plugin) {
return <h3>Plugin could not be found.</h3>; return <h3>Plugin could not be found.</h3>;
} }

View File

@ -45,10 +45,9 @@ const buildPluginProps = (
type Props = { type Props = {
hash: string; hash: string;
counts: Record<number, [number, number, number]> | null;
}; };
const PluginDetail: React.FC<Props> = ({ hash, counts }) => { const PluginDetail: React.FC<Props> = ({ hash }) => {
const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] = const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] =
useState<boolean>(false); useState<boolean>(false);
@ -83,10 +82,7 @@ const PluginDetail: React.FC<Props> = ({ hash, counts }) => {
return ( return (
<> <>
<PluginData <PluginData plugin={buildPluginProps(data, parsedPlugin)} />
plugin={buildPluginProps(data, parsedPlugin)}
counts={counts}
/>
{data && ( {data && (
<> <>
<button <button
@ -113,7 +109,7 @@ const PluginDetail: React.FC<Props> = ({ hash, counts }) => {
)} )}
</> </>
)} )}
{data && <ModList mods={data.mods} files={data.files} counts={counts} />} {data && <ModList mods={data.mods} files={data.files} />}
{parsedPlugin?.parseError && ( {parsedPlugin?.parseError && (
<div className={styles.error}> <div className={styles.error}>
{`Error parsing plugin: ${parsedPlugin.parseError}`} {`Error parsing plugin: ${parsedPlugin.parseError}`}

View File

@ -4,9 +4,10 @@ import { SearchResult } from "minisearch";
import { SearchContext } from "./SearchProvider"; import { SearchContext } from "./SearchProvider";
import styles from "../styles/SearchBar.module.css"; import styles from "../styles/SearchBar.module.css";
import { DownloadCountsContext } from "./DownloadCountsProvider";
import { GameName } from "./GamesProvider";
type Props = { type Props = {
counts: Record<number, [number, number, number]> | null;
sidebarOpen: boolean; sidebarOpen: boolean;
placeholder: string; placeholder: string;
onSelectResult: (item: SearchResult | null) => void; onSelectResult: (item: SearchResult | null) => void;
@ -15,13 +16,18 @@ type Props = {
inputRef?: React.MutableRefObject<HTMLInputElement | null>; inputRef?: React.MutableRefObject<HTMLInputElement | null>;
}; };
interface Mod { function gamePrefex(game: GameName): string {
name: string; switch (game) {
id: number; case "skyrim":
return "[LE]";
case "skyrimspecialedition":
return "[SSE]";
default:
return "";
}
} }
const SearchBar: React.FC<Props> = ({ const SearchBar: React.FC<Props> = ({
counts,
sidebarOpen, sidebarOpen,
placeholder, placeholder,
onSelectResult, onSelectResult,
@ -29,12 +35,27 @@ const SearchBar: React.FC<Props> = ({
fixed = false, fixed = false,
inputRef, inputRef,
}) => { }) => {
const counts = useContext(DownloadCountsContext);
const { cellSearch, modSearch, loading, loadError } = const { cellSearch, modSearch, loading, loadError } =
useContext(SearchContext); useContext(SearchContext);
const searchInput = useRef<HTMLInputElement | null>(null); const searchInput = useRef<HTMLInputElement | null>(null);
const [searchFocused, setSearchFocused] = useState<boolean>(false); const [searchFocused, setSearchFocused] = useState<boolean>(false);
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const renderSearchIndexError = (error: Error) => (
<div className={styles.error}>
Error loading mod search index: {loadError.message}.
</div>
);
const renderDownloadCountsLoading = () => (
<div>Loading live download counts...</div>
);
const renderDownloadCountsError = (error: Error) => (
<div
className={styles.error}
>{`Error loading live download counts: ${error.message}`}</div>
);
const { const {
isOpen, isOpen,
getMenuProps, getMenuProps,
@ -57,9 +78,11 @@ const SearchBar: React.FC<Props> = ({
) { ) {
results = results.concat( results = results.concat(
modSearch.search(inputValue).sort((resultA, resultB) => { modSearch.search(inputValue).sort((resultA, resultB) => {
if (counts) { const countsA = counts[resultA.game as GameName].counts;
const countA = counts[resultA.id]; const countsB = counts[resultB.game as GameName].counts;
const countB = counts[resultB.id]; if (countsA && countsB) {
const countA = countsA[resultA.id];
const countB = countsB[resultB.id];
const scoreA = resultA.score; const scoreA = resultA.score;
const scoreB = resultB.score; const scoreB = resultB.score;
if (countA && countB && scoreA && scoreB) { if (countA && countB && scoreA && scoreB) {
@ -130,14 +153,16 @@ const SearchBar: React.FC<Props> = ({
highlightedIndex === index ? styles["highlighted-result"] : "" highlightedIndex === index ? styles["highlighted-result"] : ""
}`} }`}
> >
{result.name} {gamePrefex(result.game)} {result.name}
</li> </li>
))} ))}
{loadError && ( {loadError && renderSearchIndexError(loadError)}
<div className={styles.error}> {counts.skyrim.error &&
Error loading mod search index: {loadError}. renderDownloadCountsError(counts.skyrim.error)}
</div> {counts.skyrimspecialedition.error &&
)} renderDownloadCountsError(counts.skyrimspecialedition.error)}
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) &&
renderDownloadCountsLoading()}
</ul> </ul>
</div> </div>
</> </>

View File

@ -3,12 +3,19 @@ import MiniSearch from "minisearch";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { jsonFetcher } from "../lib/api"; import { jsonFetcher } from "../lib/api";
import type { GameName } from "./GamesProvider";
interface Mod { interface Mod {
name: string; name: string;
id: number; id: number;
} }
interface ModWithGame {
name: string;
id: number;
game: GameName;
}
let cells = []; let cells = [];
for (let x = -77; x < 76; x++) { for (let x = -77; x < 76; x++) {
@ -42,32 +49,70 @@ export const SearchContext = createContext<SearchContext>({
}); });
const SearchProvider: React.FC = ({ children }) => { const SearchProvider: React.FC = ({ children }) => {
const modSearch = useRef<MiniSearch<Mod> | null>( const modSearch = useRef<MiniSearch<ModWithGame>>(
null new MiniSearch({
) as React.MutableRefObject<MiniSearch<Mod>>; fields: ["name"],
storeFields: ["name", "id", "game"],
searchOptions: {
fields: ["name"],
fuzzy: 0.2,
prefix: true,
},
})
) as React.MutableRefObject<MiniSearch<ModWithGame>>;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [skyrimLoading, setSkyrimLoading] = useState(true);
const [skyrimspecialEditionLoading, setSkyrimspecialeditionLoading] =
useState(true);
const { data, error } = useSWRImmutable( const { data: skyrimData, error: skyrimError } = useSWRImmutable(
`https://mods.modmapper.com/mod_search_index.json`, `https://mods.modmapper.com/skyrim/mod_search_index.json`,
(_) => jsonFetcher<Mod[]>(_) (_) => jsonFetcher<Mod[]>(_, { notFoundOk: false })
); );
const { data: skyrimspecialeditionData, error: skyrimspecialeditionError } =
useSWRImmutable(
`https://mods.modmapper.com/skyrimspecialedition/mod_search_index.json`,
(_) => jsonFetcher<Mod[]>(_, { notFoundOk: false })
);
useEffect(() => { useEffect(() => {
if (data && !modSearch.current) { if (skyrimData) {
modSearch.current = new MiniSearch({ modSearch.current
fields: ["name"], .addAllAsync(skyrimData.map((mod) => ({ ...mod, game: "skyrim" })))
storeFields: ["name", "id"], .then(() => {
searchOptions: { setSkyrimLoading(false);
fields: ["name"], });
fuzzy: 0.2,
prefix: true,
},
});
modSearch.current.addAllAsync(data).then(() => {
setLoading(false);
});
} }
}, [data]); }, [skyrimData]);
useEffect(() => {
if (skyrimspecialeditionData) {
modSearch.current
.addAllAsync(
skyrimspecialeditionData.map((mod) => ({
...mod,
game: "skyrimspecialedition",
}))
)
.then(() => {
setSkyrimspecialeditionLoading(false);
});
}
}, [skyrimspecialeditionData]);
useEffect(() => {
if (
(!skyrimLoading || skyrimError) &&
(!skyrimspecialEditionLoading || skyrimspecialeditionError)
) {
setLoading(false);
}
}, [
skyrimLoading,
skyrimError,
skyrimspecialEditionLoading,
skyrimspecialeditionError,
]);
return ( return (
<SearchContext.Provider <SearchContext.Provider
@ -75,7 +120,7 @@ const SearchProvider: React.FC = ({ children }) => {
modSearch: modSearch.current, modSearch: modSearch.current,
cellSearch, cellSearch,
loading, loading,
loadError: error, loadError: skyrimspecialeditionError || skyrimError,
}} }}
> >
{children} {children}

View File

@ -3,8 +3,6 @@ import React, { useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import arrow from "../public/img/arrow.svg";
import close from "../public/img/close.svg";
import AddModDialog from "./AddModDialog"; import AddModDialog from "./AddModDialog";
import CellData from "./CellData"; import CellData from "./CellData";
import ModData from "./ModData"; import ModData from "./ModData";
@ -19,8 +17,6 @@ type Props = {
selectedCell: { x: number; y: number } | null; selectedCell: { x: number; y: number } | null;
clearSelectedCell: () => void; clearSelectedCell: () => void;
setSelectedCells: (cells: { x: number; y: number }[] | null) => void; setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
counts: Record<number, [number, number, number]> | null;
countsError: Error | null;
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
lastModified: string | null | undefined; lastModified: string | null | undefined;
@ -32,8 +28,6 @@ const Sidebar: React.FC<Props> = ({
selectedCell, selectedCell,
clearSelectedCell, clearSelectedCell,
setSelectedCells, setSelectedCells,
counts,
countsError,
open, open,
setOpen, setOpen,
lastModified, lastModified,
@ -46,47 +40,6 @@ const Sidebar: React.FC<Props> = ({
document.getElementById("sidebar")?.scrollTo(0, 0); document.getElementById("sidebar")?.scrollTo(0, 0);
}, [selectedCell, router.query.mod, router.query.plugin]); }, [selectedCell, router.query.mod, router.query.plugin]);
const renderLoadError = (error: Error) => (
<div>{`Error loading live download counts: ${error.message}`}</div>
);
const renderLoading = () => <div>Loading...</div>;
const renderCellData = (selectedCell: { x: number; y: number }) => {
if (countsError) return renderLoadError(countsError);
if (!counts) return renderLoading();
return <CellData selectedCell={selectedCell} counts={counts} />;
};
const renderModData = (
selectedMod: number,
selectedFile: number,
selectedPlugin: string
) => {
if (countsError) return renderLoadError(countsError);
if (!counts) return renderLoading();
return (
<ModData
selectedMod={selectedMod}
selectedFile={selectedFile}
selectedPlugin={selectedPlugin}
counts={counts}
setSelectedCells={setSelectedCells}
onSelectFile={onSelectFile}
onSelectPlugin={onSelectPlugin}
/>
);
};
const renderPluginData = (plugin: string) => {
if (countsError) return renderLoadError(countsError);
if (!counts) return renderLoading();
return <PluginDetail hash={plugin} counts={counts} />;
};
const renderLastModified = (lastModified: string | null | undefined) => { const renderLastModified = (lastModified: string | null | undefined) => {
if (lastModified) { if (lastModified) {
return ( return (
@ -115,12 +68,20 @@ const Sidebar: React.FC<Props> = ({
<h1 className={styles["cell-name-header"]}> <h1 className={styles["cell-name-header"]}>
Cell {selectedCell.x}, {selectedCell.y} Cell {selectedCell.x}, {selectedCell.y}
</h1> </h1>
{renderCellData(selectedCell)} <CellData selectedCell={selectedCell} />
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>
); );
} else if (router.query.mod) { } else if (router.query.mod) {
if (!router.query.game) {
router.replace(`/?game=skyrimspecialedition&mod=${router.query.mod}`);
return null;
}
const game =
typeof router.query.game === "string"
? router.query.game
: router.query.game[0];
const modId = parseInt(router.query.mod as string, 10); const modId = parseInt(router.query.mod as string, 10);
const fileId = parseInt(router.query.file as string, 10); const fileId = parseInt(router.query.file as string, 10);
const pluginHash = router.query.plugin as string; const pluginHash = router.query.plugin as string;
@ -136,7 +97,17 @@ const Sidebar: React.FC<Props> = ({
<img src="/img/close.svg" width={24} height={24} alt="close" /> <img src="/img/close.svg" width={24} height={24} alt="close" />
</button> </button>
</div> </div>
{!Number.isNaN(modId) && renderModData(modId, fileId, pluginHash)} {!Number.isNaN(modId) && (
<ModData
game={game}
selectedMod={modId}
selectedFile={fileId}
selectedPlugin={pluginHash}
setSelectedCells={setSelectedCells}
onSelectFile={onSelectFile}
onSelectPlugin={onSelectPlugin}
/>
)}
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>
@ -154,11 +125,13 @@ const Sidebar: React.FC<Props> = ({
<img src="/img/close.svg" width={24} height={24} alt="close" /> <img src="/img/close.svg" width={24} height={24} alt="close" />
</button> </button>
</div> </div>
{renderPluginData( <PluginDetail
typeof router.query.plugin === "string" hash={
? router.query.plugin typeof router.query.plugin === "string"
: router.query.plugin[0] ? router.query.plugin
)} : router.query.plugin[0]
}
/>
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>
@ -179,7 +152,7 @@ const Sidebar: React.FC<Props> = ({
<PluginTxtEditor /> <PluginTxtEditor />
<ParsedPluginsList /> <ParsedPluginsList />
<FetchedPluginsList /> <FetchedPluginsList />
<AddModDialog counts={counts} /> <AddModDialog />
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>

View File

@ -1,8 +1,12 @@
export async function jsonFetcher<T>(url: string): Promise<T | null> { interface Options {
notFoundOk: boolean;
}
export async function jsonFetcher<T>(url: string, options: Options = { notFoundOk: true}): Promise<T | null> {
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
if (res.status === 404) { if (res.status === 404 && options.notFoundOk) {
return null; return null;
} }
const error = new Error("An error occurred while fetching the data."); const error = new Error("An error occurred while fetching the data.");
@ -12,11 +16,11 @@ export async function jsonFetcher<T>(url: string): Promise<T | null> {
return res.json(); return res.json();
}; };
export async function jsonFetcherWithLastModified<T>(url: string): Promise<{ data: T, lastModified: string | null } | null> { export async function jsonFetcherWithLastModified<T>(url: string, options: Options = { notFoundOk: true}): Promise<{ data: T, lastModified: string | null } | null> {
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
if (res.status === 404) { if (res.status === 404 && options.notFoundOk) {
return null; return null;
} }
const error = new Error("An error occurred while fetching the data."); const error = new Error("An error occurred while fetching the data.");

View File

@ -1,7 +1,8 @@
/** @type {import('next-sitemap').IConfig} */ /** @type {import('next-sitemap').IConfig} */
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
const MOD_SEARCH_INDEX_URL = 'https://mods.modmapper.com/mod_search_index.json'; const SSE_MOD_SEARCH_INDEX_URL = 'https://mods.modmapper.com/skyrimspecialedition/mod_search_index.json';
const LE_MOD_SEARCH_INDEX_URL = 'https://mods.modmapper.com/skyrim/mod_search_index.json';
module.exports = { module.exports = {
siteUrl: process.env.SITE_URL || 'https://modmapper.com', siteUrl: process.env.SITE_URL || 'https://modmapper.com',
@ -9,12 +10,21 @@ module.exports = {
additionalPaths: async (config) => { additionalPaths: async (config) => {
const result = [] const result = []
const response = await fetch(MOD_SEARCH_INDEX_URL); const skyrimResponse = await fetch(LE_MOD_SEARCH_INDEX_URL);
const index = await response.json(); const skyrimIndex = await skyrimResponse.json();
for (const mod of index) { const skyrimspecialeditionResponse = await fetch(SSE_MOD_SEARCH_INDEX_URL);
const skyrimspecialeditionIndex = await skyrimspecialeditionResponse.json();
for (const mod of skyrimIndex) {
result.push({ result.push({
loc: '/?mod=' + mod.id, loc: `/?game=skyrim&mod=${mod.id}`,
changefreq: 'daily',
});
}
for (const mod of skyrimspecialeditionIndex) {
result.push({
loc: `/?game=skyrimspecialedition&mod=${mod.id}`,
changefreq: 'daily', changefreq: 'daily',
}); });
} }

View File

@ -1,6 +1,5 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit" import { createSlice, PayloadAction } from "@reduxjs/toolkit"
import type { AppState, AppThunk } from "../lib/store"
import type { Mod } from '../components/CellData'; import type { Mod } from '../components/CellData';
export type ModWithCounts = Mod & { export type ModWithCounts = Mod & {
@ -14,6 +13,7 @@ export type ModListFiltersState = {
sortBy: keyof ModWithCounts, sortBy: keyof ModWithCounts,
sortAsc: boolean, sortAsc: boolean,
filter?: string, filter?: string,
game: string,
category: string, category: string,
includeTranslations: boolean, includeTranslations: boolean,
} }
@ -22,6 +22,7 @@ const initialState: ModListFiltersState = {
sortBy: "unique_downloads", sortBy: "unique_downloads",
sortAsc: false, sortAsc: false,
filter: undefined, filter: undefined,
game: "All",
category: "All", category: "All",
includeTranslations: true, includeTranslations: true,
}; };
@ -42,6 +43,10 @@ export const modListFiltersSlice = createSlice({
...state, ...state,
filter: action.payload, filter: action.payload,
}), }),
setGame: (state, action: PayloadAction<string>) => ({
...state,
game: action.payload,
}),
setCategory: (state, action: PayloadAction<string>) => ({ setCategory: (state, action: PayloadAction<string>) => ({
...state, ...state,
category: action.payload, category: action.payload,
@ -54,6 +59,6 @@ export const modListFiltersSlice = createSlice({
}, },
}) })
export const { setSortBy, setSortAsc, setFilter, setCategory, setIncludeTranslations, clearFilters } = modListFiltersSlice.actions export const { setSortBy, setSortAsc, setFilter, setGame, setCategory, setIncludeTranslations, clearFilters } = modListFiltersSlice.actions
export default modListFiltersSlice.reducer export default modListFiltersSlice.reducer

View File

@ -70,6 +70,11 @@
width: 175px; width: 175px;
} }
.game {
min-width: 175px;
width: 175px;
}
.filter { .filter {
width: 175px; width: 175px;
} }