Compare commits

..

No commits in common. "main" and "plugin-wasm" have entirely different histories.

56 changed files with 809 additions and 10169 deletions

View File

@ -1,4 +0,0 @@
{
"presets": ["next/babel"],
"plugins": ["add-react-displayname"]
}

3
.gitignore vendored
View File

@ -35,6 +35,3 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
# Sentry
.sentryclirc

View File

@ -2,10 +2,6 @@
This is the frontend code for the modmapper project. Modmapper is an interactive map of Skyrim mods. This is the frontend code for the modmapper project. Modmapper is an interactive map of Skyrim mods.
[View the site live at modmapper.com](https://modmapper.com).
![Screenshot of the website](/public/img/full-screenshot.jpg)
This project renders every cell edit from all Skyrim SE mods on nexusmods.com as a heatmap on top of a map of Skyrim. This project renders every cell edit from all Skyrim SE mods on nexusmods.com as a heatmap on top of a map of Skyrim.
You can click on a cell to see all of the mods that edit that cell sorted by popularity. Clicking on a mod in that list will show you all of the cells that the mod edits (across all files and versions of the mod). You can also search for a mod by name or a cell by x and y coordinates in the search bar at the top. You can click on a cell to see all of the mods that edit that cell sorted by popularity. Clicking on a mod in that list will show you all of the cells that the mod edits (across all files and versions of the mod). You can also search for a mod by name or a cell by x and y coordinates in the search bar at the top.
@ -30,15 +26,7 @@ First, install the dependencies:
npm install npm install
``` ```
Then create a file named `.env` at the root of the project with the contents: Then, run the dev server:
```
NEXT_PUBLIC_MAPBOX_TOKEN=tokengoeshere
```
You can get a Mapbox token by [creating a mapbox account and generating a token on the access token page](https://docs.mapbox.com/help/glossary/access-token/).
Now, run the dev server:
```bash ```bash
npm run dev npm run dev

View File

@ -1,186 +0,0 @@
import { format } from "date-fns";
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";
import type { SelectedMod } from "./AddModDialog";
type Props = {
selectedMod: SelectedMod;
selectedPlugin: string | null;
setSelectedPlugin: (plugin: string) => void;
};
const AddModData: React.FC<Props> = ({
selectedMod,
selectedPlugin,
setSelectedPlugin,
}) => {
const {
games,
getGameNameById,
error: gamesError,
} = useContext(GamesContext);
const counts = useContext(DownloadCountsContext);
const [selectedFile, setSelectedFile] = useState<number | null>(null);
const { data: modData, error: modError } = useSWRImmutable(
selectedMod
? `https://mods.modmapper.com/${selectedMod.game}/${selectedMod.id}.json`
: null,
(_) => jsonFetcher<Mod>(_)
);
const { data: fileData, error: fileError } = useSWRImmutable(
selectedFile ? `https://files.modmapper.com/${selectedFile}.json` : null,
(_) => jsonFetcher<File>(_)
);
const handleFileChange = useCallback(
(event) => {
setSelectedFile(event.target.value);
},
[setSelectedFile]
);
const handlePluginChange = useCallback(
(event) => {
setSelectedPlugin(event.target.value);
},
[setSelectedPlugin]
);
if (modError && modError.status === 404) {
return <div>Mod could not be found.</div>;
} else if (modError) {
return <div>{`Error loading mod data: ${modError.message}`}</div>;
}
if (modData === undefined)
return <div className={styles.status}>Loading...</div>;
if (modData === null)
return <div className={styles.status}>Mod could not be found.</div>;
let numberFmt = new Intl.NumberFormat("en-US");
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}>
<h3>
<a
href={`${NEXUS_MODS_URL}/mods/${modData.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
className={styles.name}
>
{modData.name}
</a>
</h3>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${modData.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{modData.category_name}
</a>
{modData.is_translation && <strong>&nbsp;(translation)</strong>}
</div>
<div>
<strong>Author:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/users/${modData.author_id}`}
target="_blank"
rel="noreferrer noopener"
>
{modData.author_name}
</a>
</div>
<div>
<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")}
</div>
<div>
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
</div>
<div>
<strong>Unique Downloads:</strong>{" "}
{numberFmt.format(unique_downloads)}
</div>
<div className={styles["select-container"]}>
<label htmlFor="mod-file-select" className={styles.label}>
Select file:
</label>
<select
name="file"
id="mod-file-select"
className={styles.select}
onChange={handleFileChange}
>
<option value="">--Select file--</option>
{[...modData.files].reverse().map((file) => (
<option key={file.nexus_file_id} value={file.nexus_file_id}>
{file.name} (v{file.version}) ({file.category})
</option>
))}
</select>
</div>
{fileData && (
<div className={styles["select-container"]}>
<label htmlFor="file-plugin-select" className={styles.label}>
Select plugin:
</label>
<select
name="plugin"
id="file-plugin-select"
className={styles.select}
onChange={handlePluginChange}
>
<option value="">--Select plugin--</option>
{fileData.plugins.map((plugin) => (
<option key={plugin.hash} value={plugin.hash}>
{plugin.file_path}
</option>
))}
</select>
</div>
)}
</div>
);
}
return null;
};
export default AddModData;

View File

@ -1,96 +0,0 @@
import { createPortal } from "react-dom";
import React, { useCallback, useState, useRef } from "react";
import { useDispatch } from "react-redux";
import useSWRImmutable from "swr/immutable";
import AddModData from "./AddModData";
import SearchBar from "./SearchBar";
import { jsonFetcher } from "../lib/api";
import { updateFetchedPlugin, PluginsByHashWithMods } from "../slices/plugins";
import styles from "../styles/AddModDialog.module.css";
import EscapeListener from "./EscapeListener";
export interface SelectedMod {
id: number;
game: string;
}
const AddModDialog: React.FC = () => {
const [selectedMod, setSelectedMod] = useState<SelectedMod | null>(null);
const [selectedPlugin, setSelectedPlugin] = useState<string | null>(null);
const [dialogShown, setDialogShown] = useState(false);
const searchInput = useRef<HTMLInputElement | null>(null);
const dispatch = useDispatch();
const { data, error } = useSWRImmutable(
selectedPlugin
? `https://plugins.modmapper.com/${selectedPlugin}.json`
: null,
(_) => jsonFetcher<PluginsByHashWithMods>(_)
);
const onAddModButtonClick = useCallback(async () => {
setSelectedMod(null);
setDialogShown(true);
requestAnimationFrame(() => {
if (searchInput.current) searchInput.current.focus();
});
}, [setSelectedMod, setDialogShown]);
return (
<>
<EscapeListener onEscape={() => setDialogShown(false)} />
<button onClick={onAddModButtonClick}>Add mod</button>
{typeof window !== "undefined" &&
createPortal(
<dialog open={dialogShown} className={styles.dialog}>
<h3>Add mod</h3>
<SearchBar
sidebarOpen={false}
placeholder="Search mods…"
onSelectResult={(selectedItem) => {
if (selectedItem) {
setSelectedMod({
id: selectedItem.id,
game: selectedItem.game,
});
}
}}
inputRef={searchInput}
/>
{selectedMod && (
<AddModData
selectedMod={selectedMod}
selectedPlugin={selectedPlugin}
setSelectedPlugin={setSelectedPlugin}
/>
)}
<menu>
<button
onClick={() => {
setSelectedMod(null);
setDialogShown(false);
if (searchInput.current) searchInput.current.value = "";
}}
>
Cancel
</button>
<button
onClick={() => {
if (data)
dispatch(updateFetchedPlugin({ ...data, enabled: true }));
setDialogShown(false);
}}
disabled={!selectedMod || !selectedPlugin || !data}
>
Add
</button>
</menu>
</dialog>,
document.body
)}
</>
);
};
export default AddModDialog;

View File

@ -4,9 +4,8 @@ import useSWRImmutable from "swr/immutable";
import styles from "../styles/CellData.module.css"; import styles from "../styles/CellData.module.css";
import ModList from "./ModList"; import ModList from "./ModList";
import ParsedPluginsList from "./ParsedPluginsList"; import PluginList from "./PluginsList";
import { jsonFetcher } from "../lib/api"; import { jsonFetcher } from "../lib/api";
import FetchedPluginsList from "./FetchedPluginsList";
export interface Mod { export interface Mod {
id: number; id: number;
@ -40,9 +39,10 @@ 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 }) => { const CellData: React.FC<Props> = ({ selectedCell, counts }) => {
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>(_)
@ -110,9 +110,8 @@ const CellData: React.FC<Props> = ({ selectedCell }) => {
<span>{data.plugins_count}</span> <span>{data.plugins_count}</span>
</li> </li>
</ul> </ul>
<ParsedPluginsList selectedCell={selectedCell} /> <PluginList selectedCell={selectedCell} />
<FetchedPluginsList selectedCell={selectedCell} /> <ModList mods={data.mods} counts={counts} />
<ModList mods={data.mods} />
</> </>
) )
); );

View File

@ -1,13 +1,10 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import MiniSearch from "minisearch"; import MiniSearch from "minisearch";
import ReactPaginate from "react-paginate";
import styles from "../styles/CellList.module.css"; import styles from "../styles/CellList.module.css";
import type { CellCoord } from "./ModData"; import type { CellCoord } from "./ModData";
const PAGE_SIZE = 100;
type Props = { type Props = {
cells: CellCoord[]; cells: CellCoord[];
}; };
@ -35,7 +32,6 @@ const CellList: React.FC<Props> = ({ cells }) => {
const [filter, setFilter] = useState<string>(""); const [filter, setFilter] = useState<string>("");
const [filterResults, setFilterResults] = useState<Set<string>>(new Set()); const [filterResults, setFilterResults] = useState<Set<string>>(new Set());
const [page, setPage] = useState<number>(0);
const filteredCells = cells const filteredCells = cells
.filter((cell) => !filter || filterResults.has(`${cell.x},${cell.y}`)) .filter((cell) => !filter || filterResults.has(`${cell.x},${cell.y}`))
@ -49,34 +45,10 @@ const CellList: React.FC<Props> = ({ cells }) => {
} }
}, [filter]); }, [filter]);
useEffect(() => {
setPage(0);
}, [filterResults]);
const renderPagination = () => (
<ReactPaginate
breakLabel="..."
nextLabel=">"
forcePage={page}
onPageChange={(event) => {
setPage(event.selected);
document.getElementById("exterior-cells")?.scrollIntoView();
}}
pageRangeDisplayed={3}
marginPagesDisplayed={2}
pageCount={Math.ceil(filteredCells.length / PAGE_SIZE)}
previousLabel="<"
renderOnZeroPageCount={() => null}
className={styles.pagination}
activeClassName={styles["active-page"]}
hrefBuilder={() => "#"}
/>
);
return ( return (
filteredCells && ( filteredCells && (
<> <>
<h2 id="exterior-cells">Exterior Cells ({filteredCells.length})</h2> <h2>Exterior Cells ({filteredCells.length})</h2>
<div className={styles.filters}> <div className={styles.filters}>
<hr /> <hr />
<div className={styles["filter-row"]}> <div className={styles["filter-row"]}>
@ -91,32 +63,26 @@ const CellList: React.FC<Props> = ({ cells }) => {
</div> </div>
<hr /> <hr />
</div> </div>
{renderPagination()}
<ul className={styles["cell-list"]}> <ul className={styles["cell-list"]}>
{filteredCells {filteredCells.map((cell) => (
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE) <li
.map((cell) => ( key={`cell-${cell.x},${cell.y}`}
<li className={styles["cell-list-item"]}
key={`cell-${cell.x},${cell.y}`} >
className={styles["cell-list-item"]} <div className={styles["cell-title"]}>
> <strong>
<div className={styles["cell-title"]}> <Link
<strong> href={`/?cell=${encodeURIComponent(`${cell.x},${cell.y}`)}`}
<Link >
href={`/?cell=${encodeURIComponent( <a>
`${cell.x},${cell.y}` {cell.x}, {cell.y}
)}`} </a>
> </Link>
<a> </strong>
{cell.x}, {cell.y} </div>
</a> </li>
</Link> ))}
</strong>
</div>
</li>
))}
</ul> </ul>
{renderPagination()}
</> </>
) )
); );

View File

@ -1,23 +1,18 @@
import React, { useContext, useRef, useState, useEffect } from "react"; import React, { useContext, useRef, useState, useEffect } from "react";
import Cookies from "js-cookie";
import { WorkerPoolContext } from "../lib/WorkerPool"; import { WorkerPoolContext } from "../lib/WorkerPool";
import { useAppSelector } from "../lib/hooks"; import { useAppSelector } from "../lib/hooks";
import { isPlugin, parsePluginFiles } from "../lib/plugins"; import { isPlugin, parsePluginFiles } from "../lib/plugins";
import styles from "../styles/DataDirPicker.module.css"; import styles from "../styles/DataDirPicker.module.css";
import { createPortal } from "react-dom";
type Props = {}; type Props = {};
const DataDirPicker: React.FC<Props> = () => { const DataDirPicker: React.FC<Props> = () => {
const workerPool = useContext(WorkerPoolContext); const workerPool = useContext(WorkerPoolContext);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const plugins = useAppSelector((state) => state.plugins.parsedPlugins); const plugins = useAppSelector((state) => state.plugins.plugins);
const pluginsPending = useAppSelector((state) => state.plugins.pending); const pluginsPending = useAppSelector((state) => state.plugins.pending);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [uploadNoticeShown, setUploadNoticeShown] = useState(false);
const [ignoreUploadNoticeChecked, setIgnoreUploadNoticeChecked] =
useState(false);
useEffect(() => { useEffect(() => {
if (pluginsPending === 0 && loading) { if (pluginsPending === 0 && loading) {
@ -51,9 +46,8 @@ const DataDirPicker: React.FC<Props> = () => {
return ( return (
<> <>
<p> <p className={styles["no-top-margin"]}>
<strong className={styles.step}>1. </strong>Select or drag-and-drop your Select or drag-and-drop your Skyrim{" "}
Skyrim{" "}
<strong> <strong>
<code>Data</code> <code>Data</code>
</strong>{" "} </strong>{" "}
@ -62,64 +56,7 @@ const DataDirPicker: React.FC<Props> = () => {
<br /> <br />
<br /> <br />
The Data folder can be found in the installation directory of the game. The Data folder can be found in the installation directory of the game.
<br />
<br />
For Mod Organizer users, select the mod directory located at{" "}
<strong>
<code className={styles["break-word"]}>
C:\Users\username\AppData\Local\ModOrganizer\Skyrim Special
Edition\mods
</code>
</strong>
.
</p> </p>
{typeof window !== "undefined" &&
createPortal(
<dialog open={uploadNoticeShown} className={styles.dialog}>
<p>
<strong>NOTE:</strong> the following dialog will ask you to upload
all the files in your Data folder.&nbsp;
<strong>NOTHING WILL BE UPLOADED ANYWHERE</strong>. The plugin
files will only be transferred to your browser and processed on
your device.
</p>
<p>
Drag and drop the Data folder onto the web page to avoid the
upload dialog entirely.
</p>
<label>
<input
type="checkbox"
id="ignore-upload-notice"
checked={ignoreUploadNoticeChecked}
onChange={(event) => {
if (event.target.checked) {
setIgnoreUploadNoticeChecked(true);
} else {
setIgnoreUploadNoticeChecked(false);
}
}}
/>{" "}
Don&apos;t show this message again
</label>
<menu>
<button
onClick={() => {
setUploadNoticeShown(false);
if (ignoreUploadNoticeChecked) {
Cookies.set("ignoreDataDirPickerUploadNotice", "true");
}
if (inputRef.current) {
inputRef.current.click();
}
}}
>
Ok
</button>
</menu>
</dialog>,
document.body
)}
<input <input
type="file" type="file"
webkitdirectory="" webkitdirectory=""
@ -132,11 +69,7 @@ const DataDirPicker: React.FC<Props> = () => {
onClick={() => { onClick={() => {
if (inputRef.current) { if (inputRef.current) {
inputRef.current.value = ""; // clear the value so reloading same directory works inputRef.current.value = ""; // clear the value so reloading same directory works
if (Cookies.get("ignoreDataDirPickerUploadNotice") !== "true") { inputRef.current.click();
setUploadNoticeShown(true);
} else {
inputRef.current.click();
}
} }
}} }}
disabled={!workerPool || loading} disabled={!workerPool || loading}

View File

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

View File

@ -30,7 +30,10 @@ export const DropZone: React.FC<Props> = ({ children, workerPool }) => {
} }
if (next.value.kind === "file" && isPluginPath(next.value.name)) { if (next.value.kind === "file" && isPluginPath(next.value.name)) {
plugins.push(next.value); plugins.push(next.value);
} else if (next.value.kind === "directory") { } else if (
next.value.kind === "directory" &&
next.value.name === "Data"
) {
plugins.push(...(await findPluginsInDirHandle(next.value))); plugins.push(...(await findPluginsInDirHandle(next.value)));
} }
} }

View File

@ -1,30 +0,0 @@
import React, { useCallback, useEffect } from "react";
type Props = {
onEscape: () => void;
};
const EscapeListener: React.FC<Props> = ({ onEscape }) => {
const keyHandler = useCallback(
(event) => {
switch (event.keyCode) {
case 27: // escape key
onEscape();
break;
default:
break;
}
},
[onEscape]
);
useEffect(() => {
window.addEventListener("keydown", keyHandler);
return () => window.removeEventListener("keydown", keyHandler);
}, [keyHandler]);
return null;
};
export default EscapeListener;

View File

@ -1,82 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Link from "next/link";
import React from "react";
import { useAppSelector, useAppDispatch } from "../lib/hooks";
import {
disableAllFetchedPlugins,
enableAllFetchedPlugins,
toggleFetchedPlugin,
removeFetchedPlugin,
} from "../slices/plugins";
import styles from "../styles/FetchedPluginsList.module.css";
type Props = {
selectedCell?: { x: number; y: number };
};
const FetchedPluginsList: React.FC<Props> = ({ selectedCell }) => {
const dispatch = useAppDispatch();
const plugins = useAppSelector((state) =>
selectedCell
? state.plugins.fetchedPlugins.filter((plugin) =>
plugin.cells.some(
(cell) => cell.x === selectedCell.x && cell.y === selectedCell.y
// TODO: support other worlds
)
)
: state.plugins.fetchedPlugins
);
return (
<>
{plugins.length > 0 && (
<h2 id="added-plugins">Added Plugins ({plugins.length})</h2>
)}
{!selectedCell && plugins.length > 0 && (
<div className={styles.buttons}>
<button onClick={() => dispatch(enableAllFetchedPlugins())}>
Enable all
</button>
<button onClick={() => dispatch(disableAllFetchedPlugins())}>
Disable all
</button>
</div>
)}
<ol
className={`${styles["plugin-list"]} ${
plugins.length > 0 ? styles["bottom-spacing"] : ""
}`}
>
{plugins.map((plugin) => (
<li
key={plugin.hash}
title={plugin.plugins[0].file_name}
className={styles["plugin-row"]}
>
<input
id={plugin.hash}
type="checkbox"
checked={plugin.enabled ?? false}
value={plugin.enabled ? "on" : "off"}
onChange={() => dispatch(toggleFetchedPlugin(plugin.hash))}
/>
<label htmlFor={plugin.hash} className={styles["plugin-label"]}>
<Link href={`/?plugin=${plugin.hash}`}>
<a>{plugin.plugins[0].file_name}</a>
</Link>
</label>
<button
onClick={() => dispatch(removeFetchedPlugin(plugin.hash))}
className={styles["plugin-remove"]}
>
<img src="/img/close.svg" width={18} height={18} alt="close" />
</button>
</li>
))}
</ol>
</>
);
};
export default FetchedPluginsList;

View File

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

View File

@ -5,15 +5,12 @@ import mapboxgl from "mapbox-gl";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { useAppDispatch, useAppSelector } from "../lib/hooks"; import { useAppDispatch, useAppSelector } from "../lib/hooks";
import { setSelectedFetchedPlugin, PluginFile } from "../slices/plugins"; import { setFetchedPlugin, PluginFile } from "../slices/plugins";
import styles from "../styles/Map.module.css"; import styles from "../styles/Map.module.css";
import Sidebar from "./Sidebar"; 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 { 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,7 +22,10 @@ colorGradient.setGradient(
"#FFA500", "#FFA500",
"#FF0000" "#FF0000"
); );
colorGradient.setMidpoint(730); 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();
@ -55,19 +55,23 @@ const Map: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const parsedPlugins = useAppSelector((state) => state.plugins.parsedPlugins); const plugins = useAppSelector((state) => state.plugins.plugins);
const fetchedPlugins = useAppSelector(
(state) => state.plugins.fetchedPlugins
);
const pluginsPending = useAppSelector((state) => state.plugins.pending); const pluginsPending = useAppSelector((state) => state.plugins.pending);
const selectedFetchedPlugin = useAppSelector( const fetchedPlugin = useAppSelector((state) => state.plugins.fetchedPlugin);
(state) => state.plugins.selectedFetchedPlugin
);
const { data: cellsData, error: cellsError } = useSWRImmutable( const { data: cellsData, error: cellsError } = useSWRImmutable(
"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 }) => {
@ -239,7 +243,7 @@ const Map: React.FC = () => {
const clearSelectedCells = useCallback(() => { const clearSelectedCells = useCallback(() => {
setSelectedCells(null); setSelectedCells(null);
dispatch(setSelectedFetchedPlugin(undefined)); dispatch(setFetchedPlugin(undefined));
if (map.current) { if (map.current) {
map.current.removeFeatureState({ source: "selected-cells-source" }); map.current.removeFeatureState({ source: "selected-cells-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" }); map.current.removeFeatureState({ source: "conflicted-cell-source" });
@ -293,10 +297,8 @@ const Map: React.FC = () => {
} else if (router.query.plugin && typeof router.query.plugin === "string") { } else if (router.query.plugin && typeof router.query.plugin === "string") {
clearSelectedCell(); clearSelectedCell();
setSidebarOpen(true); setSidebarOpen(true);
if (parsedPlugins && parsedPlugins.length > 0 && pluginsPending === 0) { if (plugins && plugins.length > 0 && pluginsPending === 0) {
const plugin = parsedPlugins.find( const plugin = plugins.find((p) => p.hash === router.query.plugin);
(p) => p.hash === router.query.plugin
);
if (plugin && plugin.parsed) { if (plugin && plugin.parsed) {
const cells = []; const cells = [];
const cellSet = new Set<number>(); const cellSet = new Set<number>();
@ -321,13 +323,13 @@ const Map: React.FC = () => {
} }
if ( if (
plugins &&
plugins.length > 0 &&
pluginsPending === 0 && pluginsPending === 0 &&
((parsedPlugins && parsedPlugins.length > 0) ||
fetchedPlugins.length > 0) &&
!router.query.mod && !router.query.mod &&
!router.query.plugin !router.query.plugin
) { ) {
let cells = parsedPlugins.reduce( const cells = plugins.reduce(
(acc: { x: number; y: number }[], plugin: PluginFile) => { (acc: { x: number; y: number }[], plugin: PluginFile) => {
if (plugin.enabled && plugin.parsed) { if (plugin.enabled && plugin.parsed) {
const newCells = [...acc]; const newCells = [...acc];
@ -347,11 +349,6 @@ const Map: React.FC = () => {
}, },
[] []
); );
cells = cells.concat(
fetchedPlugins
.filter((plugin) => plugin.enabled)
.flatMap((plugin) => plugin.cells)
);
selectCells(cells); selectCells(cells);
} }
}, [ }, [
@ -365,9 +362,8 @@ const Map: React.FC = () => {
clearSelectedCell, clearSelectedCell,
clearSelectedCells, clearSelectedCells,
heatmapLoaded, heatmapLoaded,
parsedPlugins, plugins,
pluginsPending, pluginsPending,
fetchedPlugins,
]); ]);
useEffect(() => { useEffect(() => {
@ -375,12 +371,12 @@ const Map: React.FC = () => {
if ( if (
router.query.plugin && router.query.plugin &&
typeof router.query.plugin === "string" && typeof router.query.plugin === "string" &&
selectedFetchedPlugin && fetchedPlugin &&
selectedFetchedPlugin.cells fetchedPlugin.cells
) { ) {
const cells = []; const cells = [];
const cellSet = new Set<number>(); const cellSet = new Set<number>();
for (const cell of selectedFetchedPlugin.cells) { for (const cell of fetchedPlugin.cells) {
if ( if (
cell.x !== undefined && cell.x !== undefined &&
cell.y !== undefined && cell.y !== undefined &&
@ -392,7 +388,7 @@ const Map: React.FC = () => {
} }
selectCells(cells); selectCells(cells);
} }
}, [heatmapLoaded, selectedFetchedPlugin, selectCells, router.query.plugin]); }, [heatmapLoaded, fetchedPlugin, selectCells, router.query.plugin]);
useEffect(() => { useEffect(() => {
if (!heatmapLoaded) return; // wait for all map layers to load if (!heatmapLoaded) return; // wait for all map layers to load
@ -622,7 +618,12 @@ const Map: React.FC = () => {
paint: { paint: {
"fill-color": ["get", "color"], "fill-color": ["get", "color"],
"fill-opacity": ["get", "opacity"], "fill-opacity": ["get", "opacity"],
"fill-outline-color": "transparent", "fill-outline-color": [
"case",
["boolean", ["feature-state", "selected"], false],
"white",
"transparent",
],
}, },
}, },
"grid-labels-layer" "grid-labels-layer"
@ -807,6 +808,17 @@ 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
@ -816,66 +828,18 @@ const Map: React.FC = () => {
ref={mapWrapper} ref={mapWrapper}
> >
<div ref={mapContainer} className={styles["map-container"]}> <div ref={mapContainer} className={styles["map-container"]}>
<DownloadCountsProvider> <Sidebar
<GamesProvider> selectedCell={selectedCell}
<SearchProvider> clearSelectedCell={() => router.push({ query: {} })}
<Sidebar setSelectedCells={setSelectedCells}
selectedCell={selectedCell} counts={counts}
clearSelectedCell={() => router.push({ query: {} })} countsError={countsError}
setSelectedCells={setSelectedCells} open={sidebarOpen}
open={sidebarOpen} setOpen={setSidebarOpenWithResize}
setOpen={setSidebarOpenWithResize} lastModified={cellsData && cellsData.lastModified}
lastModified={cellsData && cellsData.lastModified} />
onSelectFile={(selectedFile) => { <ToggleLayersControl map={map} />
const { plugin, ...withoutPlugin } = router.query; <SearchBar counts={counts} sidebarOpen={sidebarOpen} />
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: {
game: selectedItem.game,
mod: selectedItem.id,
},
});
}
}}
includeCells
fixed
/>
</SearchProvider>
</GamesProvider>
</DownloadCountsProvider>
</div> </div>
</div> </div>
</> </>

View File

@ -1,64 +1,17 @@
import { format } from "date-fns"; import { format } from "date-fns";
import Head from "next/head"; import Head from "next/head";
import React, { useCallback, useContext, useEffect, useState } from "react"; import React, { useEffect } from "react";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { useAppDispatch, useAppSelector } from "../lib/hooks";
import CellList from "./CellList"; import CellList from "./CellList";
import styles from "../styles/ModData.module.css"; import styles from "../styles/ModData.module.css";
import { jsonFetcher } from "../lib/api"; import { jsonFetcher } from "../lib/api";
import { editionNames } from "../lib/games";
import {
PluginsByHashWithMods,
removeFetchedPlugin,
updateFetchedPlugin,
} from "../slices/plugins";
import Link from "next/link";
import { DownloadCountsContext } from "./DownloadCountsProvider";
import { GamesContext } from "./GamesProvider";
export interface CellCoord { export interface CellCoord {
x: number; x: number;
y: number; y: number;
} }
export interface ModFile {
name: string;
version: string;
category: string;
nexus_file_id: number;
}
export interface FilePlugin {
hash: number;
file_path: string;
}
export interface FileCell {
x: number;
y: number;
}
export interface File {
id: number;
name: string;
file_name: string;
nexus_file_id: number;
mod_id: number;
category: string;
version: string;
mod_version: string;
size: number;
uploaded_at: string;
created_at: string;
downloaded_at: string;
has_plugin: boolean;
unable_to_extract_plugins: boolean;
cells: FileCell[];
plugins: FilePlugin[];
plugin_count: number;
}
export interface Mod { export interface Mod {
id: number; id: number;
name: string; name: string;
@ -77,216 +30,121 @@ export interface Mod {
first_upload_at: string; first_upload_at: string;
last_updated_files_at: string; last_updated_files_at: string;
cells: CellCoord[]; cells: CellCoord[];
files: ModFile[];
} }
export const NEXUS_MODS_URL = "https://www.nexusmods.com"; const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
type Props = { type Props = {
game: string;
selectedMod: number; selectedMod: number;
selectedFile: number; counts: Record<number, [number, number, number]> | null;
selectedPlugin: string;
setSelectedCells: (cells: { x: number; y: number }[] | null) => void; setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
onSelectFile: (fileId: number) => void;
onSelectPlugin: (hash: string) => void;
}; };
const ModData: React.FC<Props> = ({ const ModData: React.FC<Props> = ({
game,
selectedMod, selectedMod,
selectedFile, counts,
selectedPlugin,
setSelectedCells, setSelectedCells,
onSelectFile,
onSelectPlugin,
}) => { }) => {
const { const { data, error } = useSWRImmutable(
games, `https://mods.modmapper.com/${selectedMod}.json`,
getGameNameById,
error: gamesError,
} = useContext(GamesContext);
const counts = useContext(DownloadCountsContext);
const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] =
useState<boolean>(false);
const { data: modData, error: modError } = useSWRImmutable(
`https://mods.modmapper.com/${game}/${selectedMod}.json`,
(_) => jsonFetcher<Mod>(_) (_) => jsonFetcher<Mod>(_)
); );
const { data: fileData, error: fileError } = useSWRImmutable(
selectedFile ? `https://files.modmapper.com/${selectedFile}.json` : null,
(_) => jsonFetcher<File>(_)
);
const { data: pluginData, error: pluginError } = useSWRImmutable(
selectedPlugin
? `https://plugins.modmapper.com/${selectedPlugin}.json`
: null,
(_) => jsonFetcher<PluginsByHashWithMods>(_)
);
const dispatch = useAppDispatch();
const fetchedPlugin = useAppSelector((state) =>
state.plugins.fetchedPlugins.find(
(plugin) => plugin.hash === selectedPlugin
)
);
const handleFileChange = useCallback(
(event) => {
onSelectFile(event.target.value);
},
[onSelectFile]
);
const handlePluginChange = useCallback(
(event) => {
onSelectPlugin(event.target.value);
},
[onSelectPlugin]
);
useEffect(() => { useEffect(() => {
if (modData && !selectedFile) setSelectedCells(modData.cells); if (data) setSelectedCells(data.cells);
}, [modData, setSelectedCells, selectedFile]); }, [data, setSelectedCells]);
useEffect(() => { if (error && error.status === 404) {
if (fileData) setSelectedCells(fileData.cells);
}, [fileData, setSelectedCells]);
useEffect(() => {
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>; return <div>Mod could not be found.</div>;
} else if (modError) { } else if (error) {
return <div>{`Error loading mod modData: ${modError.message}`}</div>; return <div>{`Error loading mod data: ${error.message}`}</div>;
} }
if (modData === undefined) if (data === undefined)
return <div className={styles.status}>Loading...</div>; return <div className={styles.status}>Loading...</div>;
if (modData === null) if (data === null)
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 gameName = getGameNameById(modData.game_id); const modCounts = counts && counts[data.nexus_mod_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;
if (selectedMod && modData) { if (selectedMod && data) {
return ( return (
<> <>
<Head> <Head>
<title key="title">{`Modmapper - ${modData.name}`}</title> <title key="title">{`Modmapper - ${data.name}`}</title>
<meta <meta
key="description" key="description"
name="description" name="description"
content={`Map of Skyrim showing ${modData.cells.length} cell edits from the mod: ${modData.name}`} content={`Map of Skyrim showing ${data.cells.length} cell edits from the mod: ${data.name}`}
/> />
<meta <meta
key="og:title" key="og:title"
property="og:title" property="og:title"
content={`Modmapper - ${modData.name}`} content={`Modmapper - ${data.name}`}
/> />
<meta <meta
key="og:description" key="og:description"
property="og:description" property="og:description"
content={`Map of Skyrim showing ${modData.cells.length} cell edits from the mod: ${modData.name}`} content={`Map of Skyrim showing ${data.cells.length} cell edits from the mod: ${data.name}`}
/> />
<meta <meta
key="twitter:title" key="twitter:title"
name="twitter:title" name="twitter:title"
content={`Modmapper - ${modData.name}`} content={`Modmapper - ${data.name}`}
/> />
<meta <meta
key="twitter:description" key="twitter:description"
name="twitter:description" name="twitter:description"
content={`Map of Skyrim showing ${modData.cells.length} cell edits from the mod: ${modData.name}`} content={`Map of Skyrim showing ${data.cells.length} cell edits from the mod: ${data.name}`}
/> />
<meta <meta
key="og:url" key="og:url"
property="og:url" property="og:url"
content={`https://modmapper.com/?game=${getGameNameById( content={`https://modmapper.com/?mod=${data.nexus_mod_id}`}
modData.game_id
)}&mod=${modData.nexus_mod_id}`}
/> />
</Head> </Head>
<h1> <h1>
<a <a
href={`${NEXUS_MODS_URL}/${getGameNameById(modData.game_id)}/mods/${ href={`${NEXUS_MODS_URL}/mods/${data.nexus_mod_id}`}
modData.nexus_mod_id
}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className={styles.name} className={styles.name}
> >
{modData.name} {data.name}
</a> </a>
</h1> </h1>
<div>
<strong>Edition:&nbsp;</strong>
{
editionNames[
getGameNameById(modData.game_id) ?? "skyrimspecialedition"
]
}
</div>
<div> <div>
<strong>Category:&nbsp;</strong> <strong>Category:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/${getGameNameById( href={`${NEXUS_MODS_URL}/mods/categories/${data.category_id}`}
modData.game_id
)}/mods/categories/${modData.category_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
{modData.category_name} {data.category_name}
</a> </a>
{modData.is_translation && <strong>&nbsp;(translation)</strong>} {data.is_translation && <strong>&nbsp;(translation)</strong>}
</div> </div>
<div> <div>
<strong>Author:&nbsp;</strong> <strong>Author:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/${getGameNameById( href={`${NEXUS_MODS_URL}/users/${data.author_id}`}
modData.game_id
)}/users/${modData.author_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
{modData.author_name} {data.author_name}
</a> </a>
</div> </div>
<div> <div>
<strong>Uploaded:</strong>{" "} <strong>Uploaded:</strong>{" "}
{format(new Date(modData.first_upload_at), "d MMM y")} {format(new Date(data.first_upload_at), "d MMM y")}
</div> </div>
<div> <div>
<strong>Last Update:</strong>{" "} <strong>Last Update:</strong>{" "}
{format(new Date(modData.last_update_at), "d MMM y")} {format(new Date(data.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>
@ -294,96 +152,7 @@ const ModData: React.FC<Props> = ({
<strong>Unique Downloads:</strong>{" "} <strong>Unique Downloads:</strong>{" "}
{numberFmt.format(unique_downloads)} {numberFmt.format(unique_downloads)}
</div> </div>
<div className={styles["select-container"]}> <CellList cells={data.cells} />
<label htmlFor="mod-file-select" className={styles.label}>
Select file:
</label>
<select
name="file"
id="mod-file-select"
className={styles.select}
onChange={handleFileChange}
value={selectedFile ?? ""}
>
<option value="">--Select file--</option>
{[...modData.files].reverse().map((file) => (
<option key={file.nexus_file_id} value={file.nexus_file_id}>
{file.name} (v{file.version}) ({file.category})
</option>
))}
</select>
</div>
{fileData && (
<div className={styles["select-container"]}>
<label htmlFor="file-plugin-select" className={styles.label}>
Select plugin:
</label>
<select
name="plugin"
id="file-plugin-select"
className={styles.select}
onChange={handlePluginChange}
value={selectedPlugin ?? ""}
>
<option value="">--Select plugin--</option>
{fileData.plugins.map((plugin) => (
<option key={plugin.hash} value={plugin.hash}>
{plugin.file_path}
</option>
))}
</select>
</div>
)}
{pluginData ? (
<>
<div className={styles["plugin-actions"]}>
<Link href={`/?plugin=${pluginData.hash}`}>
<a className={styles["plugin-link"]}>View plugin</a>
</Link>
<button
className={styles.button}
onClick={() => {
if (fetchedPlugin) {
dispatch(removeFetchedPlugin(pluginData.hash));
} else {
dispatch(
updateFetchedPlugin({ ...pluginData, enabled: true })
);
}
setShowAddRemovePluginNotification(true);
}}
>
{Boolean(fetchedPlugin) ? "Remove plugin" : "Add plugin"}
</button>
</div>
{showAddRemovePluginNotification && (
<span>
Plugin {Boolean(fetchedPlugin) ? "added" : "removed"}.{" "}
<Link href="/#added-plugins">
<a>View list</a>
</Link>
.
</span>
)}
</>
) : (
<div className={styles.spacer} />
)}
{fileError &&
(fileError.status === 404 ? (
<div>File cound not be found.</div>
) : (
<div>{`Error loading file data: ${fileError.message}`}</div>
))}
{pluginError &&
(pluginError.status === 404 ? (
<div>Plugin cound not be found.</div>
) : (
<div>{`Error loading plugin data: ${pluginError.message}`}</div>
))}
<CellList
cells={pluginData?.cells ?? fileData?.cells ?? modData.cells}
/>
</> </>
); );
} }

View File

@ -1,10 +1,9 @@
/* 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, { useContext, useEffect, useRef, useState } from "react"; import React, { 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";
import ReactPaginate from "react-paginate";
import styles from "../styles/ModList.module.css"; import styles from "../styles/ModList.module.css";
import type { Mod } from "./CellData"; import type { Mod } from "./CellData";
@ -16,35 +15,24 @@ import {
setSortBy, setSortBy,
setSortAsc, setSortAsc,
setFilter, setFilter,
setGame,
setCategory, setCategory,
setIncludeTranslations, setIncludeTranslations,
} from "../slices/modListFilters"; } from "../slices/modListFilters";
import { editionNames } from "../lib/games";
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"; const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
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 }) => { const ModList: React.FC<Props> = ({ mods, files, counts }) => {
const {
games,
getGameNameById,
error: gamesError,
} = useContext(GamesContext);
const counts = useContext(DownloadCountsContext);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { sortBy, sortAsc, filter, category, game, includeTranslations } = const { sortBy, sortAsc, filter, category, 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 { data: cellCounts, error: cellCountsError } = useSWRImmutable( const { data: cellCounts, error: cellCountsError } = useSWRImmutable(
`https://mods.modmapper.com/mod_cell_counts.json`, `https://mods.modmapper.com/mod_cell_counts.json`,
@ -53,10 +41,7 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
const modsWithCounts: ModWithCounts[] = mods const modsWithCounts: ModWithCounts[] = mods
.map((mod) => { .map((mod) => {
const gameName = getGameNameById(mod.game_id); const modCounts = counts && counts[mod.nexus_mod_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,
@ -71,7 +56,6 @@ 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) => {
@ -95,19 +79,6 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
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>>;
@ -135,34 +106,10 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
} }
}, [filter]); }, [filter]);
useEffect(() => {
setPage(0);
}, [filterResults, category, includeTranslations, sortBy, sortAsc]);
const renderPagination = () => (
<ReactPaginate
breakLabel="..."
nextLabel=">"
forcePage={page}
onPageChange={(event) => {
setPage(event.selected);
document.getElementById("nexus-mods")?.scrollIntoView();
}}
pageRangeDisplayed={3}
marginPagesDisplayed={2}
pageCount={Math.ceil(modsWithCounts.length / PAGE_SIZE)}
previousLabel="<"
renderOnZeroPageCount={() => null}
className={styles.pagination}
activeClassName={styles["active-page"]}
hrefBuilder={() => "#"}
/>
);
return ( return (
mods && ( mods && (
<> <>
<h2 id="nexus-mods">Nexus Mods ({modsWithCounts.length})</h2> <h2>Nexus Mods ({modsWithCounts.length})</h2>
<div className={styles.filters}> <div className={styles.filters}>
<hr /> <hr />
<div className={styles["filter-row"]}> <div className={styles["filter-row"]}>
@ -191,6 +138,7 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
<div className={styles["sort-direction"]}> <div className={styles["sort-direction"]}>
<button <button
title="Sort ascending" title="Sort ascending"
className={sortAsc ? styles.active : ""}
onClick={() => dispatch(setSortAsc(true))} onClick={() => dispatch(setSortAsc(true))}
> >
<img <img
@ -205,6 +153,7 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
</button> </button>
<button <button
title="Sort descending" title="Sort descending"
className={!sortAsc ? styles.active : ""}
onClick={() => dispatch(setSortAsc(false))} onClick={() => dispatch(setSortAsc(false))}
> >
<img <img
@ -229,26 +178,6 @@ 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">Edition:</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}>
{editionNames[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
@ -291,136 +220,107 @@ const ModList: React.FC<Props> = ({ mods, files }) => {
</div> </div>
<hr /> <hr />
</div> </div>
{renderPagination()}
<ul className={styles["mod-list"]}> <ul className={styles["mod-list"]}>
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) && {modsWithCounts.map((mod) => (
renderDownloadCountsLoading()} <li key={mod.id} className={styles["mod-list-item"]}>
{(!games || gamesError) && renderGamesError(gamesError)} <div className={styles["mod-title"]}>
{counts.skyrim.error && <strong>
renderDownloadCountsError(counts.skyrim.error)} <Link href={`/?mod=${mod.nexus_mod_id}`}>
{counts.skyrimspecialedition.error && <a>{mod.name}</a>
renderDownloadCountsError(counts.skyrimspecialedition.error)} </Link>
{modsWithCounts </strong>
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE) </div>
.map((mod) => ( <div>
<li key={mod.id} className={styles["mod-list-item"]}> <a
<div className={styles["mod-title"]}> href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
<strong> target="_blank"
<Link rel="noreferrer noopener"
href={`/?game=${getGameNameById(mod.game_id)}&mod=${ >
mod.nexus_mod_id View on Nexus Mods
}`} </a>
> </div>
<a>{mod.name}</a> <div>
</Link> <strong>Category:&nbsp;</strong>
</strong> <a
</div> href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.category_name}
</a>
</div>
<div>
<strong>Author:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/users/${mod.author_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.author_name}
</a>
</div>
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(mod.first_upload_at), "d MMM y")}
</div>
<div>
<strong>Last Update:</strong>{" "}
{format(new Date(mod.last_update_at), "d MMM y")}
</div>
<div>
<strong>Total Downloads:</strong>{" "}
{numberFmt.format(mod.total_downloads)}
</div>
<div>
<strong>Unique Downloads:</strong>{" "}
{numberFmt.format(mod.unique_downloads)}
</div>
{cellCounts && (
<div> <div>
<a <strong>Exterior Cells Edited:</strong>{" "}
href={`${NEXUS_MODS_URL}/${getGameNameById( {numberFmt.format(mod.exterior_cells_edited)}
mod.game_id
)}/mods/${mod.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
>
View on Nexus Mods
</a>
</div> </div>
<div> )}
<strong>Edition:&nbsp;</strong> <ul className={styles["file-list"]}>
{ {files &&
editionNames[ files
getGameNameById(mod.game_id) ?? "skyrimspecialedition" .filter((file) => file.mod_id === mod.id)
] .sort((a, b) => b.nexus_file_id - a.nexus_file_id)
} .map((file) => (
</div> <li key={file.id}>
<div> <div>
<strong>Category:&nbsp;</strong> <strong>File:</strong> {file.name}
<a </div>
href={`${NEXUS_MODS_URL}/${getGameNameById( {file.mod_version && (
mod.game_id
)}/mods/categories/${mod.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.category_name}
</a>
</div>
<div>
<strong>Author:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/${getGameNameById(
mod.game_id
)}/users/${mod.author_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.author_name}
</a>
</div>
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(mod.first_upload_at), "d MMM y")}
</div>
<div>
<strong>Last Update:</strong>{" "}
{format(new Date(mod.last_update_at), "d MMM y")}
</div>
<div>
<strong>Total Downloads:</strong>{" "}
{numberFmt.format(mod.total_downloads)}
</div>
<div>
<strong>Unique Downloads:</strong>{" "}
{numberFmt.format(mod.unique_downloads)}
</div>
{cellCounts && (
<div>
<strong>Exterior Cells Edited:</strong>{" "}
{numberFmt.format(mod.exterior_cells_edited)}
</div>
)}
<ul className={styles["file-list"]}>
{files &&
files
.filter((file) => file.mod_id === mod.id)
.sort((a, b) => b.nexus_file_id - a.nexus_file_id)
.map((file) => (
<li key={file.id}>
<div> <div>
<strong>File:</strong> {file.name} <strong>Version:</strong> {file.mod_version}
</div> </div>
{file.mod_version && ( )}
<div> {file.version && file.mod_version !== file.version && (
<strong>Version:</strong> {file.mod_version}
</div>
)}
{file.version && file.mod_version !== file.version && (
<div>
<strong>File Version:</strong> {file.version}
</div>
)}
{file.category && (
<div>
<strong>Category:</strong> {file.category}
</div>
)}
<div> <div>
<strong>Size:</strong> {formatBytes(file.size)} <strong>File Version:</strong> {file.version}
</div> </div>
{file.uploaded_at && ( )}
<div> {file.category && (
<strong>Uploaded:</strong>{" "} <div>
{format(new Date(file.uploaded_at), "d MMM y")} <strong>Category:</strong> {file.category}
</div> </div>
)} )}
</li> <div>
))} <strong>Size:</strong> {formatBytes(file.size)}
</ul> </div>
</li> {file.uploaded_at && (
))} <div>
<strong>Uploaded:</strong>{" "}
{format(new Date(file.uploaded_at), "d MMM y")}
</div>
)}
</li>
))}
</ul>
</li>
))}
</ul> </ul>
{renderPagination()}
</> </>
) )
); );

View File

@ -16,9 +16,10 @@ export interface Plugin {
type Props = { type Props = {
plugin: Plugin; plugin: Plugin;
counts: Record<number, [number, number, number]> | null;
}; };
const PluginData: React.FC<Props> = ({ plugin }) => { const PluginData: React.FC<Props> = ({ plugin, counts }) => {
if (!plugin) { if (!plugin) {
return <h3>Plugin could not be found.</h3>; return <h3>Plugin could not be found.</h3>;
} }
@ -79,13 +80,11 @@ const PluginData: React.FC<Props> = ({ plugin }) => {
<strong>Cell edits:&nbsp;</strong> <strong>Cell edits:&nbsp;</strong>
{plugin.cell_count} {plugin.cell_count}
</div> </div>
{plugin.description ? ( {plugin.description && (
<div> <div>
<h3>Description:</h3> <h3>Description:</h3>
<p>{plugin.description}</p> <p>{plugin.description}</p>
</div> </div>
) : (
<div className={styles.spacer} />
)} )}
</> </>
); );

View File

@ -1,13 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { useAppDispatch, useAppSelector } from "../lib/hooks"; import { useAppDispatch, useAppSelector } from "../lib/hooks";
import { import {
setSelectedFetchedPlugin, setFetchedPlugin,
PluginFile, PluginFile,
PluginsByHashWithMods, PluginsByHashWithMods,
updateFetchedPlugin,
removeFetchedPlugin,
} from "../slices/plugins"; } from "../slices/plugins";
import ModList from "./ModList"; import ModList from "./ModList";
import CellList from "./CellList"; import CellList from "./CellList";
@ -15,7 +13,6 @@ import type { CellCoord } from "./ModData";
import PluginData, { Plugin as PluginProps } from "./PluginData"; import PluginData, { Plugin as PluginProps } from "./PluginData";
import styles from "../styles/PluginDetail.module.css"; import styles from "../styles/PluginDetail.module.css";
import { jsonFetcher } from "../lib/api"; import { jsonFetcher } from "../lib/api";
import Link from "next/link";
const buildPluginProps = ( const buildPluginProps = (
data?: PluginsByHashWithMods | null, data?: PluginsByHashWithMods | null,
@ -23,7 +20,10 @@ const buildPluginProps = (
): PluginProps => { ): PluginProps => {
const dataPlugin = data && data.plugins.length > 0 && data.plugins[0]; const dataPlugin = data && data.plugins.length > 0 && data.plugins[0];
return { return {
hash: (plugin && plugin.hash) || (dataPlugin && dataPlugin.hash) || "", hash:
(plugin && plugin.hash) ||
(dataPlugin && dataPlugin.hash.toString(36)) ||
"",
size: plugin?.size || (dataPlugin && dataPlugin.size) || 0, size: plugin?.size || (dataPlugin && dataPlugin.size) || 0,
author: author:
plugin?.parsed?.header.author || plugin?.parsed?.header.author ||
@ -45,84 +45,53 @@ const buildPluginProps = (
type Props = { type Props = {
hash: string; hash: string;
counts: Record<number, [number, number, number]> | null;
}; };
const PluginDetail: React.FC<Props> = ({ hash }) => { const PluginDetail: React.FC<Props> = ({ hash, counts }) => {
const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] =
useState<boolean>(false);
const { data, error } = useSWRImmutable( const { data, error } = useSWRImmutable(
`https://plugins.modmapper.com/${hash}.json`, `https://plugins.modmapper.com/${hash}.json`,
(_) => jsonFetcher<PluginsByHashWithMods>(_) (_) => jsonFetcher<PluginsByHashWithMods>(_)
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const parsedPlugin = useAppSelector((state) => const plugins = useAppSelector((state) => state.plugins.plugins);
state.plugins.parsedPlugins.find((plugin) => plugin.hash === hash) const fetchedPlugin = useAppSelector((state) => state.plugins.fetchedPlugin);
); const plugin = plugins.find((plugin) => plugin.hash === hash);
const fetchedPlugin = useAppSelector((state) =>
state.plugins.fetchedPlugins.find((plugin) => plugin.hash === hash)
);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
dispatch(setSelectedFetchedPlugin(data)); dispatch(setFetchedPlugin(data));
} }
}, [dispatch, data]); }, [dispatch, data, fetchedPlugin]);
if (!parsedPlugin && error && error.status === 404) { if (!plugin && error && error.status === 404) {
return <h3>Plugin could not be found.</h3>; return <h3>Plugin could not be found.</h3>;
} else if (!parsedPlugin && error) { } else if (!plugin && error) {
return <div>{`Error loading plugin data: ${error.message}`}</div>; return <div>{`Error loading plugin data: ${error.message}`}</div>;
} }
if (!parsedPlugin && data === undefined) if (!plugin && data === undefined)
return <div className={styles.status}>Loading...</div>; return <div className={styles.status}>Loading...</div>;
if (!parsedPlugin && data === null) if (!plugin && data === null)
return <div className={styles.status}>Plugin could not be found.</div>; return <div className={styles.status}>Plugin could not be found.</div>;
return ( return (
<> <>
<PluginData plugin={buildPluginProps(data, parsedPlugin)} /> <PluginData plugin={buildPluginProps(data, plugin)} counts={counts} />
{data && ( {data && <ModList mods={data.mods} files={data.files} counts={counts} />}
<> {plugin?.parseError && (
<button
className={styles.button}
onClick={() => {
if (fetchedPlugin) {
dispatch(removeFetchedPlugin(data.hash));
} else {
dispatch(updateFetchedPlugin({ ...data, enabled: true }));
}
setShowAddRemovePluginNotification(true);
}}
>
{Boolean(fetchedPlugin) ? "Remove plugin" : "Add plugin"}
</button>
{showAddRemovePluginNotification && (
<span>
Plugin {Boolean(fetchedPlugin) ? "added" : "removed"}.{" "}
<Link href="/#added-plugins">
<a>View list</a>
</Link>
.
</span>
)}
</>
)}
{data && <ModList mods={data.mods} files={data.files} />}
{parsedPlugin?.parseError && (
<div className={styles.error}> <div className={styles.error}>
{`Error parsing plugin: ${parsedPlugin.parseError}`} {`Error parsing plugin: ${plugin.parseError}`}
</div> </div>
)} )}
<CellList <CellList
cells={ cells={
(parsedPlugin?.parsed?.cells.filter( (plugin?.parsed?.cells.filter(
(cell) => (cell) =>
cell.x !== undefined && cell.x !== undefined &&
cell.y !== undefined && cell.y !== undefined &&
cell.world_form_id === 60 && cell.world_form_id === 60 &&
parsedPlugin.parsed?.header.masters[0] === "Skyrim.esm" plugin.parsed?.header.masters[0] === "Skyrim.esm"
) as CellCoord[]) || ) as CellCoord[]) ||
data?.cells || data?.cells ||
[] []

View File

@ -4,7 +4,6 @@ import React, { useEffect, useState } from "react";
import { useAppSelector, useAppDispatch } from "../lib/hooks"; import { useAppSelector, useAppDispatch } from "../lib/hooks";
import { setPluginsTxtAndApplyLoadOrder } from "../slices/pluginsTxt"; import { setPluginsTxtAndApplyLoadOrder } from "../slices/pluginsTxt";
import styles from "../styles/PluginTxtEditor.module.css"; import styles from "../styles/PluginTxtEditor.module.css";
import EscapeListener from "./EscapeListener";
export const excludedPlugins = [ export const excludedPlugins = [
"Skyrim.esm", "Skyrim.esm",
@ -33,9 +32,8 @@ const PluginTxtEditor: React.FC<Props> = () => {
return ( return (
<> <>
<EscapeListener onEscape={() => setPluginsTxtShown(false)} />
<p className={styles["top-spacing"]}> <p className={styles["top-spacing"]}>
<strong className={styles.step}>2. </strong>Paste or drag-and-drop your{" "} Paste or drag-and-drop your{" "}
<strong> <strong>
<code>plugins.txt</code> <code>plugins.txt</code>
</strong>{" "} </strong>{" "}
@ -48,16 +46,6 @@ const PluginTxtEditor: React.FC<Props> = () => {
C:\Users\username\AppData\Local\Skyrim Special Edition C:\Users\username\AppData\Local\Skyrim Special Edition
</code> </code>
</strong> </strong>
.
<br />
<br />
For Mod Organizer users, it is at{" "}
<strong>
<code className={styles["break-word"]}>
C:\Users\username\AppData\Local\ModOrganizer\Skyrim Special
Edition\profiles\profilename\plugins.txt
</code>
</strong>
</p> </p>
<button onClick={onPluginsTxtButtonClick} className={styles.button}> <button onClick={onPluginsTxtButtonClick} className={styles.button}>
{!pluginsTxt ? "Paste" : "Edit"} Skyrim plugins.txt file {!pluginsTxt ? "Paste" : "Edit"} Skyrim plugins.txt file
@ -82,15 +70,6 @@ const PluginTxtEditor: React.FC<Props> = () => {
). ).
<br /> <br />
<br /> <br />
For Mod Organizer users, it is at{" "}
<strong>
<code className={styles["break-word"]}>
C:\Users\username\AppData\Local\ModOrganizer\Skyrim Special
Edition\profiles\profilename\plugins.txt
</code>
</strong>
<br />
<br />
You can also drag-and-drop the file anywhere on the window to load You can also drag-and-drop the file anywhere on the window to load
the file. the file.
</p> </p>

View File

@ -4,21 +4,21 @@ import React from "react";
import { useAppSelector, useAppDispatch } from "../lib/hooks"; import { useAppSelector, useAppDispatch } from "../lib/hooks";
import { excludedPlugins } from "../lib/plugins"; import { excludedPlugins } from "../lib/plugins";
import { import {
enableAllParsedPlugins, enableAllPlugins,
disableAllParsedPlugins, disableAllPlugins,
toggleParsedPlugin, togglePlugin,
} from "../slices/plugins"; } from "../slices/plugins";
import styles from "../styles/ParsedPluginsList.module.css"; import styles from "../styles/PluginList.module.css";
type Props = { type Props = {
selectedCell?: { x: number; y: number }; selectedCell?: { x: number; y: number };
}; };
const ParsedPluginsList: React.FC<Props> = ({ selectedCell }) => { const PluginsList: React.FC<Props> = ({ selectedCell }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const plugins = useAppSelector((state) => const plugins = useAppSelector((state) =>
selectedCell selectedCell
? state.plugins.parsedPlugins.filter((plugin) => ? state.plugins.plugins.filter((plugin) =>
plugin.parsed?.cells.some( plugin.parsed?.cells.some(
(cell) => (cell) =>
cell.x === selectedCell.x && cell.x === selectedCell.x &&
@ -28,7 +28,7 @@ const ParsedPluginsList: React.FC<Props> = ({ selectedCell }) => {
plugin.parsed?.header.masters[0] === "Skyrim.esm" plugin.parsed?.header.masters[0] === "Skyrim.esm"
) )
) )
: state.plugins.parsedPlugins : state.plugins.plugins
); );
const pluginsPending = useAppSelector((state) => state.plugins.pending); const pluginsPending = useAppSelector((state) => state.plugins.pending);
@ -37,10 +37,10 @@ const ParsedPluginsList: React.FC<Props> = ({ selectedCell }) => {
{plugins.length > 0 && <h2>Loaded Plugins ({plugins.length})</h2>} {plugins.length > 0 && <h2>Loaded Plugins ({plugins.length})</h2>}
{!selectedCell && plugins.length > 0 && ( {!selectedCell && plugins.length > 0 && (
<div className={styles.buttons}> <div className={styles.buttons}>
<button onClick={() => dispatch(enableAllParsedPlugins())}> <button onClick={() => dispatch(enableAllPlugins())}>
Enable all Enable all
</button> </button>
<button onClick={() => dispatch(disableAllParsedPlugins())}> <button onClick={() => dispatch(disableAllPlugins())}>
Disable all Disable all
</button> </button>
</div> </div>
@ -60,7 +60,7 @@ const ParsedPluginsList: React.FC<Props> = ({ selectedCell }) => {
} }
checked={plugin.enabled ?? false} checked={plugin.enabled ?? false}
value={plugin.enabled ? "on" : "off"} value={plugin.enabled ? "on" : "off"}
onChange={() => dispatch(toggleParsedPlugin(plugin.filename))} onChange={() => dispatch(togglePlugin(plugin.filename))}
/> />
<label htmlFor={plugin.filename} className={styles["plugin-label"]}> <label htmlFor={plugin.filename} className={styles["plugin-label"]}>
{excludedPlugins.includes(plugin.filename) ? ( {excludedPlugins.includes(plugin.filename) ? (
@ -87,4 +87,4 @@ const ParsedPluginsList: React.FC<Props> = ({ selectedCell }) => {
); );
}; };
export default ParsedPluginsList; export default PluginsList;

View File

@ -1,61 +1,73 @@
import { useCombobox } from "downshift"; import { useCombobox } from "downshift";
import React, { useContext, useState, useRef } from "react"; import React, { useEffect, useState, useRef } from "react";
import { SearchResult } from "minisearch"; import { useRouter } from "next/router";
import MiniSearch, { SearchResult } from "minisearch";
import useSWRImmutable from "swr/immutable";
import { SearchContext } from "./SearchProvider";
import styles from "../styles/SearchBar.module.css"; import styles from "../styles/SearchBar.module.css";
import { DownloadCountsContext } from "./DownloadCountsProvider"; import { jsonFetcher } from "../lib/api";
import type { GameName } from "../lib/games";
type Props = { type Props = {
counts: Record<number, [number, number, number]> | null;
sidebarOpen: boolean; sidebarOpen: boolean;
placeholder: string;
onSelectResult: (item: SearchResult | null) => void;
includeCells?: boolean;
fixed?: boolean;
inputRef?: React.MutableRefObject<HTMLInputElement | null>;
}; };
function gamePrefex(game: GameName): string { interface Mod {
switch (game) { name: string;
case "skyrim": id: number;
return "[LE]";
case "skyrimspecialedition":
return "[SE]";
default:
return "";
}
} }
const SearchBar: React.FC<Props> = ({ let cells = [];
sidebarOpen,
placeholder, for (let x = -77; x < 76; x++) {
onSelectResult, for (let y = -50; y < 45; y++) {
includeCells = false, const id = `${x},${y}`;
fixed = false, cells.push({ id, name: `Cell ${id}`, x, y });
inputRef, }
}) => { }
const counts = useContext(DownloadCountsContext); const cellSearch = new MiniSearch({
const { cellSearch, modSearch, loading, loadError } = fields: ["id"],
useContext(SearchContext); storeFields: ["id", "name", "x", "y"],
tokenize: (s) => [s.replace(/cell\s?/gi, "")],
searchOptions: {
fields: ["id"],
prefix: true,
fuzzy: false,
},
});
cellSearch.addAll(cells);
const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
const router = useRouter();
const modSearch = useRef<MiniSearch<Mod> | null>(
null
) as React.MutableRefObject<MiniSearch<Mod>>;
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"],
storeFields: ["name", "id"],
searchOptions: {
fields: ["name"],
fuzzy: 0.2,
prefix: true,
},
});
modSearch.current.addAll(data);
}
}, [data]);
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,
@ -70,19 +82,12 @@ const SearchBar: React.FC<Props> = ({
onInputValueChange: ({ inputValue }) => { onInputValueChange: ({ inputValue }) => {
if (inputValue) { if (inputValue) {
let results: SearchResult[] = []; let results: SearchResult[] = [];
if ( if (modSearch.current && !/^(cell)?\s?-?\d+,-?\d+$/i.test(inputValue)) {
modSearch &&
!/(^cell\s?-?\d+\s?,?\s?-?\d*$)|(^-?\d+\s?,\s?-?\d*$)/i.test(
inputValue
)
) {
results = results.concat( results = results.concat(
modSearch.search(inputValue).sort((resultA, resultB) => { modSearch.current.search(inputValue).sort((resultA, resultB) => {
const countsA = counts[resultA.game as GameName].counts; if (counts) {
const countsB = counts[resultB.game as GameName].counts; const countA = counts[resultA.id];
if (countsA && countsB) { const countB = counts[resultB.id];
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) {
@ -97,16 +102,20 @@ const SearchBar: React.FC<Props> = ({
}) })
); );
} }
if (includeCells) { results = results.concat(cellSearch.search(inputValue));
results = results.concat(cellSearch.search(inputValue));
}
setResults(results.splice(0, 30)); setResults(results.splice(0, 30));
} }
}, },
onSelectedItemChange: ({ selectedItem }) => { onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) { if (selectedItem) {
setSearchFocused(false); setSearchFocused(false);
onSelectResult(selectedItem); if (selectedItem.x && selectedItem.y) {
router.push({
query: { cell: `${selectedItem.x},${selectedItem.y}` },
});
} else {
router.push({ query: { mod: selectedItem.id } });
}
if (searchInput.current) searchInput.current.blur(); if (searchInput.current) searchInput.current.blur();
reset(); reset();
} }
@ -118,25 +127,19 @@ const SearchBar: React.FC<Props> = ({
<div <div
className={`${styles["search-bar"]} ${ className={`${styles["search-bar"]} ${
searchFocused ? styles["search-bar-focused"] : "" searchFocused ? styles["search-bar-focused"] : ""
} ${fixed ? styles["search-bar-fixed"] : ""} ${ } ${sidebarOpen ? styles["search-bar-sidebar-open"] : ""}`}
sidebarOpen ? styles["search-bar-sidebar-open"] : ""
}`}
{...getComboboxProps()} {...getComboboxProps()}
> >
<input <input
{...getInputProps({ {...getInputProps({
type: "text", type: "text",
placeholder: placeholder: "Search mods or cells…",
modSearch && !loading ? placeholder : "Search (loading...)",
onFocus: () => setSearchFocused(true), onFocus: () => setSearchFocused(true),
onBlur: () => { onBlur: () => {
if (!isOpen) setSearchFocused(false); if (!isOpen) setSearchFocused(false);
}, },
disabled: !modSearch, disabled: !data,
ref: (ref) => { ref: searchInput,
searchInput.current = ref;
if (inputRef) inputRef.current = ref;
},
})} })}
/> />
<ul <ul
@ -153,16 +156,9 @@ const SearchBar: React.FC<Props> = ({
highlightedIndex === index ? styles["highlighted-result"] : "" highlightedIndex === index ? styles["highlighted-result"] : ""
}`} }`}
> >
{gamePrefex(result.game)} {result.name} {result.name}
</li> </li>
))} ))}
{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> </ul>
</div> </div>
</> </>

View File

@ -1,131 +0,0 @@
import React, { createContext, useEffect, useRef, useState } from "react";
import MiniSearch from "minisearch";
import useSWRImmutable from "swr/immutable";
import { jsonFetcher } from "../lib/api";
import type { GameName } from "../lib/games";
interface Mod {
name: string;
id: number;
}
interface ModWithGame {
name: string;
id: number;
game: GameName;
}
let cells = [];
for (let x = -77; x < 76; x++) {
for (let y = -50; y < 45; y++) {
const id = `${x},${y}`;
cells.push({ id, name: `Cell ${id}`, x, y });
}
}
const cellSearch = new MiniSearch({
fields: ["id"],
storeFields: ["id", "name", "x", "y"],
tokenize: (s) => [s.replace(/(cell\s?)|\s/gi, "")],
searchOptions: {
fields: ["id"],
prefix: true,
fuzzy: false,
},
});
cellSearch.addAll(cells);
type SearchContext = {
cellSearch: MiniSearch;
modSearch?: MiniSearch;
loading: boolean;
loadError?: any;
};
export const SearchContext = createContext<SearchContext>({
cellSearch,
loading: true,
});
const SearchProvider: React.FC = ({ children }) => {
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: 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);
});
}
}, [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 (
<SearchContext.Provider
value={{
modSearch: modSearch.current,
cellSearch,
loading,
loadError: skyrimspecialeditionError || skyrimError,
}}
>
{children}
</SearchContext.Provider>
);
};
export default SearchProvider;

View File

@ -1,44 +1,73 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import React, { useEffect } from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import AddModDialog from "./AddModDialog"; import arrow from "../public/img/arrow.svg";
import close from "../public/img/close.svg";
import CellData from "./CellData"; import CellData from "./CellData";
import ModData from "./ModData"; import ModData from "./ModData";
import PluginDetail from "./PluginDetail"; import PluginDetail from "./PluginDetail";
import DataDirPicker from "./DataDirPicker"; import DataDirPicker from "./DataDirPicker";
import PluginTxtEditor from "./PluginTxtEditor"; import PluginTxtEditor from "./PluginTxtEditor";
import ParsedPluginsList from "./ParsedPluginsList"; import PluginsList from "./PluginsList";
import FetchedPluginsList from "./FetchedPluginsList";
import styles from "../styles/Sidebar.module.css"; import styles from "../styles/Sidebar.module.css";
type Props = { 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;
onSelectFile: (fileId: number) => void;
onSelectPlugin: (hash: string) => void;
}; };
const Sidebar: React.FC<Props> = ({ const Sidebar: React.FC<Props> = ({
selectedCell, selectedCell,
clearSelectedCell, clearSelectedCell,
setSelectedCells, setSelectedCells,
counts,
countsError,
open, open,
setOpen, setOpen,
lastModified, lastModified,
onSelectFile,
onSelectPlugin,
}) => { }) => {
const router = useRouter(); const router = useRouter();
useEffect(() => { const renderLoadError = (error: Error) => (
document.getElementById("sidebar")?.scrollTo(0, 0); <div>{`Error loading live download counts: ${error.message}`}</div>
}, [selectedCell, router.query.mod, router.query.plugin]); );
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) => {
if (countsError) return renderLoadError(countsError);
if (!counts) return renderLoading();
return (
<ModData
selectedMod={selectedMod}
counts={counts}
setSelectedCells={setSelectedCells}
/>
);
};
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) {
@ -57,7 +86,6 @@ const Sidebar: React.FC<Props> = ({
<div <div
className={styles.sidebar} className={styles.sidebar}
style={!open ? { display: "none" } : {}} style={!open ? { display: "none" } : {}}
id="sidebar"
> >
<div className={styles["sidebar-content"]}> <div className={styles["sidebar-content"]}>
<div className={styles["sidebar-header"]}> <div className={styles["sidebar-header"]}>
@ -68,28 +96,17 @@ 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} /> {renderCellData(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 pluginHash = router.query.plugin as string;
return ( return (
<div <div
className={styles.sidebar} className={styles.sidebar}
style={!open ? { display: "none" } : {}} style={!open ? { display: "none" } : {}}
id="sidebar"
> >
<div className={styles["sidebar-content"]}> <div className={styles["sidebar-content"]}>
<div className={styles["sidebar-header"]}> <div className={styles["sidebar-header"]}>
@ -97,17 +114,7 @@ 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) && ( {!Number.isNaN(modId) && renderModData(modId)}
<ModData
game={game}
selectedMod={modId}
selectedFile={fileId}
selectedPlugin={pluginHash}
setSelectedCells={setSelectedCells}
onSelectFile={onSelectFile}
onSelectPlugin={onSelectPlugin}
/>
)}
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>
@ -117,7 +124,6 @@ const Sidebar: React.FC<Props> = ({
<div <div
className={styles.sidebar} className={styles.sidebar}
style={!open ? { display: "none" } : {}} style={!open ? { display: "none" } : {}}
id="sidebar"
> >
<div className={styles["sidebar-content"]}> <div className={styles["sidebar-content"]}>
<div className={styles["sidebar-header"]}> <div className={styles["sidebar-header"]}>
@ -125,13 +131,11 @@ 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>
<PluginDetail {renderPluginData(
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>
@ -141,7 +145,6 @@ const Sidebar: React.FC<Props> = ({
<div <div
className={styles.sidebar} className={styles.sidebar}
style={!open ? { display: "none" } : {}} style={!open ? { display: "none" } : {}}
id="sidebar"
> >
<div className={styles["sidebar-content"]}> <div className={styles["sidebar-content"]}>
<h1 className={styles.title}>Modmapper</h1> <h1 className={styles.title}>Modmapper</h1>
@ -150,9 +153,7 @@ const Sidebar: React.FC<Props> = ({
</p> </p>
<DataDirPicker /> <DataDirPicker />
<PluginTxtEditor /> <PluginTxtEditor />
<ParsedPluginsList /> <PluginsList />
<FetchedPluginsList />
<AddModDialog />
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
import { createContext } from "react"; import { createContext } from "react";
import { import {
addParsedPluginInOrder, addPluginInOrder,
decrementPending, decrementPending,
PluginFile, PluginFile,
} from "../slices/plugins"; } from "../slices/plugins";
@ -31,6 +31,12 @@ export class WorkerPool {
return this; return this;
} }
public async addWorker() {
const worker = await this.createWorker();
this.availableWorkers.push(worker);
this.assignWorker();
}
public async createWorker(): Promise<Worker> { public async createWorker(): Promise<Worker> {
return new Promise((resolve) => { return new Promise((resolve) => {
const worker = new Worker(new URL("../workers/PluginsLoader.worker.ts", import.meta.url)); const worker = new Worker(new URL("../workers/PluginsLoader.worker.ts", import.meta.url));
@ -42,10 +48,13 @@ export class WorkerPool {
resolve(worker); resolve(worker);
} else if (typeof data !== "string") { } else if (typeof data !== "string") {
store.dispatch(decrementPending(1)); store.dispatch(decrementPending(1));
store.dispatch(addParsedPluginInOrder(data)); store.dispatch(addPluginInOrder(data));
// Since web assembly memory cannot be shrunk, replace worker with a fresh one to avoid slow repeated
this.availableWorkers.push(worker); // invocations on the same worker instance. Repeated invocations are so slow that the delay in creating a
this.assignWorker() // new worker is worth it. In practice, there are usually more workers than tasks, so the delay does not slow
// down processing.
worker.terminate();
this.addWorker();
} }
}; };
}); });

View File

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

View File

@ -1,8 +0,0 @@
export type GameName = "skyrim" | "skyrimspecialedition";
// Translates gameName (e.g. "skyrim" or "skyrimspecialedition") to edition name which is displayed in the
// UI ("Classic" or "Special Edition").
export const editionNames: Record<GameName, string> = {
skyrim: 'Classic',
skyrimspecialedition: 'Special Edition',
};

View File

@ -1,8 +0,0 @@
import LogRocket from "logrocket";
import setupLogRocketReact from "logrocket-react";
const LOGROCKET_APP_ID =
process.env.LOGROCKET_APP_ID || process.env.NEXT_PUBLIC_LOGROCKET_APP_ID;
LogRocket.init(LOGROCKET_APP_ID || "0tlgj3/modmapper");
if (typeof window !== "undefined") setupLogRocketReact(LogRocket);

View File

@ -1,6 +1,6 @@
import { WorkerPool } from "./WorkerPool"; import { WorkerPool } from "./WorkerPool";
import store from "./store"; import store from "./store";
import { clearParsedPlugins, setPending } from "../slices/plugins"; import { clearPlugins, setPending } from "../slices/plugins";
export const excludedPlugins = [ export const excludedPlugins = [
"Skyrim.esm", "Skyrim.esm",
@ -12,7 +12,7 @@ export const excludedPlugins = [
export const isPluginPath = (path: string) => { export const isPluginPath = (path: string) => {
if ( if (
/^.*\.es[mpl]$/i.test(path) /^((Skyrim Special Edition|Skyrim|SkyrimVR)\/)?(Data\/)?[^/\\]*\.es[mpl]$/i.test(path)
) { ) {
return true; return true;
} }
@ -28,7 +28,7 @@ export const parsePluginFiles = (pluginFiles: File[], workerPool: WorkerPool) =>
alert("Found no plugins in the folder. Please select the Data folder underneath the Skyrim installation folder."); alert("Found no plugins in the folder. Please select the Data folder underneath the Skyrim installation folder.");
return; return;
} }
store.dispatch(clearParsedPlugins()); store.dispatch(clearPlugins());
store.dispatch(setPending(pluginFiles.length)); store.dispatch(setPending(pluginFiles.length));
pluginFiles.forEach(async (plugin) => { pluginFiles.forEach(async (plugin) => {

View File

@ -1,18 +1,13 @@
import LogRocket from "logrocket"
import * as Sentry from "@sentry/react";
import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit" import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"
import pluginsReducer from "../slices/plugins" import pluginsReducer from "../slices/plugins"
import pluginsTxtReducer from "../slices/pluginsTxt" import pluginsTxtReducer from "../slices/pluginsTxt"
import modListFiltersReducer from "../slices/modListFilters" import modListFiltersReducer from "../slices/modListFilters"
const sentryReduxEnhancer = Sentry.createReduxEnhancer();
export function makeStore() { export function makeStore() {
return configureStore({ return configureStore({
reducer: { pluginsTxt: pluginsTxtReducer, plugins: pluginsReducer, modListFilters: modListFiltersReducer }, reducer: { pluginsTxt: pluginsTxtReducer, plugins: pluginsReducer, modListFilters: modListFiltersReducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }).concat(LogRocket.reduxMiddleware()), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }),
enhancers: [sentryReduxEnhancer],
}) })
} }

View File

@ -1,8 +1,7 @@
/** @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 SSE_MOD_SEARCH_INDEX_URL = 'https://mods.modmapper.com/skyrimspecialedition/mod_search_index.json'; const MOD_SEARCH_INDEX_URL = 'https://mods.modmapper.com/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',
@ -10,21 +9,12 @@ module.exports = {
additionalPaths: async (config) => { additionalPaths: async (config) => {
const result = [] const result = []
const skyrimResponse = await fetch(LE_MOD_SEARCH_INDEX_URL); const response = await fetch(MOD_SEARCH_INDEX_URL);
const skyrimIndex = await skyrimResponse.json(); const index = await response.json();
const skyrimspecialeditionResponse = await fetch(SSE_MOD_SEARCH_INDEX_URL); for (const mod of index) {
const skyrimspecialeditionIndex = await skyrimspecialeditionResponse.json();
for (const mod of skyrimIndex) {
result.push({ result.push({
loc: `/?game=skyrim&mod=${mod.id}`, loc: '/?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,5 +1,3 @@
const { withSentryConfig } = require('@sentry/nextjs');
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
@ -10,15 +8,4 @@ const nextConfig = {
}, },
} }
const sentryWebpackPluginOptions = { module.exports = nextConfig
// Additional config options for the Sentry Webpack plugin. Keep in mind that
// the following options are set automatically, and overriding them is not
// recommended:
// release, url, org, project, authToken, configFile, stripPrefix,
// urlPrefix, include, ignore
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options.
};
module.exports = withSentryConfig(nextConfig, sentryWebpackPluginOptions);

8288
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,31 +9,22 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.8.5", "@reduxjs/toolkit": "^1.7.2",
"@sentry/nextjs": "^7.11.1",
"@sentry/react": "^7.11.1",
"@types/javascript-color-gradient": "^1.3.0", "@types/javascript-color-gradient": "^1.3.0",
"@types/mapbox-gl": "^2.7.6", "@types/mapbox-gl": "^2.6.0",
"babel-plugin-add-react-displayname": "^0.0.5",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"downshift": "^6.1.7", "downshift": "^6.1.7",
"javascript-color-gradient": "^1.3.2", "javascript-color-gradient": "^1.3.2",
"js-cookie": "^3.0.1", "mapbox-gl": "^2.6.1",
"logrocket": "^3.0.1", "minisearch": "^3.2.0",
"logrocket-react": "^5.0.1", "next": "12.1.1-canary.15",
"mapbox-gl": "^2.10.0",
"minisearch": "^5.0.0",
"next": "12.2.5",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-paginate": "^8.1.3",
"react-redux": "^7.2.6", "react-redux": "^7.2.6",
"skyrim-cell-dump-wasm": "0.1.4", "skyrim-cell-dump-wasm": "0.1.0",
"swr": "^1.1.2" "swr": "^1.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/js-cookie": "^3.0.2",
"@types/logrocket-react": "^3.0.0",
"@types/node": "17.0.8", "@types/node": "17.0.8",
"@types/react": "17.0.38", "@types/react": "17.0.38",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",

View File

@ -1,3 +0,0 @@
export default function Custom404() {
return <h1>404 - Page Not Found</h1>
}

View File

@ -1,19 +1,10 @@
import "../lib/logrocketSetup";
import "../styles/globals.css"; import "../styles/globals.css";
import LogRocket from "logrocket";
import * as Sentry from "@sentry/nextjs";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import store from "../lib/store"; import store from "../lib/store";
LogRocket.getSessionURL((sessionURL) => {
Sentry.configureScope((scope) => {
scope.setExtra("sessionURL", sessionURL);
});
});
function MyApp({ Component, pageProps }: AppProps) { function MyApp({ Component, pageProps }: AppProps) {
return ( return (
<Provider store={store}> <Provider store={store}>

View File

@ -1,39 +0,0 @@
/**
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
*
* NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
* penultimate line in `CustomErrorComponent`.
*
* This page is loaded by Nextjs:
* - on the server, when data-fetching methods throw or reject
* - on the client, when `getInitialProps` throws or rejects
* - on the client, when a React lifecycle method throws or rejects, and it's
* caught by the built-in Nextjs error boundary
*
* See:
* - https://nextjs.org/docs/basic-features/data-fetching/overview
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
* - https://reactjs.org/docs/error-boundaries.html
*/
import * as Sentry from '@sentry/nextjs';
import NextErrorComponent from 'next/error';
const CustomErrorComponent = props => {
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
// compensate for https://github.com/vercel/next.js/issues/8592
// Sentry.captureUnderscoreErrorException(props);
return <NextErrorComponent statusCode={props.statusCode} />;
};
CustomErrorComponent.getInitialProps = async contextData => {
// In case this is running in a serverless function, await this in order to give Sentry
// time to send the error before the lambda exits
await Sentry.captureUnderscoreErrorException(contextData);
// This will contain the status code of the response
return NextErrorComponent.getInitialProps(contextData);
};
export default CustomErrorComponent;

View File

@ -1,47 +0,0 @@
import Head from 'next/head'
const boxStyles = { padding: '12px', border: '1px solid #eaeaea', borderRadius: '10px' };
export default function Home() {
return (
<div>
<Head>
<title>Sentry Onboarding</title>
<meta name="description" content="Make your Next.js ready for Sentry" />
</Head>
<main style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}>
<h1 style={{ fontSize: '4rem' }}>
<svg style={{
height: '1em'
}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 44">
<path fill="currentColor" d="M124.32,28.28,109.56,9.22h-3.68V34.77h3.73V15.19l15.18,19.58h3.26V9.22h-3.73ZM87.15,23.54h13.23V20.22H87.14V12.53h14.93V9.21H83.34V34.77h18.92V31.45H87.14ZM71.59,20.3h0C66.44,19.06,65,18.08,65,15.7c0-2.14,1.89-3.59,4.71-3.59a12.06,12.06,0,0,1,7.07,2.55l2-2.83a14.1,14.1,0,0,0-9-3c-5.06,0-8.59,3-8.59,7.27,0,4.6,3,6.19,8.46,7.52C74.51,24.74,76,25.78,76,28.11s-2,3.77-5.09,3.77a12.34,12.34,0,0,1-8.3-3.26l-2.25,2.69a15.94,15.94,0,0,0,10.42,3.85c5.48,0,9-2.95,9-7.51C79.75,23.79,77.47,21.72,71.59,20.3ZM195.7,9.22l-7.69,12-7.64-12h-4.46L186,24.67V34.78h3.84V24.55L200,9.22Zm-64.63,3.46h8.37v22.1h3.84V12.68h8.37V9.22H131.08ZM169.41,24.8c3.86-1.07,6-3.77,6-7.63,0-4.91-3.59-8-9.38-8H154.67V34.76h3.8V25.58h6.45l6.48,9.2h4.44l-7-9.82Zm-10.95-2.5V12.6h7.17c3.74,0,5.88,1.77,5.88,4.84s-2.29,4.86-5.84,4.86Z M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z"></path>
</svg>
</h1>
<p >
Get started by sending us a sample error
</p>
<button type="button" style={{
...boxStyles,
backgroundColor: '#c73852',
borderRadius: '12px',
border: 'none'
}} onClick={() => {
throw new Error("Sentry Frontend Error");
}}>
Throw error
</button>
<p>
For more information, see <a href="https://docs.sentry.io/platforms/javascript/guides/nextjs/">https://docs.sentry.io/platforms/javascript/guides/nextjs/</a>
</p>
</main>
</div>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -1,37 +0,0 @@
// This file configures the initialization of Sentry on the browser.
// The config you add here will be used whenever a page is visited.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
import LogRocket from 'logrocket';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
const SENTRY_ENVIRONMENT = process.env.SENTRY_ENVIRONMENT || process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT;
const LOGROCKET_APP_ID = process.env.LOGROCKET_APP_ID || process.env.NEXT_PUBLIC_LOGROCKET_APP_ID;
Sentry.init({
dsn: SENTRY_DSN || 'https://dda36383332143d3a84c25a4f6aa6470@o1382253.ingest.sentry.io/6697231',
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
environment: SENTRY_ENVIRONMENT || 'production',
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
beforeSend(event) {
const logRocketSession = LogRocket.sessionURL;
if (event.extra && logRocketSession !== null) {
event.extra["LogRocket"] = logRocketSession;
return event;
} else {
return event;
}
},
// filter out logrocket pings
beforeBreadcrumb(breadcrumb) {
if (breadcrumb.category === 'xhr' && breadcrumb.data.url.includes(`i?a=${encodeURIComponent(LOGROCKET_APP_ID)}`)) {
return null;
}
return breadcrumb;
},
});

View File

@ -1,4 +0,0 @@
defaults.url=https://sentry.io/
defaults.org=hallada
defaults.project=modmapper
cli.executable=..\\AppData\\Local\\npm-cache\\_npx\\a8388072043b4cbc\\node_modules\\@sentry\\cli\\bin\\sentry-cli

View File

@ -1,19 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
const SENTRY_ENVIRONMENT = process.env.SENTRY_ENVIRONMENT || process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT;
Sentry.init({
dsn: SENTRY_DSN || 'https://dda36383332143d3a84c25a4f6aa6470@o1382253.ingest.sentry.io/6697231',
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
environment: SENTRY_ENVIRONMENT || 'production',
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View File

@ -1,5 +1,6 @@
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 & {
@ -13,7 +14,6 @@ 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,7 +22,6 @@ 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,
}; };
@ -43,10 +42,6 @@ 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,
@ -59,6 +54,6 @@ export const modListFiltersSlice = createSlice({
}, },
}) })
export const { setSortBy, setSortAsc, setFilter, setGame, setCategory, setIncludeTranslations, clearFilters } = modListFiltersSlice.actions export const { setSortBy, setSortAsc, setFilter, setCategory, setIncludeTranslations, clearFilters } = modListFiltersSlice.actions
export default modListFiltersSlice.reducer export default modListFiltersSlice.reducer

View File

@ -27,14 +27,14 @@ export interface World {
form_id: number; form_id: number;
} }
export interface ParsedPlugin { export interface Plugin {
header: Header; header: Header;
cells: Cell[]; cells: Cell[];
worlds: World[]; worlds: World[];
} }
export interface PluginFile { export interface PluginFile {
parsed?: ParsedPlugin; parsed?: Plugin;
filename: string; filename: string;
lastModified: number; lastModified: number;
hash: string; hash: string;
@ -65,7 +65,7 @@ export interface File {
export interface FetchedPlugin { export interface FetchedPlugin {
id: number; id: number;
name: string; name: string;
hash: string; hash: bigint;
file_id: number; file_id: number;
mod_id: number; mod_id: number;
version: number; version: number;
@ -79,130 +79,86 @@ export interface FetchedPlugin {
created_at: Date; created_at: Date;
} }
export interface FetchedCell {
x: 0;
y: 0;
}
export interface PluginsByHashWithMods { export interface PluginsByHashWithMods {
hash: string; hash: number;
plugins: FetchedPlugin[]; plugins: FetchedPlugin[];
files: File[]; files: File[];
mods: Mod[]; mods: Mod[];
cells: FetchedCell[]; cells: Cell[];
enabled?: boolean;
} }
export type PluginsState = { export type PluginsState = {
parsedPlugins: PluginFile[]; plugins: PluginFile[];
fetchedPlugins: PluginsByHashWithMods[]; fetchedPlugin?: PluginsByHashWithMods;
selectedFetchedPlugin?: PluginsByHashWithMods;
pending: number; pending: number;
} }
const initialState: PluginsState = { parsedPlugins: [], fetchedPlugins: [], pending: 0 }; const initialState: PluginsState = { plugins: [], pending: 0 };
export const pluginsSlice = createSlice({ export const pluginsSlice = createSlice({
name: "plugins", name: "plugins",
initialState, initialState,
reducers: { reducers: {
addParsedPlugin: (state: PluginsState, action: PayloadAction<PluginFile>) => ({ addPlugin: (state, action: PayloadAction<PluginFile>) => ({
...state, plugins: [...state.plugins, action.payload],
parsedPlugins: [...state.parsedPlugins, action.payload], pending: state.pending,
fetchedPlugin: state.fetchedPlugin,
}), }),
addFetchedPlugin: (state: PluginsState, action: PayloadAction<PluginsByHashWithMods>) => ({ updatePlugin: (state, action: PayloadAction<PluginFile>) => ({
...state, plugins: [...state.plugins.filter(plugin => plugin.hash !== action.payload.hash), action.payload],
fetchedPlugins: [...state.fetchedPlugins, action.payload], pending: state.pending,
fetchedPlugin: state.fetchedPlugin,
}), }),
updateParsedPlugin: (state: PluginsState, action: PayloadAction<PluginFile>) => ({ setPlugins: (state, action: PayloadAction<PluginFile[]>) => ({
...state, plugins: action.payload,
parsedPlugins: [...state.parsedPlugins.filter(plugin => plugin.hash !== action.payload.hash), action.payload], pending: state.pending,
fetchedPlugin: state.fetchedPlugin,
}), }),
updateFetchedPlugin: (state: PluginsState, action: PayloadAction<PluginsByHashWithMods>) => ({ setPending: (state, action: PayloadAction<number>) => ({
...state, plugins: state.plugins,
fetchedPlugins: [...state.fetchedPlugins.filter(plugin => plugin.hash !== action.payload.hash), action.payload],
}),
removeFetchedPlugin: (state: PluginsState, action: PayloadAction<string>) => ({
...state,
fetchedPlugins: state.fetchedPlugins.filter(plugin => plugin.hash !== action.payload),
}),
setParsedPlugins: (state: PluginsState, action: PayloadAction<PluginFile[]>) => ({
...state,
parsedPlugins: action.payload,
}),
setFetchedPlugins: (state: PluginsState, action: PayloadAction<PluginsByHashWithMods[]>) => ({
...state,
fetchedPlugins: action.payload,
}),
setPending: (state: PluginsState, action: PayloadAction<number>) => ({
...state,
pending: action.payload, pending: action.payload,
fetchedPlugin: state.fetchedPlugin,
}), }),
decrementPending: (state: PluginsState, action: PayloadAction<number>) => ({ decrementPending: (state, action: PayloadAction<number>) => ({
...state, plugins: state.plugins,
pending: state.pending - action.payload, pending: state.pending - action.payload,
fetchedPlugin: state.fetchedPlugin,
}), }),
toggleParsedPlugin: (state: PluginsState, action: PayloadAction<string>) => ({ togglePlugin: (state, action: PayloadAction<string>) => ({
...state, plugins: state.plugins.map((plugin) => (plugin.filename === action.payload ? { ...plugin, enabled: !plugin.enabled } : plugin)),
parsedPlugins: state.parsedPlugins.map((plugin) => (plugin.filename === action.payload ? { ...plugin, enabled: !plugin.enabled } : plugin)), pending: state.pending,
fetchedPlugin: state.fetchedPlugin,
}), }),
toggleFetchedPlugin: (state: PluginsState, action: PayloadAction<string>) => ({ enableAllPlugins: (state) => ({
...state, plugins: state.plugins.map((plugin) => ({ ...plugin, enabled: !plugin.parseError && !excludedPlugins.includes(plugin.filename) && true })),
fetchedPlugins: state.fetchedPlugins.map((plugin) => (plugin.hash === action.payload ? { ...plugin, enabled: !plugin.enabled } : plugin)), pending: state.pending,
fetchedPlugin: state.fetchedPlugin,
}), }),
enableAllParsedPlugins: (state: PluginsState) => ({ disableAllPlugins: (state) => ({
...state, plugins: state.plugins.map((plugin) => ({ ...plugin, enabled: false })),
parsedPlugins: state.parsedPlugins.map((plugin) => ({ ...plugin, enabled: !plugin.parseError && !excludedPlugins.includes(plugin.filename) && true })), pending: state.pending,
fetchedPlugin: state.fetchedPlugin,
}), }),
enableAllFetchedPlugins: (state: PluginsState) => ({ setFetchedPlugin: (state, action: PayloadAction<PluginsByHashWithMods | undefined>) => ({
...state, plugins: state.plugins,
fetchedPlugins: state.fetchedPlugins.map((plugin) => ({ ...plugin, enabled: true })), pending: state.pending,
fetchedPlugin: action.payload,
}), }),
disableAllParsedPlugins: (state: PluginsState) => ({ clearPlugins: () => ({
...state, plugins: [],
parsedPlugins: state.parsedPlugins.map((plugin) => ({ ...plugin, enabled: false })),
}),
disableAllFetchedPlugins: (state: PluginsState) => ({
...state,
fetchedPlugins: state.fetchedPlugins.map((plugin) => ({ ...plugin, enabled: false })),
}),
setSelectedFetchedPlugin: (state: PluginsState, action: PayloadAction<PluginsByHashWithMods | undefined>) => ({
...state,
selectedFetchedPlugin: action.payload,
}),
clearParsedPlugins: (state: PluginsState) => ({
...state,
parsedPlugins: [],
pending: 0, pending: 0,
loadedPluginCells: [],
}), }),
}, },
}) })
export const { export const { addPlugin, setPlugins, setPending, decrementPending, togglePlugin, enableAllPlugins, disableAllPlugins, setFetchedPlugin, clearPlugins } = pluginsSlice.actions
addParsedPlugin,
addFetchedPlugin,
updateParsedPlugin,
updateFetchedPlugin,
removeFetchedPlugin,
setParsedPlugins,
setFetchedPlugins,
setPending,
decrementPending,
toggleParsedPlugin,
toggleFetchedPlugin,
enableAllParsedPlugins,
enableAllFetchedPlugins,
disableAllParsedPlugins,
disableAllFetchedPlugins,
setSelectedFetchedPlugin,
clearParsedPlugins,
} = pluginsSlice.actions;
export const selectPlugins = (state: AppState) => state.plugins export const selectPlugins = (state: AppState) => state.plugins
export const applyLoadOrder = (): AppThunk => (dispatch, getState) => { export const applyLoadOrder = (): AppThunk => (dispatch, getState) => {
const { plugins, pluginsTxt } = getState(); const { plugins, pluginsTxt } = getState();
const originalPlugins = [...plugins.parsedPlugins]; const originalPlugins = [...plugins.plugins];
let newPlugins = []; let newPlugins = [];
for (let line of pluginsTxt.split("\n")) { for (let line of pluginsTxt.split("\n")) {
let enabled = false; let enabled = false;
@ -223,11 +179,11 @@ export const applyLoadOrder = (): AppThunk => (dispatch, getState) => {
} }
} }
} }
dispatch(setParsedPlugins([...originalPlugins.sort((a, b) => b.lastModified - a.lastModified), ...newPlugins])); dispatch(setPlugins([...originalPlugins.sort((a, b) => b.lastModified - a.lastModified), ...newPlugins]));
} }
export const addParsedPluginInOrder = (plugin: PluginFile): AppThunk => (dispatch) => { export const addPluginInOrder = (plugin: PluginFile): AppThunk => (dispatch) => {
dispatch(updateParsedPlugin(plugin)); dispatch(addPlugin(plugin));
dispatch(applyLoadOrder()); dispatch(applyLoadOrder());
} }

View File

@ -1,29 +0,0 @@
.wrapper {
margin-top: 24px;
margin-bottom: 24px;
}
a.name {
margin-top: 24px;
word-wrap: break-word;
}
.select-container {
margin-top: 12px;
}
.select {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.label {
white-space: nowrap;
margin-right: 12px;
font-weight: bold;
width: 100%;
display: block;
margin-bottom: 4px;
}

View File

@ -1,29 +0,0 @@
.dialog {
top: 12px;
z-index: 8;
background-color: #fbefd5;
box-shadow: 0 0 1em black;
max-width: 400px;
min-width: 300px;
min-height: 400px;
}
.dialog[open] {
display: flex;
flex-direction: column;
}
.dialog h3 {
margin-top: 0px;
}
.dialog menu {
padding: 0;
display: flex;
justify-content: space-between;
margin-top: auto;
}
.button {
margin-bottom: 24px;
}

View File

@ -48,27 +48,3 @@
.filter { .filter {
width: 175px; width: 175px;
} }
.pagination {
display: flex;
flex-direction: row;
list-style-type: none;
padding: 0;
margin-top: 0;
width: 100%;
justify-content: space-between;
}
.pagination li {
padding: 4px;
}
.pagination li a {
cursor: pointer;
user-select: none;
}
.active-page a {
color: black;
text-decoration: none;
}

View File

@ -1,35 +1,3 @@
.no-top-margin { .no-top-margin {
margin-top: 0; margin-top: 0;
} }
.step {
font-size: 1.5rem;
}
.break-word {
word-break: break-word;
}
.dialog {
top: 12px;
z-index: 8;
background-color: #fbefd5;
box-shadow: 0 0 1em black;
max-width: 400px;
}
.dialog label {
user-select: none;
cursor: pointer;
}
.dialog menu {
padding: 0;
display: flex;
justify-content: center;
}
.dialog menu button {
font-size: 14px;
padding: 4px 8px;
}

View File

@ -1,60 +0,0 @@
.plugin-list {
list-style-type: none;
padding: 0;
margin: 0;
}
.plugin-list li {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
.bottom-spacing {
margin-bottom: 12px;
}
.plugin-row {
display: flex;
align-items: center;
}
.plugin-label {
margin-left: 8px;
margin-right: 4px;
overflow: hidden;
text-overflow: ellipsis;
}
.plugin-remove {
margin-left: auto;
padding: 2px 8px;
background: none;
border: none;
display: flex;
align-items: center;
cursor: pointer;
}
.plugin-remove:hover img {
filter: invert(40%);
}
.loading {
margin-bottom: 12px;
}
.buttons {
display: flex;
flex-direction: row;
padding-right: 12px;
padding-left: 12px;
justify-content: space-evenly;
margin-bottom: 12px;
}
.buttons button {
flex: 1;
margin-right: 12px;
margin-right: 12px;
}

View File

@ -6,47 +6,3 @@ a.name {
line-height: 1.75rem; line-height: 1.75rem;
word-wrap: break-word; word-wrap: break-word;
} }
.select-container {
margin-top: 12px;
}
.select {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.label {
white-space: nowrap;
margin-right: 12px;
font-weight: bold;
width: 100%;
display: block;
margin-bottom: 4px;
}
.spacer {
margin-bottom: 12px;
}
.plugin-actions {
display: flex;
flex-direction: row;
padding-right: 12px;
padding-left: 12px;
justify-content: space-evenly;
align-items: center;
}
.plugin-link {
flex: 1;
}
.button {
flex: 1;
margin-top: 12px;
margin-bottom: 12px;
margin-right: auto;
}

View File

@ -70,11 +70,6 @@
width: 175px; width: 175px;
} }
.game {
min-width: 175px;
width: 175px;
}
.filter { .filter {
width: 175px; width: 175px;
} }
@ -112,27 +107,3 @@
.desc { .desc {
transform: rotate(90deg); transform: rotate(90deg);
} }
.pagination {
display: flex;
flex-direction: row;
list-style-type: none;
padding: 0;
margin-top: 0;
width: 100%;
justify-content: space-between;
}
.pagination li {
padding: 4px;
}
.pagination li a {
cursor: pointer;
user-select: none;
}
.active-page a {
color: black;
text-decoration: none;
}

View File

@ -2,7 +2,3 @@ h1.name {
line-height: 1.75rem; line-height: 1.75rem;
word-wrap: break-word; word-wrap: break-word;
} }
.spacer {
margin-bottom: 12px;
}

View File

@ -2,10 +2,3 @@
color: red; color: red;
margin-top: 12px; margin-top: 12px;
} }
.button {
margin-bottom: 12px;
margin-right: auto;
padding-left: 12px;
padding-right: 12px;
}

View File

@ -30,18 +30,6 @@
margin-top: 34px; margin-top: 34px;
} }
.no-top-margin {
margin-top: 0;
}
.no-bottom-margin {
margin-bottom: 0;
}
.step {
font-size: 1.5rem;
}
.break-word { .break-word {
word-break: break-word; word-break: break-word;
} }

View File

@ -1,4 +1,4 @@
.search-bar-fixed { .search-bar {
position: fixed; position: fixed;
top: 8px; top: 8px;
width: 150px; width: 150px;
@ -6,33 +6,29 @@
z-index: 2; z-index: 2;
} }
.search-bar-fixed.search-bar-focused { .search-bar.search-bar-focused {
width: max(40vw, 250px); width: max(40vw, 250px);
left: calc(50% - max(20vw, 125px)); left: calc(50% - max(20vw, 125px));
} }
@media only screen and (min-width: 600px) { @media only screen and (min-width: 600px) {
.search-bar-fixed.search-bar-sidebar-open { .search-bar.search-bar-sidebar-open {
left: calc(50% + 75px); left: calc(50% + 75px);
} }
.search-bar-fixed.search-bar-focused.search-bar-sidebar-open { .search-bar.search-bar-focused.search-bar-sidebar-open {
left: calc(50% - max(20vw, 125px) + 125px); left: calc(50% - max(20vw, 125px) + 125px);
} }
} }
.search-bar input { .search-bar input {
width: 100%; width: 150px;
border-radius: 8px; border-radius: 8px;
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
} }
.search-bar-fixed input { .search-bar.search-bar.search-bar-focused input {
width: 150px;
}
.search-bar-fixed.search-bar-focused input {
width: max(40vw, 250px); width: max(40vw, 250px);
border-radius: 8px; border-radius: 8px;
padding-left: 8px; padding-left: 8px;
@ -62,7 +58,3 @@
.highlighted-result { .highlighted-result {
background-color: #bde4ff; background-color: #bde4ff;
} }
.error {
color: red;
}

View File

@ -48,7 +48,7 @@
} }
.close:hover { .close:hover {
filter: invert(40%); color: #888888;
} }
.hide { .hide {