Merge pull request #6 from thallada/skyrim-le
Support for multiple games
This commit is contained in:
commit
8adb07e70f
@ -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")}
|
||||||
|
@ -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>
|
||||||
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
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 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,13 +821,13 @@ const Map: React.FC = () => {
|
|||||||
ref={mapWrapper}
|
ref={mapWrapper}
|
||||||
>
|
>
|
||||||
<div ref={mapContainer} className={styles["map-container"]}>
|
<div ref={mapContainer} className={styles["map-container"]}>
|
||||||
|
<DownloadCountsProvider>
|
||||||
|
<GamesProvider>
|
||||||
<SearchProvider>
|
<SearchProvider>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
selectedCell={selectedCell}
|
selectedCell={selectedCell}
|
||||||
clearSelectedCell={() => router.push({ query: {} })}
|
clearSelectedCell={() => router.push({ query: {} })}
|
||||||
setSelectedCells={setSelectedCells}
|
setSelectedCells={setSelectedCells}
|
||||||
counts={counts}
|
|
||||||
countsError={countsError}
|
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
setOpen={setSidebarOpenWithResize}
|
setOpen={setSidebarOpenWithResize}
|
||||||
lastModified={cellsData && cellsData.lastModified}
|
lastModified={cellsData && cellsData.lastModified}
|
||||||
@ -876,7 +855,6 @@ const Map: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<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) => {
|
||||||
@ -896,6 +874,8 @@ const Map: React.FC = () => {
|
|||||||
fixed
|
fixed
|
||||||
/>
|
/>
|
||||||
</SearchProvider>
|
</SearchProvider>
|
||||||
|
</GamesProvider>
|
||||||
|
</DownloadCountsProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -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: </strong>
|
<strong>Category: </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: </strong>
|
<strong>Author: </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>
|
||||||
|
@ -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: </strong>
|
<strong>Category: </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: </strong>
|
<strong>Author: </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"
|
||||||
>
|
>
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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}`}
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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>>;
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const { data, error } = useSWRImmutable(
|
|
||||||
`https://mods.modmapper.com/mod_search_index.json`,
|
|
||||||
(_) => jsonFetcher<Mod[]>(_)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data && !modSearch.current) {
|
|
||||||
modSearch.current = new MiniSearch({
|
|
||||||
fields: ["name"],
|
fields: ["name"],
|
||||||
storeFields: ["name", "id"],
|
storeFields: ["name", "id", "game"],
|
||||||
searchOptions: {
|
searchOptions: {
|
||||||
fields: ["name"],
|
fields: ["name"],
|
||||||
fuzzy: 0.2,
|
fuzzy: 0.2,
|
||||||
prefix: true,
|
prefix: true,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
modSearch.current.addAllAsync(data).then(() => {
|
) as React.MutableRefObject<MiniSearch<ModWithGame>>;
|
||||||
setLoading(false);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [skyrimLoading, setSkyrimLoading] = useState(true);
|
||||||
|
const [skyrimspecialEditionLoading, setSkyrimspecialeditionLoading] =
|
||||||
|
useState(true);
|
||||||
|
|
||||||
|
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 (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 || 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}
|
||||||
|
@ -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
|
||||||
|
hash={
|
||||||
typeof router.query.plugin === "string"
|
typeof router.query.plugin === "string"
|
||||||
? router.query.plugin
|
? router.query.plugin
|
||||||
: router.query.plugin[0]
|
: 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>
|
||||||
|
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);
|
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.");
|
||||||
|
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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
|
@ -70,6 +70,11 @@
|
|||||||
width: 175px;
|
width: 175px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game {
|
||||||
|
min-width: 175px;
|
||||||
|
width: 175px;
|
||||||
|
}
|
||||||
|
|
||||||
.filter {
|
.filter {
|
||||||
width: 175px;
|
width: 175px;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user