Finish support for multiple games

* Adds game filter to ModList.
* Prefixes search results with game
* Adds `&game=X` to URL for mods
* Loads json from new directories on static server
This commit is contained in:
Tyler Hallada 2022-09-03 15:41:44 -04:00
parent 5ff11d568e
commit 8f254ef761
10 changed files with 134 additions and 39 deletions

View File

@ -7,9 +7,10 @@ import styles from "../styles/AddModData.module.css";
import { jsonFetcher } from "../lib/api"; import { jsonFetcher } from "../lib/api";
import { DownloadCountsContext } from "./DownloadCountsProvider"; import { DownloadCountsContext } from "./DownloadCountsProvider";
import { GamesContext } from "./GamesProvider"; 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;
}; };
@ -28,7 +29,9 @@ const AddModData: React.FC<Props> = ({
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(

View File

@ -1,5 +1,5 @@
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import React, { useCallback, useEffect, useState, useRef } from "react"; import React, { useCallback, useState, useRef } from "react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
@ -10,8 +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";
export interface SelectedMod {
id: number;
game: string;
}
const AddModDialog: React.FC = () => { 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,7 +50,10 @@ const AddModDialog: React.FC = () => {
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}

View File

@ -79,9 +79,10 @@ 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;
@ -91,6 +92,7 @@ type Props = {
}; };
const ModData: React.FC<Props> = ({ const ModData: React.FC<Props> = ({
game,
selectedMod, selectedMod,
selectedFile, selectedFile,
selectedPlugin, selectedPlugin,
@ -107,7 +109,7 @@ const ModData: React.FC<Props> = ({
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>(_)
); );
@ -220,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}
@ -236,7 +242,9 @@ const ModData: React.FC<Props> = ({
<div> <div>
<strong>Category:&nbsp;</strong> <strong>Category:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/mods/categories/${modData.category_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
modData.game_id
)}/mods/categories/${modData.category_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -247,7 +255,9 @@ const ModData: React.FC<Props> = ({
<div> <div>
<strong>Author:&nbsp;</strong> <strong>Author:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/users/${modData.author_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
modData.game_id
)}/users/${modData.author_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >

View File

@ -16,6 +16,7 @@ import {
setSortBy, setSortBy,
setSortAsc, setSortAsc,
setFilter, setFilter,
setGame,
setCategory, setCategory,
setIncludeTranslations, setIncludeTranslations,
} from "../slices/modListFilters"; } from "../slices/modListFilters";
@ -23,7 +24,7 @@ import { useAppDispatch, useAppSelector } from "../lib/hooks";
import { DownloadCountsContext } from "./DownloadCountsProvider"; import { DownloadCountsContext } from "./DownloadCountsProvider";
import { GamesContext } from "./GamesProvider"; 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 = {
@ -39,7 +40,7 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
} = useContext(GamesContext); } = useContext(GamesContext);
const counts = useContext(DownloadCountsContext); 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);
@ -69,6 +70,7 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
(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) => {
@ -226,6 +228,26 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
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
@ -283,14 +305,20 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
<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"
> >
@ -300,7 +328,9 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
<div> <div>
<strong>Category:&nbsp;</strong> <strong>Category:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
mod.game_id
)}/mods/categories/${mod.category_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -310,7 +340,9 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
<div> <div>
<strong>Author:&nbsp;</strong> <strong>Author:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/users/${mod.author_id}`} href={`${NEXUS_MODS_URL}/${getGameNameById(
mod.game_id
)}/users/${mod.author_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >

View File

@ -16,9 +16,15 @@ 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> = ({
@ -36,11 +42,11 @@ const SearchBar: React.FC<Props> = ({
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) => { const renderSearchIndexError = (error: Error) => (
<div className={styles.error}> <div className={styles.error}>
Error loading mod search index: {loadError}. Error loading mod search index: {loadError.message}.
</div>; </div>
}; );
const renderDownloadCountsLoading = () => ( const renderDownloadCountsLoading = () => (
<div>Loading live download counts...</div> <div>Loading live download counts...</div>
); );
@ -49,7 +55,6 @@ const SearchBar: React.FC<Props> = ({
className={styles.error} className={styles.error}
>{`Error loading live download counts: ${error.message}`}</div> >{`Error loading live download counts: ${error.message}`}</div>
); );
console.log(loadError);
const { const {
isOpen, isOpen,
@ -148,7 +153,7 @@ 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 && renderSearchIndexError(loadError)} {loadError && renderSearchIndexError(loadError)}

View File

@ -66,12 +66,12 @@ const SearchProvider: React.FC = ({ children }) => {
useState(true); useState(true);
const { data: skyrimData, error: skyrimError } = useSWRImmutable( const { data: skyrimData, error: skyrimError } = useSWRImmutable(
`https://mods.modmapper.com/skyrim_mod_search_index.json`, `https://mods.modmapper.com/skyrim/mod_search_index.json`,
(_) => jsonFetcher<Mod[]>(_, { notFoundOk: false }) (_) => jsonFetcher<Mod[]>(_, { notFoundOk: false })
); );
const { data: skyrimspecialeditionData, error: skyrimspecialeditionError } = const { data: skyrimspecialeditionData, error: skyrimspecialeditionError } =
useSWRImmutable( useSWRImmutable(
`https://mods.modmapper.com/skyrimspecialedition_mod_search_index.json`, `https://mods.modmapper.com/skyrimspecialedition/mod_search_index.json`,
(_) => jsonFetcher<Mod[]>(_, { notFoundOk: false }) (_) => jsonFetcher<Mod[]>(_, { notFoundOk: false })
); );
@ -101,10 +101,18 @@ const SearchProvider: React.FC = ({ children }) => {
}, [skyrimspecialeditionData]); }, [skyrimspecialeditionData]);
useEffect(() => { useEffect(() => {
if (!skyrimLoading && !skyrimspecialEditionLoading) { if (
(!skyrimLoading || skyrimError) &&
(!skyrimspecialEditionLoading || skyrimspecialeditionError)
) {
setLoading(false); setLoading(false);
} }
}, [skyrimLoading, skyrimspecialEditionLoading]); }, [
skyrimLoading,
skyrimError,
skyrimspecialEditionLoading,
skyrimspecialeditionError,
]);
return ( return (
<SearchContext.Provider <SearchContext.Provider

View File

@ -68,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>
<CellData selectedCell={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;
@ -91,6 +99,7 @@ const Sidebar: React.FC<Props> = ({
</div> </div>
{!Number.isNaN(modId) && ( {!Number.isNaN(modId) && (
<ModData <ModData
game={game}
selectedMod={modId} selectedMod={modId}
selectedFile={fileId} selectedFile={fileId}
selectedPlugin={pluginHash} selectedPlugin={pluginHash}
@ -123,7 +132,7 @@ const Sidebar: React.FC<Props> = ({
: router.query.plugin[0] : router.query.plugin[0]
} }
/> />
;{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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