Add support for multiple games
Just supporting skyrim and skyrimspecialedition for now. Move live download count fetching to a context provider. Also adds new games fetching context provider.
This commit is contained in:
parent
d103451a40
commit
5ff11d568e
@ -1,24 +1,30 @@
|
||||
import { format } from "date-fns";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback, useContext, useState } from "react";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
|
||||
import { Mod, File, NEXUS_MODS_URL } from "./ModData";
|
||||
import styles from "../styles/AddModData.module.css";
|
||||
import { jsonFetcher } from "../lib/api";
|
||||
import { DownloadCountsContext } from "./DownloadCountsProvider";
|
||||
import { GamesContext } from "./GamesProvider";
|
||||
|
||||
type Props = {
|
||||
selectedMod: number;
|
||||
selectedPlugin: string | null;
|
||||
setSelectedPlugin: (plugin: string) => void;
|
||||
counts: Record<number, [number, number, number]> | null;
|
||||
};
|
||||
|
||||
const AddModData: React.FC<Props> = ({
|
||||
selectedMod,
|
||||
selectedPlugin,
|
||||
setSelectedPlugin,
|
||||
counts,
|
||||
}) => {
|
||||
const {
|
||||
games,
|
||||
getGameNameById,
|
||||
error: gamesError,
|
||||
} = useContext(GamesContext);
|
||||
const counts = useContext(DownloadCountsContext);
|
||||
const [selectedFile, setSelectedFile] = useState<number | null>(null);
|
||||
|
||||
const { data: modData, error: modError } = useSWRImmutable(
|
||||
@ -54,11 +60,27 @@ const AddModData: React.FC<Props> = ({
|
||||
return <div className={styles.status}>Mod could not be found.</div>;
|
||||
|
||||
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 unique_downloads = modCounts ? modCounts[1] : 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) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
@ -97,6 +119,12 @@ const AddModData: React.FC<Props> = ({
|
||||
<strong>Uploaded:</strong>{" "}
|
||||
{format(new Date(modData.first_upload_at), "d MMM y")}
|
||||
</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>
|
||||
<strong>Last Update:</strong>{" "}
|
||||
{format(new Date(modData.last_update_at), "d MMM y")}
|
||||
|
@ -10,11 +10,7 @@ import { updateFetchedPlugin, PluginsByHashWithMods } from "../slices/plugins";
|
||||
import styles from "../styles/AddModDialog.module.css";
|
||||
import EscapeListener from "./EscapeListener";
|
||||
|
||||
type Props = {
|
||||
counts: Record<number, [number, number, number]> | null;
|
||||
};
|
||||
|
||||
const AddModDialog: React.FC<Props> = ({ counts }) => {
|
||||
const AddModDialog: React.FC = () => {
|
||||
const [selectedMod, setSelectedMod] = useState<number | null>(null);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<string | null>(null);
|
||||
const [dialogShown, setDialogShown] = useState(false);
|
||||
@ -45,7 +41,6 @@ const AddModDialog: React.FC<Props> = ({ counts }) => {
|
||||
<dialog open={dialogShown} className={styles.dialog}>
|
||||
<h3>Add mod</h3>
|
||||
<SearchBar
|
||||
counts={counts}
|
||||
sidebarOpen={false}
|
||||
placeholder="Search mods…"
|
||||
onSelectResult={(selectedItem) => {
|
||||
@ -60,7 +55,6 @@ const AddModDialog: React.FC<Props> = ({ counts }) => {
|
||||
selectedMod={selectedMod}
|
||||
selectedPlugin={selectedPlugin}
|
||||
setSelectedPlugin={setSelectedPlugin}
|
||||
counts={counts}
|
||||
/>
|
||||
)}
|
||||
<menu>
|
||||
|
@ -40,10 +40,9 @@ export interface Cell {
|
||||
|
||||
type Props = {
|
||||
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(
|
||||
`https://cells.modmapper.com/${selectedCell.x}/${selectedCell.y}.json`,
|
||||
(_) => jsonFetcher<Cell>(_)
|
||||
@ -113,7 +112,7 @@ const CellData: React.FC<Props> = ({ selectedCell, counts }) => {
|
||||
</ul>
|
||||
<ParsedPluginsList selectedCell={selectedCell} />
|
||||
<FetchedPluginsList selectedCell={selectedCell} />
|
||||
<ModList mods={data.mods} counts={counts} />
|
||||
<ModList mods={data.mods} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
86
components/DownloadCountsProvider.tsx
Normal file
86
components/DownloadCountsProvider.tsx
Normal 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;
|
53
components/GamesProvider.tsx
Normal file
53
components/GamesProvider.tsx
Normal 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;
|
@ -11,7 +11,9 @@ import Sidebar from "./Sidebar";
|
||||
import ToggleLayersControl from "./ToggleLayersControl";
|
||||
import SearchBar from "./SearchBar";
|
||||
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 ?? "";
|
||||
|
||||
@ -25,9 +27,6 @@ colorGradient.setGradient(
|
||||
);
|
||||
colorGradient.setMidpoint(360);
|
||||
|
||||
const LIVE_DOWNLOAD_COUNTS_URL =
|
||||
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
|
||||
|
||||
const Map: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const mapContainer = useRef<HTMLDivElement | null>(
|
||||
@ -69,15 +68,6 @@ const Map: React.FC = () => {
|
||||
"https://cells.modmapper.com/edits.json",
|
||||
(_) => 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(
|
||||
(cell: { x: number; y: number }) => {
|
||||
@ -822,17 +812,6 @@ const Map: React.FC = () => {
|
||||
setHeatmapLoaded(true);
|
||||
}, [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 (
|
||||
<>
|
||||
<div
|
||||
@ -842,60 +821,61 @@ const Map: React.FC = () => {
|
||||
ref={mapWrapper}
|
||||
>
|
||||
<div ref={mapContainer} className={styles["map-container"]}>
|
||||
<SearchProvider>
|
||||
<Sidebar
|
||||
selectedCell={selectedCell}
|
||||
clearSelectedCell={() => router.push({ query: {} })}
|
||||
setSelectedCells={setSelectedCells}
|
||||
counts={counts}
|
||||
countsError={countsError}
|
||||
open={sidebarOpen}
|
||||
setOpen={setSidebarOpenWithResize}
|
||||
lastModified={cellsData && cellsData.lastModified}
|
||||
onSelectFile={(selectedFile) => {
|
||||
const { plugin, ...withoutPlugin } = router.query;
|
||||
if (selectedFile) {
|
||||
router.push({
|
||||
query: { ...withoutPlugin, file: selectedFile },
|
||||
});
|
||||
} else {
|
||||
const { file, ...withoutFile } = withoutPlugin;
|
||||
router.push({ query: { ...withoutFile } });
|
||||
}
|
||||
}}
|
||||
onSelectPlugin={(selectedPlugin) => {
|
||||
if (selectedPlugin) {
|
||||
router.push({
|
||||
query: { ...router.query, plugin: selectedPlugin },
|
||||
});
|
||||
} else {
|
||||
const { plugin, ...withoutPlugin } = router.query;
|
||||
router.push({ query: { ...withoutPlugin } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ToggleLayersControl map={map} />
|
||||
<SearchBar
|
||||
counts={counts}
|
||||
sidebarOpen={sidebarOpen}
|
||||
placeholder="Search mods or cells…"
|
||||
onSelectResult={(selectedItem) => {
|
||||
if (!selectedItem) return;
|
||||
if (
|
||||
selectedItem.x !== undefined &&
|
||||
selectedItem.y !== undefined
|
||||
) {
|
||||
router.push({
|
||||
query: { cell: `${selectedItem.x},${selectedItem.y}` },
|
||||
});
|
||||
} else {
|
||||
router.push({ query: { mod: selectedItem.id } });
|
||||
}
|
||||
}}
|
||||
includeCells
|
||||
fixed
|
||||
/>
|
||||
</SearchProvider>
|
||||
<DownloadCountsProvider>
|
||||
<GamesProvider>
|
||||
<SearchProvider>
|
||||
<Sidebar
|
||||
selectedCell={selectedCell}
|
||||
clearSelectedCell={() => router.push({ query: {} })}
|
||||
setSelectedCells={setSelectedCells}
|
||||
open={sidebarOpen}
|
||||
setOpen={setSidebarOpenWithResize}
|
||||
lastModified={cellsData && cellsData.lastModified}
|
||||
onSelectFile={(selectedFile) => {
|
||||
const { plugin, ...withoutPlugin } = router.query;
|
||||
if (selectedFile) {
|
||||
router.push({
|
||||
query: { ...withoutPlugin, file: selectedFile },
|
||||
});
|
||||
} else {
|
||||
const { file, ...withoutFile } = withoutPlugin;
|
||||
router.push({ query: { ...withoutFile } });
|
||||
}
|
||||
}}
|
||||
onSelectPlugin={(selectedPlugin) => {
|
||||
if (selectedPlugin) {
|
||||
router.push({
|
||||
query: { ...router.query, plugin: selectedPlugin },
|
||||
});
|
||||
} else {
|
||||
const { plugin, ...withoutPlugin } = router.query;
|
||||
router.push({ query: { ...withoutPlugin } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ToggleLayersControl map={map} />
|
||||
<SearchBar
|
||||
sidebarOpen={sidebarOpen}
|
||||
placeholder="Search mods or cells…"
|
||||
onSelectResult={(selectedItem) => {
|
||||
if (!selectedItem) return;
|
||||
if (
|
||||
selectedItem.x !== undefined &&
|
||||
selectedItem.y !== undefined
|
||||
) {
|
||||
router.push({
|
||||
query: { cell: `${selectedItem.x},${selectedItem.y}` },
|
||||
});
|
||||
} else {
|
||||
router.push({ query: { mod: selectedItem.id } });
|
||||
}
|
||||
}}
|
||||
includeCells
|
||||
fixed
|
||||
/>
|
||||
</SearchProvider>
|
||||
</GamesProvider>
|
||||
</DownloadCountsProvider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { format } from "date-fns";
|
||||
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 { useAppDispatch, useAppSelector } from "../lib/hooks";
|
||||
@ -13,6 +13,8 @@ import {
|
||||
updateFetchedPlugin,
|
||||
} from "../slices/plugins";
|
||||
import Link from "next/link";
|
||||
import { DownloadCountsContext } from "./DownloadCountsProvider";
|
||||
import { GamesContext } from "./GamesProvider";
|
||||
|
||||
export interface CellCoord {
|
||||
x: number;
|
||||
@ -83,7 +85,6 @@ type Props = {
|
||||
selectedMod: number;
|
||||
selectedFile: number;
|
||||
selectedPlugin: string;
|
||||
counts: Record<number, [number, number, number]> | null;
|
||||
setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
|
||||
onSelectFile: (fileId: number) => void;
|
||||
onSelectPlugin: (hash: string) => void;
|
||||
@ -93,11 +94,16 @@ const ModData: React.FC<Props> = ({
|
||||
selectedMod,
|
||||
selectedFile,
|
||||
selectedPlugin,
|
||||
counts,
|
||||
setSelectedCells,
|
||||
onSelectFile,
|
||||
onSelectPlugin,
|
||||
}) => {
|
||||
const {
|
||||
games,
|
||||
getGameNameById,
|
||||
error: gamesError,
|
||||
} = useContext(GamesContext);
|
||||
const counts = useContext(DownloadCountsContext);
|
||||
const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] =
|
||||
useState<boolean>(false);
|
||||
const { data: modData, error: modError } = useSWRImmutable(
|
||||
@ -149,6 +155,19 @@ const ModData: React.FC<Props> = ({
|
||||
if (pluginData) setSelectedCells(pluginData.cells);
|
||||
}, [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) {
|
||||
return <div>Mod could not be found.</div>;
|
||||
} else if (modError) {
|
||||
@ -160,7 +179,10 @@ const ModData: React.FC<Props> = ({
|
||||
return <div className={styles.status}>Mod could not be found.</div>;
|
||||
|
||||
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 unique_downloads = modCounts ? modCounts[1] : 0;
|
||||
const views = modCounts ? modCounts[2] : 0;
|
||||
@ -240,6 +262,12 @@ const ModData: React.FC<Props> = ({
|
||||
<strong>Last Update:</strong>{" "}
|
||||
{format(new Date(modData.last_update_at), "d MMM y")}
|
||||
</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>
|
||||
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
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 Link from "next/link";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
@ -20,6 +20,8 @@ import {
|
||||
setIncludeTranslations,
|
||||
} from "../slices/modListFilters";
|
||||
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
||||
import { DownloadCountsContext } from "./DownloadCountsProvider";
|
||||
import { GamesContext } from "./GamesProvider";
|
||||
|
||||
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
|
||||
const PAGE_SIZE = 50;
|
||||
@ -27,10 +29,15 @@ const PAGE_SIZE = 50;
|
||||
type Props = {
|
||||
mods: Mod[];
|
||||
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 { sortBy, sortAsc, filter, category, includeTranslations } =
|
||||
useAppSelector((state) => state.modListFilters);
|
||||
@ -44,7 +51,10 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
||||
|
||||
const modsWithCounts: ModWithCounts[] = mods
|
||||
.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 {
|
||||
...mod,
|
||||
total_downloads: modCounts ? modCounts[0] : 0,
|
||||
@ -82,6 +92,19 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
||||
|
||||
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>(
|
||||
null
|
||||
) as React.MutableRefObject<MiniSearch<Mod>>;
|
||||
@ -247,6 +270,13 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
||||
</div>
|
||||
{renderPagination()}
|
||||
<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
|
||||
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
|
||||
.map((mod) => (
|
||||
|
@ -16,10 +16,9 @@ export interface Plugin {
|
||||
|
||||
type Props = {
|
||||
plugin: Plugin;
|
||||
counts: Record<number, [number, number, number]> | null;
|
||||
};
|
||||
|
||||
const PluginData: React.FC<Props> = ({ plugin, counts }) => {
|
||||
const PluginData: React.FC<Props> = ({ plugin }) => {
|
||||
if (!plugin) {
|
||||
return <h3>Plugin could not be found.</h3>;
|
||||
}
|
||||
|
@ -45,10 +45,9 @@ const buildPluginProps = (
|
||||
|
||||
type Props = {
|
||||
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] =
|
||||
useState<boolean>(false);
|
||||
|
||||
@ -83,10 +82,7 @@ const PluginDetail: React.FC<Props> = ({ hash, counts }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginData
|
||||
plugin={buildPluginProps(data, parsedPlugin)}
|
||||
counts={counts}
|
||||
/>
|
||||
<PluginData plugin={buildPluginProps(data, parsedPlugin)} />
|
||||
{data && (
|
||||
<>
|
||||
<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 && (
|
||||
<div className={styles.error}>
|
||||
{`Error parsing plugin: ${parsedPlugin.parseError}`}
|
||||
|
@ -4,9 +4,10 @@ import { SearchResult } from "minisearch";
|
||||
|
||||
import { SearchContext } from "./SearchProvider";
|
||||
import styles from "../styles/SearchBar.module.css";
|
||||
import { DownloadCountsContext } from "./DownloadCountsProvider";
|
||||
import { GameName } from "./GamesProvider";
|
||||
|
||||
type Props = {
|
||||
counts: Record<number, [number, number, number]> | null;
|
||||
sidebarOpen: boolean;
|
||||
placeholder: string;
|
||||
onSelectResult: (item: SearchResult | null) => void;
|
||||
@ -21,7 +22,6 @@ interface Mod {
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<Props> = ({
|
||||
counts,
|
||||
sidebarOpen,
|
||||
placeholder,
|
||||
onSelectResult,
|
||||
@ -29,12 +29,28 @@ const SearchBar: React.FC<Props> = ({
|
||||
fixed = false,
|
||||
inputRef,
|
||||
}) => {
|
||||
const counts = useContext(DownloadCountsContext);
|
||||
const { cellSearch, modSearch, loading, loadError } =
|
||||
useContext(SearchContext);
|
||||
const searchInput = useRef<HTMLInputElement | null>(null);
|
||||
const [searchFocused, setSearchFocused] = useState<boolean>(false);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
|
||||
const renderSearchIndexError = (error: Error) => {
|
||||
<div className={styles.error}>
|
||||
Error loading mod search index: {loadError}.
|
||||
</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>
|
||||
);
|
||||
console.log(loadError);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getMenuProps,
|
||||
@ -57,9 +73,11 @@ const SearchBar: React.FC<Props> = ({
|
||||
) {
|
||||
results = results.concat(
|
||||
modSearch.search(inputValue).sort((resultA, resultB) => {
|
||||
if (counts) {
|
||||
const countA = counts[resultA.id];
|
||||
const countB = counts[resultB.id];
|
||||
const countsA = counts[resultA.game as GameName].counts;
|
||||
const countsB = counts[resultB.game as GameName].counts;
|
||||
if (countsA && countsB) {
|
||||
const countA = countsA[resultA.id];
|
||||
const countB = countsB[resultB.id];
|
||||
const scoreA = resultA.score;
|
||||
const scoreB = resultB.score;
|
||||
if (countA && countB && scoreA && scoreB) {
|
||||
@ -133,11 +151,13 @@ const SearchBar: React.FC<Props> = ({
|
||||
{result.name}
|
||||
</li>
|
||||
))}
|
||||
{loadError && (
|
||||
<div className={styles.error}>
|
||||
Error loading mod search index: {loadError}.
|
||||
</div>
|
||||
)}
|
||||
{loadError && renderSearchIndexError(loadError)}
|
||||
{counts.skyrim.error &&
|
||||
renderDownloadCountsError(counts.skyrim.error)}
|
||||
{counts.skyrimspecialedition.error &&
|
||||
renderDownloadCountsError(counts.skyrimspecialedition.error)}
|
||||
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) &&
|
||||
renderDownloadCountsLoading()}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
|
@ -3,12 +3,19 @@ import MiniSearch from "minisearch";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
|
||||
import { jsonFetcher } from "../lib/api";
|
||||
import type { GameName } from "./GamesProvider";
|
||||
|
||||
interface Mod {
|
||||
name: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ModWithGame {
|
||||
name: string;
|
||||
id: number;
|
||||
game: GameName;
|
||||
}
|
||||
|
||||
let cells = [];
|
||||
|
||||
for (let x = -77; x < 76; x++) {
|
||||
@ -42,32 +49,62 @@ export const SearchContext = createContext<SearchContext>({
|
||||
});
|
||||
|
||||
const SearchProvider: React.FC = ({ children }) => {
|
||||
const modSearch = useRef<MiniSearch<Mod> | null>(
|
||||
null
|
||||
) as React.MutableRefObject<MiniSearch<Mod>>;
|
||||
const modSearch = useRef<MiniSearch<ModWithGame>>(
|
||||
new MiniSearch({
|
||||
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 [skyrimLoading, setSkyrimLoading] = useState(true);
|
||||
const [skyrimspecialEditionLoading, setSkyrimspecialeditionLoading] =
|
||||
useState(true);
|
||||
|
||||
const { data, error } = useSWRImmutable(
|
||||
`https://mods.modmapper.com/mod_search_index.json`,
|
||||
(_) => jsonFetcher<Mod[]>(_)
|
||||
const { data: skyrimData, error: skyrimError } = useSWRImmutable(
|
||||
`https://mods.modmapper.com/skyrim_mod_search_index.json`,
|
||||
(_) => jsonFetcher<Mod[]>(_, { notFoundOk: false })
|
||||
);
|
||||
const { data: skyrimspecialeditionData, error: skyrimspecialeditionError } =
|
||||
useSWRImmutable(
|
||||
`https://mods.modmapper.com/skyrimspecialedition_mod_search_index.json`,
|
||||
(_) => jsonFetcher<Mod[]>(_, { notFoundOk: false })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !modSearch.current) {
|
||||
modSearch.current = new MiniSearch({
|
||||
fields: ["name"],
|
||||
storeFields: ["name", "id"],
|
||||
searchOptions: {
|
||||
fields: ["name"],
|
||||
fuzzy: 0.2,
|
||||
prefix: true,
|
||||
},
|
||||
});
|
||||
modSearch.current.addAllAsync(data).then(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
if (skyrimData) {
|
||||
modSearch.current
|
||||
.addAllAsync(skyrimData.map((mod) => ({ ...mod, game: "skyrim" })))
|
||||
.then(() => {
|
||||
setSkyrimLoading(false);
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
}, [skyrimData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skyrimspecialeditionData) {
|
||||
modSearch.current
|
||||
.addAllAsync(
|
||||
skyrimspecialeditionData.map((mod) => ({
|
||||
...mod,
|
||||
game: "skyrimspecialedition",
|
||||
}))
|
||||
)
|
||||
.then(() => {
|
||||
setSkyrimspecialeditionLoading(false);
|
||||
});
|
||||
}
|
||||
}, [skyrimspecialeditionData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skyrimLoading && !skyrimspecialEditionLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [skyrimLoading, skyrimspecialEditionLoading]);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider
|
||||
@ -75,7 +112,7 @@ const SearchProvider: React.FC = ({ children }) => {
|
||||
modSearch: modSearch.current,
|
||||
cellSearch,
|
||||
loading,
|
||||
loadError: error,
|
||||
loadError: skyrimspecialeditionError || skyrimError,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -3,8 +3,6 @@ import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { formatRelative } from "date-fns";
|
||||
|
||||
import arrow from "../public/img/arrow.svg";
|
||||
import close from "../public/img/close.svg";
|
||||
import AddModDialog from "./AddModDialog";
|
||||
import CellData from "./CellData";
|
||||
import ModData from "./ModData";
|
||||
@ -19,8 +17,6 @@ type Props = {
|
||||
selectedCell: { x: number; y: number } | null;
|
||||
clearSelectedCell: () => void;
|
||||
setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
|
||||
counts: Record<number, [number, number, number]> | null;
|
||||
countsError: Error | null;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
lastModified: string | null | undefined;
|
||||
@ -32,8 +28,6 @@ const Sidebar: React.FC<Props> = ({
|
||||
selectedCell,
|
||||
clearSelectedCell,
|
||||
setSelectedCells,
|
||||
counts,
|
||||
countsError,
|
||||
open,
|
||||
setOpen,
|
||||
lastModified,
|
||||
@ -46,47 +40,6 @@ const Sidebar: React.FC<Props> = ({
|
||||
document.getElementById("sidebar")?.scrollTo(0, 0);
|
||||
}, [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) => {
|
||||
if (lastModified) {
|
||||
return (
|
||||
@ -115,7 +68,7 @@ const Sidebar: React.FC<Props> = ({
|
||||
<h1 className={styles["cell-name-header"]}>
|
||||
Cell {selectedCell.x}, {selectedCell.y}
|
||||
</h1>
|
||||
{renderCellData(selectedCell)}
|
||||
<CellData selectedCell={selectedCell} />;
|
||||
{renderLastModified(lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
@ -136,7 +89,16 @@ const Sidebar: React.FC<Props> = ({
|
||||
<img src="/img/close.svg" width={24} height={24} alt="close" />
|
||||
</button>
|
||||
</div>
|
||||
{!Number.isNaN(modId) && renderModData(modId, fileId, pluginHash)}
|
||||
{!Number.isNaN(modId) && (
|
||||
<ModData
|
||||
selectedMod={modId}
|
||||
selectedFile={fileId}
|
||||
selectedPlugin={pluginHash}
|
||||
setSelectedCells={setSelectedCells}
|
||||
onSelectFile={onSelectFile}
|
||||
onSelectPlugin={onSelectPlugin}
|
||||
/>
|
||||
)}
|
||||
{renderLastModified(lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
@ -154,12 +116,14 @@ const Sidebar: React.FC<Props> = ({
|
||||
<img src="/img/close.svg" width={24} height={24} alt="close" />
|
||||
</button>
|
||||
</div>
|
||||
{renderPluginData(
|
||||
typeof router.query.plugin === "string"
|
||||
? router.query.plugin
|
||||
: router.query.plugin[0]
|
||||
)}
|
||||
{renderLastModified(lastModified)}
|
||||
<PluginDetail
|
||||
hash={
|
||||
typeof router.query.plugin === "string"
|
||||
? router.query.plugin
|
||||
: router.query.plugin[0]
|
||||
}
|
||||
/>
|
||||
;{renderLastModified(lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -179,7 +143,7 @@ const Sidebar: React.FC<Props> = ({
|
||||
<PluginTxtEditor />
|
||||
<ParsedPluginsList />
|
||||
<FetchedPluginsList />
|
||||
<AddModDialog counts={counts} />
|
||||
<AddModDialog />
|
||||
{renderLastModified(lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
|
12
lib/api.ts
12
lib/api.ts
@ -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);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
if (res.status === 404 && options.notFoundOk) {
|
||||
return null;
|
||||
}
|
||||
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();
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
if (res.status === 404 && options.notFoundOk) {
|
||||
return null;
|
||||
}
|
||||
const error = new Error("An error occurred while fetching the data.");
|
||||
|
Loading…
Reference in New Issue
Block a user