Compare commits

..

56 Commits

Author SHA1 Message Date
e7be15a201 Bump up heatmap gradient midpoint again 2022-10-05 18:49:19 -04:00
1565db5b30 Move last updated time to bottom of sidebar 2022-10-05 18:21:59 -04:00
9579e659c9 Update skyrim-cell-dump-wasm to 0.1.4 2022-10-01 17:56:11 -04:00
525e11aff6 Update skyrim-cell-dump-wasm to 0.1.3 2022-09-30 16:46:41 -04:00
a8b15c3f17 Update skyrim-cell-dump-wasm to 0.1.2 2022-09-30 12:59:41 -04:00
1c10f95ddd Update mapbox 2022-09-30 12:51:37 -04:00
513e95d697 Don't terminate & recreate workers after each task
After some testing, I'm fairly confident the issue I was seeing before isn't
happening now. After re-processing ~250 plugins a few times with the same
workers I was seeing significant slow-downs with large plugins. Also, if I was
listening to music in the browser it would start crackling and popping.

My hypothesis is that wee_alloc was causing a memory leak in the old version
which was causing browsers to freak out unless I recreated the workers on every
run. Though I'm not sure if that theory holds since I wasn't seeing out of
control memory usage in the dev tools. Perhaps wee_alloc just wasn't good at
managing memory after a certain number of allocate/deallocate cycles and was
putting a huge strain on the browser.

I'm hoping that reusing workers like this will also speed up processing and
possibly resolve some crashing that some users were seeing.
2022-09-26 23:04:48 -04:00
f8956413c2 Upgrade skyrim-cell-dump-wasm
Uses default allocator instead of wee_alloc which might have a memory leak.
2022-09-26 22:57:20 -04:00
4af6285590 Fix white cell outline after selection 2022-09-23 17:43:40 -04:00
c32e30492c Fix navigating to LE mod from search 2022-09-22 13:16:06 -04:00
57088e12ef Adjust color gradient midpoint to 630
Now that there are significantly more mods and cell edits, this reduces the amount of red cells.
2022-09-18 14:24:50 -04:00
48e524b247 Prevent TypeError in sentry beforeSend 2022-09-12 21:19:04 -04:00
ec65c6aaa3 Use [SE] in search results for SE mods
Instead of [SSE] so the different edtions are the same number of characters.
2022-09-07 12:50:30 -04:00
eebc43fbee Rename game to edition in the UI
Much clearer to say Classic vs Special Edition instead of "skyrim" vs "skyrimspecialedition".
2022-09-06 15:15:27 -04:00
941eb7b08f Fix getGameNameById
Was totally broken, oops.
2022-09-05 18:02:48 -04:00
8adb07e70f
Merge pull request #6 from thallada/skyrim-le
Support for multiple games
2022-09-03 15:49:03 -04:00
8f254ef761 Finish support for multiple games
* Adds game filter to ModList.
* Prefixes search results with game
* Adds `&game=X` to URL for mods
* Loads json from new directories on static server
2022-09-03 15:41:44 -04:00
5ff11d568e Add support for multiple games
Just supporting skyrim and skyrimspecialedition for now.

Move live download count fetching to a context provider. Also adds new games fetching context provider.
2022-09-03 02:51:57 -04:00
d103451a40
Merge pull request #5 from thallada/add-mod-to-list
Add mod to list, select files & plugins of mod, and perf improvements
2022-08-30 00:18:28 -04:00
bcd4cb0fa7 Filter out LogRocket pings from sentry breadcrumbs 2022-08-30 00:12:13 -04:00
07271f2fae Add logrocket session url in sentry beforeSend 2022-08-29 23:51:04 -04:00
098e3f3ce5 Add Sentry redux integration 2022-08-29 23:47:15 -04:00
4c79640850 Try to fix logrocket 2022-08-29 22:51:12 -04:00
bbe2698f0b Convert env vars to NEXT_PUBLIC_* vars 2022-08-29 22:37:13 -04:00
9ceb7cc00c Fixup sentry config
Correct project name, use SENTRY_ENVIRONMENT env var.
2022-08-29 22:18:57 -04:00
ddd67dc25c Configure sentry and logrocket per-env 2022-08-29 22:05:59 -04:00
e1e9a77c90 Upgrade @reduxjs/toolkit 2022-08-29 21:42:19 -04:00
5f196b0651 Add logrocket integrations 2022-08-29 01:19:01 -04:00
28c50a56f1 Add sentry for error tracking
If an exception occurs on the page I'll be alerted.
2022-08-29 01:01:51 -04:00
aa2bd77435 Add logrocket
Useful for figuring out what went wrong during an error or to see how users are using the app.
2022-08-29 00:48:03 -04:00
2bd5227287 Notification message for add/remove mod
Also changed how scrolling resets on pagination.

And tweaked pagination styling.
2022-08-28 23:43:44 -04:00
10bc093300 Fix scroll on page change, add paginator at top of lists 2022-08-28 21:51:56 -04:00
5491894e00 Add pagination to cell and mod lists
Speeds up showing and hiding of the sidebar.
2022-08-28 02:27:53 -04:00
47e95f4d59 Fix close button not sticking to the top of sidebar 2022-08-27 17:24:54 -04:00
236b4c84ca SearchProvider singleton, fix lag with addAllAsync
Load modSearch data asynchronously and only rebuild the search if the page is refreshed. Fixes the lag when returning to the base sidebar page from a data page.
2022-08-27 17:01:16 -04:00
f99a9cf79b Fix slow re-render of sidebar due to useSWR
Very weird that useSWR is blocking the render in this way. Trusty `requestAnimationFrame` is here to save the day yet again.
2022-08-21 01:54:53 -04:00
921247343c Remove unneeded toString in PluginDetail 2022-08-21 01:06:45 -04:00
329c6f8afd
Add Mapbox token instructions to README 2022-08-20 01:49:16 -04:00
4f3a439679 Update map selectedCells on plugin select 2022-08-20 00:52:40 -04:00
c94eb6a399 Add plugin actions on ModData page 2022-08-20 00:12:10 -04:00
75643f89ae Select file and plugin on ModData page 2022-08-19 23:50:16 -04:00
d76751f495 Close button icon hover styles 2022-08-19 22:35:00 -04:00
cb0c7922b7 Add/Remove plugin on plugin sidebar page 2022-08-19 19:37:38 -04:00
735670c163 Add fetched plugins to CellData, close img 2022-08-19 19:09:33 -04:00
cd61c29a96 Implement removing fetched plugins 2022-08-19 19:00:10 -04:00
9f86fe1571 EscapeListener and display added plugins on map 2022-08-19 18:39:05 -04:00
2065b5fa3a Select file and plugin, add to new plugins state 2022-08-17 23:19:55 -04:00
a067f21f15 Add dialog for searching and adding mod
Still WIP, need to implement selecting a particular plugin under mod, which will require some backend changes.
2022-05-31 23:55:36 -04:00
db5fd884b0 Convert readme screenshot to jpg image 2022-04-28 23:05:47 -04:00
358090f833 Add link and screenshot to README 2022-04-28 23:03:08 -04:00
92ee93a3c9 Add a notice about the "upload" dialog
Users keep getting confused about the webkit directory selection dialog that has unfortunate wording about "uploading" that I can't change. Hopefully this new dialog that displays before the dialog I can't control will help users understand that nothing is getting uploaded anywhere.

The message can be ignored in the future with a checkbox that sets a cookie.
2022-04-28 22:48:31 -04:00
c58a3a0316 Remove console.log 2022-03-23 11:19:09 -04:00
dbd6c02264 Fixup search
Fix selecting cell 0, 0 and improve regex criteria for when cell results show vs mod results.
2022-03-23 11:16:25 -04:00
139257f2a0 Add instructions for Mod Organizer users 2022-03-19 17:49:33 -04:00
a9d1a5af54 Allow loading from directories other than Data
Enables users to upload MO2 mod directory.

Also use updatePlugin instead of addPlugin to prevent duplicate plugins.
2022-03-19 17:42:24 -04:00
6d0eed2628 Add steps to default sidebar step for clarity 2022-03-19 16:29:06 -04:00
56 changed files with 10173 additions and 813 deletions

4
.babelrc Normal file
View File

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

3
.gitignore vendored
View File

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

View File

@ -2,6 +2,10 @@
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.
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.
@ -26,7 +30,15 @@ First, install the dependencies:
npm install
```
Then, run the dev server:
Then create a file named `.env` at the root of the project with the contents:
```
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
npm run dev

186
components/AddModData.tsx Normal file
View File

@ -0,0 +1,186 @@
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

@ -0,0 +1,96 @@
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,8 +4,9 @@ import useSWRImmutable from "swr/immutable";
import styles from "../styles/CellData.module.css";
import ModList from "./ModList";
import PluginList from "./PluginsList";
import ParsedPluginsList from "./ParsedPluginsList";
import { jsonFetcher } from "../lib/api";
import FetchedPluginsList from "./FetchedPluginsList";
export interface Mod {
id: number;
@ -39,10 +40,9 @@ export interface Cell {
type Props = {
selectedCell: { x: number; y: number };
counts: Record<number, [number, number, number]> | null;
};
const CellData: React.FC<Props> = ({ selectedCell, counts }) => {
const CellData: React.FC<Props> = ({ selectedCell }) => {
const { data, error } = useSWRImmutable(
`https://cells.modmapper.com/${selectedCell.x}/${selectedCell.y}.json`,
(_) => jsonFetcher<Cell>(_)
@ -110,8 +110,9 @@ const CellData: React.FC<Props> = ({ selectedCell, counts }) => {
<span>{data.plugins_count}</span>
</li>
</ul>
<PluginList selectedCell={selectedCell} />
<ModList mods={data.mods} counts={counts} />
<ParsedPluginsList selectedCell={selectedCell} />
<FetchedPluginsList selectedCell={selectedCell} />
<ModList mods={data.mods} />
</>
)
);

View File

@ -1,10 +1,13 @@
import React, { useEffect, useRef, useState } from "react";
import Link from "next/link";
import MiniSearch from "minisearch";
import ReactPaginate from "react-paginate";
import styles from "../styles/CellList.module.css";
import type { CellCoord } from "./ModData";
const PAGE_SIZE = 100;
type Props = {
cells: CellCoord[];
};
@ -32,6 +35,7 @@ const CellList: React.FC<Props> = ({ cells }) => {
const [filter, setFilter] = useState<string>("");
const [filterResults, setFilterResults] = useState<Set<string>>(new Set());
const [page, setPage] = useState<number>(0);
const filteredCells = cells
.filter((cell) => !filter || filterResults.has(`${cell.x},${cell.y}`))
@ -45,10 +49,34 @@ const CellList: React.FC<Props> = ({ cells }) => {
}
}, [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 (
filteredCells && (
<>
<h2>Exterior Cells ({filteredCells.length})</h2>
<h2 id="exterior-cells">Exterior Cells ({filteredCells.length})</h2>
<div className={styles.filters}>
<hr />
<div className={styles["filter-row"]}>
@ -63,26 +91,32 @@ const CellList: React.FC<Props> = ({ cells }) => {
</div>
<hr />
</div>
{renderPagination()}
<ul className={styles["cell-list"]}>
{filteredCells.map((cell) => (
<li
key={`cell-${cell.x},${cell.y}`}
className={styles["cell-list-item"]}
>
<div className={styles["cell-title"]}>
<strong>
<Link
href={`/?cell=${encodeURIComponent(`${cell.x},${cell.y}`)}`}
>
<a>
{cell.x}, {cell.y}
</a>
</Link>
</strong>
</div>
</li>
))}
{filteredCells
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
.map((cell) => (
<li
key={`cell-${cell.x},${cell.y}`}
className={styles["cell-list-item"]}
>
<div className={styles["cell-title"]}>
<strong>
<Link
href={`/?cell=${encodeURIComponent(
`${cell.x},${cell.y}`
)}`}
>
<a>
{cell.x}, {cell.y}
</a>
</Link>
</strong>
</div>
</li>
))}
</ul>
{renderPagination()}
</>
)
);

View File

@ -1,18 +1,23 @@
import React, { useContext, useRef, useState, useEffect } from "react";
import Cookies from "js-cookie";
import { WorkerPoolContext } from "../lib/WorkerPool";
import { useAppSelector } from "../lib/hooks";
import { isPlugin, parsePluginFiles } from "../lib/plugins";
import styles from "../styles/DataDirPicker.module.css";
import { createPortal } from "react-dom";
type Props = {};
const DataDirPicker: React.FC<Props> = () => {
const workerPool = useContext(WorkerPoolContext);
const inputRef = useRef<HTMLInputElement>(null);
const plugins = useAppSelector((state) => state.plugins.plugins);
const plugins = useAppSelector((state) => state.plugins.parsedPlugins);
const pluginsPending = useAppSelector((state) => state.plugins.pending);
const [loading, setLoading] = useState<boolean>(false);
const [uploadNoticeShown, setUploadNoticeShown] = useState(false);
const [ignoreUploadNoticeChecked, setIgnoreUploadNoticeChecked] =
useState(false);
useEffect(() => {
if (pluginsPending === 0 && loading) {
@ -46,8 +51,9 @@ const DataDirPicker: React.FC<Props> = () => {
return (
<>
<p className={styles["no-top-margin"]}>
Select or drag-and-drop your Skyrim{" "}
<p>
<strong className={styles.step}>1. </strong>Select or drag-and-drop your
Skyrim{" "}
<strong>
<code>Data</code>
</strong>{" "}
@ -56,7 +62,64 @@ const DataDirPicker: React.FC<Props> = () => {
<br />
<br />
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>
{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
type="file"
webkitdirectory=""
@ -69,7 +132,11 @@ const DataDirPicker: React.FC<Props> = () => {
onClick={() => {
if (inputRef.current) {
inputRef.current.value = ""; // clear the value so reloading same directory works
inputRef.current.click();
if (Cookies.get("ignoreDataDirPickerUploadNotice") !== "true") {
setUploadNoticeShown(true);
} else {
inputRef.current.click();
}
}
}}
disabled={!workerPool || loading}

View File

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

View File

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

View File

@ -0,0 +1,30 @@
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

@ -0,0 +1,82 @@
/* 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

@ -0,0 +1,51 @@
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,12 +5,15 @@ import mapboxgl from "mapbox-gl";
import useSWRImmutable from "swr/immutable";
import { useAppDispatch, useAppSelector } from "../lib/hooks";
import { setFetchedPlugin, PluginFile } from "../slices/plugins";
import { setSelectedFetchedPlugin, PluginFile } from "../slices/plugins";
import styles from "../styles/Map.module.css";
import Sidebar from "./Sidebar";
import ToggleLayersControl from "./ToggleLayersControl";
import SearchBar from "./SearchBar";
import { csvFetcher, jsonFetcherWithLastModified } from "../lib/api";
import SearchProvider from "./SearchProvider";
import { jsonFetcherWithLastModified } from "../lib/api";
import DownloadCountsProvider from "./DownloadCountsProvider";
import GamesProvider from "./GamesProvider";
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
@ -22,10 +25,7 @@ colorGradient.setGradient(
"#FFA500",
"#FF0000"
);
colorGradient.setMidpoint(360);
const LIVE_DOWNLOAD_COUNTS_URL =
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
colorGradient.setMidpoint(730);
const Map: React.FC = () => {
const router = useRouter();
@ -55,23 +55,19 @@ const Map: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const dispatch = useAppDispatch();
const plugins = useAppSelector((state) => state.plugins.plugins);
const parsedPlugins = useAppSelector((state) => state.plugins.parsedPlugins);
const fetchedPlugins = useAppSelector(
(state) => state.plugins.fetchedPlugins
);
const pluginsPending = useAppSelector((state) => state.plugins.pending);
const fetchedPlugin = useAppSelector((state) => state.plugins.fetchedPlugin);
const selectedFetchedPlugin = useAppSelector(
(state) => state.plugins.selectedFetchedPlugin
);
const { data: cellsData, error: cellsError } = useSWRImmutable(
"https://cells.modmapper.com/edits.json",
(_) => jsonFetcherWithLastModified<Record<string, number>>(_)
);
// The live download counts are not really immutable, but I'd still rather load them once per session
const [counts, setCounts] = useState<Record<
number,
[number, number, number]
> | null>(null);
const { data: countsData, error: countsError } = useSWRImmutable(
LIVE_DOWNLOAD_COUNTS_URL,
csvFetcher
);
const selectMapCell = useCallback(
(cell: { x: number; y: number }) => {
@ -243,7 +239,7 @@ const Map: React.FC = () => {
const clearSelectedCells = useCallback(() => {
setSelectedCells(null);
dispatch(setFetchedPlugin(undefined));
dispatch(setSelectedFetchedPlugin(undefined));
if (map.current) {
map.current.removeFeatureState({ source: "selected-cells-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
@ -297,8 +293,10 @@ const Map: React.FC = () => {
} else if (router.query.plugin && typeof router.query.plugin === "string") {
clearSelectedCell();
setSidebarOpen(true);
if (plugins && plugins.length > 0 && pluginsPending === 0) {
const plugin = plugins.find((p) => p.hash === router.query.plugin);
if (parsedPlugins && parsedPlugins.length > 0 && pluginsPending === 0) {
const plugin = parsedPlugins.find(
(p) => p.hash === router.query.plugin
);
if (plugin && plugin.parsed) {
const cells = [];
const cellSet = new Set<number>();
@ -323,13 +321,13 @@ const Map: React.FC = () => {
}
if (
plugins &&
plugins.length > 0 &&
pluginsPending === 0 &&
((parsedPlugins && parsedPlugins.length > 0) ||
fetchedPlugins.length > 0) &&
!router.query.mod &&
!router.query.plugin
) {
const cells = plugins.reduce(
let cells = parsedPlugins.reduce(
(acc: { x: number; y: number }[], plugin: PluginFile) => {
if (plugin.enabled && plugin.parsed) {
const newCells = [...acc];
@ -349,6 +347,11 @@ const Map: React.FC = () => {
},
[]
);
cells = cells.concat(
fetchedPlugins
.filter((plugin) => plugin.enabled)
.flatMap((plugin) => plugin.cells)
);
selectCells(cells);
}
}, [
@ -362,8 +365,9 @@ const Map: React.FC = () => {
clearSelectedCell,
clearSelectedCells,
heatmapLoaded,
plugins,
parsedPlugins,
pluginsPending,
fetchedPlugins,
]);
useEffect(() => {
@ -371,12 +375,12 @@ const Map: React.FC = () => {
if (
router.query.plugin &&
typeof router.query.plugin === "string" &&
fetchedPlugin &&
fetchedPlugin.cells
selectedFetchedPlugin &&
selectedFetchedPlugin.cells
) {
const cells = [];
const cellSet = new Set<number>();
for (const cell of fetchedPlugin.cells) {
for (const cell of selectedFetchedPlugin.cells) {
if (
cell.x !== undefined &&
cell.y !== undefined &&
@ -388,7 +392,7 @@ const Map: React.FC = () => {
}
selectCells(cells);
}
}, [heatmapLoaded, fetchedPlugin, selectCells, router.query.plugin]);
}, [heatmapLoaded, selectedFetchedPlugin, selectCells, router.query.plugin]);
useEffect(() => {
if (!heatmapLoaded) return; // wait for all map layers to load
@ -618,12 +622,7 @@ const Map: React.FC = () => {
paint: {
"fill-color": ["get", "color"],
"fill-opacity": ["get", "opacity"],
"fill-outline-color": [
"case",
["boolean", ["feature-state", "selected"], false],
"white",
"transparent",
],
"fill-outline-color": "transparent",
},
},
"grid-labels-layer"
@ -808,17 +807,6 @@ const Map: React.FC = () => {
setHeatmapLoaded(true);
}, [cellsData, mapLoaded, router, setHeatmapLoaded]);
useEffect(() => {
if (countsData) {
const newCounts: Record<number, [number, number, number]> = {};
for (const line of countsData.split("\n")) {
const nums = line.split(",").map((count) => parseInt(count, 10));
newCounts[nums[0]] = [nums[1], nums[2], nums[3]];
}
setCounts(newCounts);
}
}, [setCounts, countsData]);
return (
<>
<div
@ -828,18 +816,66 @@ const Map: React.FC = () => {
ref={mapWrapper}
>
<div ref={mapContainer} className={styles["map-container"]}>
<Sidebar
selectedCell={selectedCell}
clearSelectedCell={() => router.push({ query: {} })}
setSelectedCells={setSelectedCells}
counts={counts}
countsError={countsError}
open={sidebarOpen}
setOpen={setSidebarOpenWithResize}
lastModified={cellsData && cellsData.lastModified}
/>
<ToggleLayersControl map={map} />
<SearchBar counts={counts} sidebarOpen={sidebarOpen} />
<DownloadCountsProvider>
<GamesProvider>
<SearchProvider>
<Sidebar
selectedCell={selectedCell}
clearSelectedCell={() => router.push({ query: {} })}
setSelectedCells={setSelectedCells}
open={sidebarOpen}
setOpen={setSidebarOpenWithResize}
lastModified={cellsData && cellsData.lastModified}
onSelectFile={(selectedFile) => {
const { plugin, ...withoutPlugin } = router.query;
if (selectedFile) {
router.push({
query: { ...withoutPlugin, file: selectedFile },
});
} else {
const { file, ...withoutFile } = withoutPlugin;
router.push({ query: { ...withoutFile } });
}
}}
onSelectPlugin={(selectedPlugin) => {
if (selectedPlugin) {
router.push({
query: { ...router.query, plugin: selectedPlugin },
});
} else {
const { plugin, ...withoutPlugin } = router.query;
router.push({ query: { ...withoutPlugin } });
}
}}
/>
<ToggleLayersControl map={map} />
<SearchBar
sidebarOpen={sidebarOpen}
placeholder="Search mods or cells…"
onSelectResult={(selectedItem) => {
if (!selectedItem) return;
if (
selectedItem.x !== undefined &&
selectedItem.y !== undefined
) {
router.push({
query: { cell: `${selectedItem.x},${selectedItem.y}` },
});
} else {
router.push({
query: {
game: selectedItem.game,
mod: selectedItem.id,
},
});
}
}}
includeCells
fixed
/>
</SearchProvider>
</GamesProvider>
</DownloadCountsProvider>
</div>
</div>
</>

View File

@ -1,17 +1,64 @@
import { format } from "date-fns";
import Head from "next/head";
import React, { useEffect } from "react";
import React, { useCallback, useContext, useEffect, useState } from "react";
import useSWRImmutable from "swr/immutable";
import { useAppDispatch, useAppSelector } from "../lib/hooks";
import CellList from "./CellList";
import styles from "../styles/ModData.module.css";
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 {
x: 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 {
id: number;
name: string;
@ -30,121 +77,216 @@ export interface Mod {
first_upload_at: string;
last_updated_files_at: string;
cells: CellCoord[];
files: ModFile[];
}
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
export const NEXUS_MODS_URL = "https://www.nexusmods.com";
type Props = {
game: string;
selectedMod: number;
counts: Record<number, [number, number, number]> | null;
selectedFile: number;
selectedPlugin: string;
setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
onSelectFile: (fileId: number) => void;
onSelectPlugin: (hash: string) => void;
};
const ModData: React.FC<Props> = ({
game,
selectedMod,
counts,
selectedFile,
selectedPlugin,
setSelectedCells,
onSelectFile,
onSelectPlugin,
}) => {
const { data, error } = useSWRImmutable(
`https://mods.modmapper.com/${selectedMod}.json`,
const {
games,
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>(_)
);
useEffect(() => {
if (data) setSelectedCells(data.cells);
}, [data, setSelectedCells]);
const { data: fileData, error: fileError } = useSWRImmutable(
selectedFile ? `https://files.modmapper.com/${selectedFile}.json` : null,
(_) => jsonFetcher<File>(_)
);
if (error && error.status === 404) {
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(() => {
if (modData && !selectedFile) setSelectedCells(modData.cells);
}, [modData, setSelectedCells, selectedFile]);
useEffect(() => {
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>;
} else if (error) {
return <div>{`Error loading mod data: ${error.message}`}</div>;
} else if (modError) {
return <div>{`Error loading mod modData: ${modError.message}`}</div>;
}
if (data === undefined)
if (modData === undefined)
return <div className={styles.status}>Loading...</div>;
if (data === null)
if (modData === null)
return <div className={styles.status}>Mod could not be found.</div>;
let numberFmt = new Intl.NumberFormat("en-US");
const modCounts = counts && counts[data.nexus_mod_id];
const gameName = getGameNameById(modData.game_id);
const gameDownloadCounts = gameName && counts[gameName].counts;
const modCounts =
gameDownloadCounts && gameDownloadCounts[modData.nexus_mod_id];
const total_downloads = modCounts ? modCounts[0] : 0;
const unique_downloads = modCounts ? modCounts[1] : 0;
const views = modCounts ? modCounts[2] : 0;
if (selectedMod && data) {
if (selectedMod && modData) {
return (
<>
<Head>
<title key="title">{`Modmapper - ${data.name}`}</title>
<title key="title">{`Modmapper - ${modData.name}`}</title>
<meta
key="description"
name="description"
content={`Map of Skyrim showing ${data.cells.length} cell edits from the mod: ${data.name}`}
content={`Map of Skyrim showing ${modData.cells.length} cell edits from the mod: ${modData.name}`}
/>
<meta
key="og:title"
property="og:title"
content={`Modmapper - ${data.name}`}
content={`Modmapper - ${modData.name}`}
/>
<meta
key="og:description"
property="og:description"
content={`Map of Skyrim showing ${data.cells.length} cell edits from the mod: ${data.name}`}
content={`Map of Skyrim showing ${modData.cells.length} cell edits from the mod: ${modData.name}`}
/>
<meta
key="twitter:title"
name="twitter:title"
content={`Modmapper - ${data.name}`}
content={`Modmapper - ${modData.name}`}
/>
<meta
key="twitter:description"
name="twitter:description"
content={`Map of Skyrim showing ${data.cells.length} cell edits from the mod: ${data.name}`}
content={`Map of Skyrim showing ${modData.cells.length} cell edits from the mod: ${modData.name}`}
/>
<meta
key="og:url"
property="og:url"
content={`https://modmapper.com/?mod=${data.nexus_mod_id}`}
content={`https://modmapper.com/?game=${getGameNameById(
modData.game_id
)}&mod=${modData.nexus_mod_id}`}
/>
</Head>
<h1>
<a
href={`${NEXUS_MODS_URL}/mods/${data.nexus_mod_id}`}
href={`${NEXUS_MODS_URL}/${getGameNameById(modData.game_id)}/mods/${
modData.nexus_mod_id
}`}
target="_blank"
rel="noreferrer noopener"
className={styles.name}
>
{data.name}
{modData.name}
</a>
</h1>
<div>
<strong>Edition:&nbsp;</strong>
{
editionNames[
getGameNameById(modData.game_id) ?? "skyrimspecialedition"
]
}
</div>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${data.category_id}`}
href={`${NEXUS_MODS_URL}/${getGameNameById(
modData.game_id
)}/mods/categories/${modData.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{data.category_name}
{modData.category_name}
</a>
{data.is_translation && <strong>&nbsp;(translation)</strong>}
{modData.is_translation && <strong>&nbsp;(translation)</strong>}
</div>
<div>
<strong>Author:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/users/${data.author_id}`}
href={`${NEXUS_MODS_URL}/${getGameNameById(
modData.game_id
)}/users/${modData.author_id}`}
target="_blank"
rel="noreferrer noopener"
>
{data.author_name}
{modData.author_name}
</a>
</div>
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(data.first_upload_at), "d MMM y")}
{format(new Date(modData.first_upload_at), "d MMM y")}
</div>
<div>
<strong>Last Update:</strong>{" "}
{format(new Date(data.last_update_at), "d MMM y")}
{format(new Date(modData.last_update_at), "d MMM y")}
</div>
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) &&
renderDownloadCountsLoading()}
{(!games || gamesError) && renderGamesError(gamesError)}
{counts.skyrim.error && renderDownloadCountsError(counts.skyrim.error)}
{counts.skyrimspecialedition.error &&
renderDownloadCountsError(counts.skyrimspecialedition.error)}
<div>
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
</div>
@ -152,7 +294,96 @@ const ModData: React.FC<Props> = ({
<strong>Unique Downloads:</strong>{" "}
{numberFmt.format(unique_downloads)}
</div>
<CellList cells={data.cells} />
<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}
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,9 +1,10 @@
/* eslint-disable @next/next/no-img-element */
import { format } from "date-fns";
import React, { useEffect, useRef, useState } from "react";
import React, { useContext, useEffect, useRef, useState } from "react";
import MiniSearch from "minisearch";
import Link from "next/link";
import useSWRImmutable from "swr/immutable";
import ReactPaginate from "react-paginate";
import styles from "../styles/ModList.module.css";
import type { Mod } from "./CellData";
@ -15,24 +16,35 @@ import {
setSortBy,
setSortAsc,
setFilter,
setGame,
setCategory,
setIncludeTranslations,
} from "../slices/modListFilters";
import { editionNames } from "../lib/games";
import { useAppDispatch, useAppSelector } from "../lib/hooks";
import { DownloadCountsContext } from "./DownloadCountsProvider";
import { GamesContext } from "./GamesProvider";
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
const NEXUS_MODS_URL = "https://www.nexusmods.com";
const PAGE_SIZE = 50;
type Props = {
mods: Mod[];
files?: File[];
counts: Record<number, [number, number, number]> | null;
};
const ModList: React.FC<Props> = ({ mods, files, counts }) => {
const ModList: React.FC<Props> = ({ mods, files }) => {
const {
games,
getGameNameById,
error: gamesError,
} = useContext(GamesContext);
const counts = useContext(DownloadCountsContext);
const dispatch = useAppDispatch();
const { sortBy, sortAsc, filter, category, includeTranslations } =
const { sortBy, sortAsc, filter, category, game, includeTranslations } =
useAppSelector((state) => state.modListFilters);
const [filterResults, setFilterResults] = useState<Set<number>>(new Set());
const [page, setPage] = useState<number>(0);
const { data: cellCounts, error: cellCountsError } = useSWRImmutable(
`https://mods.modmapper.com/mod_cell_counts.json`,
@ -41,7 +53,10 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
const modsWithCounts: ModWithCounts[] = mods
.map((mod) => {
const modCounts = counts && counts[mod.nexus_mod_id];
const gameName = getGameNameById(mod.game_id);
const gameDownloadCounts = gameName && counts[gameName].counts;
const modCounts =
gameDownloadCounts && gameDownloadCounts[mod.nexus_mod_id];
return {
...mod,
total_downloads: modCounts ? modCounts[0] : 0,
@ -56,6 +71,7 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
(mod) =>
(includeTranslations || !mod.is_translation) &&
(!filter || filterResults.has(mod.id)) &&
(game === "All" || getGameNameById(mod.game_id) === game) &&
(category === "All" || mod.category_name === category)
)
.sort((a, b) => {
@ -79,6 +95,19 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
let numberFmt = new Intl.NumberFormat("en-US");
const renderDownloadCountsLoading = () => (
<div>Loading live download counts...</div>
);
const renderDownloadCountsError = (error: Error) => (
<div>{`Error loading live download counts: ${error.message}`}</div>
);
const renderGamesError = (error?: Error) =>
error ? (
<div>{`Error loading games: ${error.message}`}</div>
) : (
<div>Error loading games</div>
);
const modSearch = useRef<MiniSearch<Mod> | null>(
null
) as React.MutableRefObject<MiniSearch<Mod>>;
@ -106,10 +135,34 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
}
}, [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 (
mods && (
<>
<h2>Nexus Mods ({modsWithCounts.length})</h2>
<h2 id="nexus-mods">Nexus Mods ({modsWithCounts.length})</h2>
<div className={styles.filters}>
<hr />
<div className={styles["filter-row"]}>
@ -138,7 +191,6 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
<div className={styles["sort-direction"]}>
<button
title="Sort ascending"
className={sortAsc ? styles.active : ""}
onClick={() => dispatch(setSortAsc(true))}
>
<img
@ -153,7 +205,6 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
</button>
<button
title="Sort descending"
className={!sortAsc ? styles.active : ""}
onClick={() => dispatch(setSortAsc(false))}
>
<img
@ -178,6 +229,26 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
onChange={(event) => dispatch(setFilter(event.target.value))}
/>
</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"]}>
<label htmlFor="category">Category:</label>
<select
@ -220,107 +291,136 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
</div>
<hr />
</div>
{renderPagination()}
<ul className={styles["mod-list"]}>
{modsWithCounts.map((mod) => (
<li key={mod.id} className={styles["mod-list-item"]}>
<div className={styles["mod-title"]}>
<strong>
<Link href={`/?mod=${mod.nexus_mod_id}`}>
<a>{mod.name}</a>
</Link>
</strong>
</div>
<div>
<a
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
>
View on Nexus Mods
</a>
</div>
<div>
<strong>Category:&nbsp;</strong>
<a
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>
<strong>Exterior Cells Edited:</strong>{" "}
{numberFmt.format(mod.exterior_cells_edited)}
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) &&
renderDownloadCountsLoading()}
{(!games || gamesError) && renderGamesError(gamesError)}
{counts.skyrim.error &&
renderDownloadCountsError(counts.skyrim.error)}
{counts.skyrimspecialedition.error &&
renderDownloadCountsError(counts.skyrimspecialedition.error)}
{modsWithCounts
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
.map((mod) => (
<li key={mod.id} className={styles["mod-list-item"]}>
<div className={styles["mod-title"]}>
<strong>
<Link
href={`/?game=${getGameNameById(mod.game_id)}&mod=${
mod.nexus_mod_id
}`}
>
<a>{mod.name}</a>
</Link>
</strong>
</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>
<strong>File:</strong> {file.name}
</div>
{file.mod_version && (
<div>
<a
href={`${NEXUS_MODS_URL}/${getGameNameById(
mod.game_id
)}/mods/${mod.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
>
View on Nexus Mods
</a>
</div>
<div>
<strong>Edition:&nbsp;</strong>
{
editionNames[
getGameNameById(mod.game_id) ?? "skyrimspecialedition"
]
}
</div>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/${getGameNameById(
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>
<strong>Version:</strong> {file.mod_version}
<strong>File:</strong> {file.name}
</div>
)}
{file.version && file.mod_version !== file.version && (
{file.mod_version && (
<div>
<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>
<strong>File Version:</strong> {file.version}
<strong>Size:</strong> {formatBytes(file.size)}
</div>
)}
{file.category && (
<div>
<strong>Category:</strong> {file.category}
</div>
)}
<div>
<strong>Size:</strong> {formatBytes(file.size)}
</div>
{file.uploaded_at && (
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(file.uploaded_at), "d MMM y")}
</div>
)}
</li>
))}
</ul>
</li>
))}
{file.uploaded_at && (
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(file.uploaded_at), "d MMM y")}
</div>
)}
</li>
))}
</ul>
</li>
))}
</ul>
{renderPagination()}
</>
)
);

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { useAppSelector, useAppDispatch } from "../lib/hooks";
import { setPluginsTxtAndApplyLoadOrder } from "../slices/pluginsTxt";
import styles from "../styles/PluginTxtEditor.module.css";
import EscapeListener from "./EscapeListener";
export const excludedPlugins = [
"Skyrim.esm",
@ -32,8 +33,9 @@ const PluginTxtEditor: React.FC<Props> = () => {
return (
<>
<EscapeListener onEscape={() => setPluginsTxtShown(false)} />
<p className={styles["top-spacing"]}>
Paste or drag-and-drop your{" "}
<strong className={styles.step}>2. </strong>Paste or drag-and-drop your{" "}
<strong>
<code>plugins.txt</code>
</strong>{" "}
@ -46,6 +48,16 @@ const PluginTxtEditor: React.FC<Props> = () => {
C:\Users\username\AppData\Local\Skyrim Special Edition
</code>
</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>
<button onClick={onPluginsTxtButtonClick} className={styles.button}>
{!pluginsTxt ? "Paste" : "Edit"} Skyrim plugins.txt file
@ -70,6 +82,15 @@ const PluginTxtEditor: React.FC<Props> = () => {
).
<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
the file.
</p>

View File

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

View File

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

View File

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

View File

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

8
lib/games.ts Normal file
View File

@ -0,0 +1,8 @@
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',
};

8
lib/logrocketSetup.ts Normal file
View File

@ -0,0 +1,8 @@
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 store from "./store";
import { clearPlugins, setPending } from "../slices/plugins";
import { clearParsedPlugins, setPending } from "../slices/plugins";
export const excludedPlugins = [
"Skyrim.esm",
@ -12,7 +12,7 @@ export const excludedPlugins = [
export const isPluginPath = (path: string) => {
if (
/^((Skyrim Special Edition|Skyrim|SkyrimVR)\/)?(Data\/)?[^/\\]*\.es[mpl]$/i.test(path)
/^.*\.es[mpl]$/i.test(path)
) {
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.");
return;
}
store.dispatch(clearPlugins());
store.dispatch(clearParsedPlugins());
store.dispatch(setPending(pluginFiles.length));
pluginFiles.forEach(async (plugin) => {

View File

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

View File

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

View File

@ -1,3 +1,5 @@
const { withSentryConfig } = require('@sentry/nextjs');
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
@ -8,4 +10,15 @@ const nextConfig = {
},
}
module.exports = nextConfig
const sentryWebpackPluginOptions = {
// 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);

8294
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

3
pages/404.js Normal file
View File

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

View File

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

39
pages/_error.js Normal file
View File

@ -0,0 +1,39 @@
/**
* 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

@ -0,0 +1,47 @@
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.

After

Width:  |  Height:  |  Size: 1.0 MiB

37
sentry.client.config.js Normal file
View File

@ -0,0 +1,37 @@
// 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;
},
});

4
sentry.properties Normal file
View File

@ -0,0 +1,4 @@
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

19
sentry.server.config.js Normal file
View File

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

View File

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

View File

@ -0,0 +1,29 @@
.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

@ -0,0 +1,29 @@
.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,3 +48,27 @@
.filter {
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,3 +1,35 @@
.no-top-margin {
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

@ -0,0 +1,60 @@
.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,3 +6,47 @@ a.name {
line-height: 1.75rem;
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,6 +70,11 @@
width: 175px;
}
.game {
min-width: 175px;
width: 175px;
}
.filter {
width: 175px;
}
@ -107,3 +112,27 @@
.desc {
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,3 +2,7 @@ h1.name {
line-height: 1.75rem;
word-wrap: break-word;
}
.spacer {
margin-bottom: 12px;
}

View File

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

View File

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

View File

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

View File

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