Compare commits
56 Commits
plugin-was
...
main
Author | SHA1 | Date | |
---|---|---|---|
e7be15a201 | |||
1565db5b30 | |||
9579e659c9 | |||
525e11aff6 | |||
a8b15c3f17 | |||
1c10f95ddd | |||
513e95d697 | |||
f8956413c2 | |||
4af6285590 | |||
c32e30492c | |||
57088e12ef | |||
48e524b247 | |||
ec65c6aaa3 | |||
eebc43fbee | |||
941eb7b08f | |||
8adb07e70f | |||
8f254ef761 | |||
5ff11d568e | |||
d103451a40 | |||
bcd4cb0fa7 | |||
07271f2fae | |||
098e3f3ce5 | |||
4c79640850 | |||
bbe2698f0b | |||
9ceb7cc00c | |||
ddd67dc25c | |||
e1e9a77c90 | |||
5f196b0651 | |||
28c50a56f1 | |||
aa2bd77435 | |||
2bd5227287 | |||
10bc093300 | |||
5491894e00 | |||
47e95f4d59 | |||
236b4c84ca | |||
f99a9cf79b | |||
921247343c | |||
329c6f8afd | |||
4f3a439679 | |||
c94eb6a399 | |||
75643f89ae | |||
d76751f495 | |||
cb0c7922b7 | |||
735670c163 | |||
cd61c29a96 | |||
9f86fe1571 | |||
2065b5fa3a | |||
a067f21f15 | |||
db5fd884b0 | |||
358090f833 | |||
92ee93a3c9 | |||
c58a3a0316 | |||
dbd6c02264 | |||
139257f2a0 | |||
a9d1a5af54 | |||
6d0eed2628 |
4
.babelrc
Normal file
4
.babelrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"presets": ["next/babel"],
|
||||||
|
"plugins": ["add-react-displayname"]
|
||||||
|
}
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -35,3 +35,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
.sentryclirc
|
||||||
|
14
README.md
14
README.md
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
This is the frontend code for the modmapper project. Modmapper is an interactive map of Skyrim mods.
|
This is the frontend code for the modmapper project. Modmapper is an interactive map of Skyrim mods.
|
||||||
|
|
||||||
|
[View the site live at modmapper.com](https://modmapper.com).
|
||||||
|
|
||||||
|
![Screenshot of the website](/public/img/full-screenshot.jpg)
|
||||||
|
|
||||||
This project renders every cell edit from all Skyrim SE mods on nexusmods.com as a heatmap on top of a map of Skyrim.
|
This project renders every cell edit from all Skyrim SE mods on nexusmods.com as a heatmap on top of a map of Skyrim.
|
||||||
|
|
||||||
You can click on a cell to see all of the mods that edit that cell sorted by popularity. Clicking on a mod in that list will show you all of the cells that the mod edits (across all files and versions of the mod). You can also search for a mod by name or a cell by x and y coordinates in the search bar at the top.
|
You can click on a cell to see all of the mods that edit that cell sorted by popularity. Clicking on a mod in that list will show you all of the cells that the mod edits (across all files and versions of the mod). You can also search for a mod by name or a cell by x and y coordinates in the search bar at the top.
|
||||||
@ -26,7 +30,15 @@ First, install the dependencies:
|
|||||||
npm install
|
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
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
|
186
components/AddModData.tsx
Normal file
186
components/AddModData.tsx
Normal 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: </strong>
|
||||||
|
<a
|
||||||
|
href={`${NEXUS_MODS_URL}/mods/categories/${modData.category_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{modData.category_name}
|
||||||
|
</a>
|
||||||
|
{modData.is_translation && <strong> (translation)</strong>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Author: </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;
|
96
components/AddModDialog.tsx
Normal file
96
components/AddModDialog.tsx
Normal 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;
|
@ -4,8 +4,9 @@ import useSWRImmutable from "swr/immutable";
|
|||||||
|
|
||||||
import styles from "../styles/CellData.module.css";
|
import styles from "../styles/CellData.module.css";
|
||||||
import ModList from "./ModList";
|
import ModList from "./ModList";
|
||||||
import PluginList from "./PluginsList";
|
import ParsedPluginsList from "./ParsedPluginsList";
|
||||||
import { jsonFetcher } from "../lib/api";
|
import { jsonFetcher } from "../lib/api";
|
||||||
|
import FetchedPluginsList from "./FetchedPluginsList";
|
||||||
|
|
||||||
export interface Mod {
|
export interface Mod {
|
||||||
id: number;
|
id: number;
|
||||||
@ -39,10 +40,9 @@ export interface Cell {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedCell: { x: number; y: number };
|
selectedCell: { x: number; y: number };
|
||||||
counts: Record<number, [number, number, number]> | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CellData: React.FC<Props> = ({ selectedCell, counts }) => {
|
const CellData: React.FC<Props> = ({ selectedCell }) => {
|
||||||
const { data, error } = useSWRImmutable(
|
const { data, error } = useSWRImmutable(
|
||||||
`https://cells.modmapper.com/${selectedCell.x}/${selectedCell.y}.json`,
|
`https://cells.modmapper.com/${selectedCell.x}/${selectedCell.y}.json`,
|
||||||
(_) => jsonFetcher<Cell>(_)
|
(_) => jsonFetcher<Cell>(_)
|
||||||
@ -110,8 +110,9 @@ const CellData: React.FC<Props> = ({ selectedCell, counts }) => {
|
|||||||
<span>{data.plugins_count}</span>
|
<span>{data.plugins_count}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<PluginList selectedCell={selectedCell} />
|
<ParsedPluginsList selectedCell={selectedCell} />
|
||||||
<ModList mods={data.mods} counts={counts} />
|
<FetchedPluginsList selectedCell={selectedCell} />
|
||||||
|
<ModList mods={data.mods} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import MiniSearch from "minisearch";
|
import MiniSearch from "minisearch";
|
||||||
|
import ReactPaginate from "react-paginate";
|
||||||
|
|
||||||
import styles from "../styles/CellList.module.css";
|
import styles from "../styles/CellList.module.css";
|
||||||
import type { CellCoord } from "./ModData";
|
import type { CellCoord } from "./ModData";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
cells: CellCoord[];
|
cells: CellCoord[];
|
||||||
};
|
};
|
||||||
@ -32,6 +35,7 @@ const CellList: React.FC<Props> = ({ cells }) => {
|
|||||||
|
|
||||||
const [filter, setFilter] = useState<string>("");
|
const [filter, setFilter] = useState<string>("");
|
||||||
const [filterResults, setFilterResults] = useState<Set<string>>(new Set());
|
const [filterResults, setFilterResults] = useState<Set<string>>(new Set());
|
||||||
|
const [page, setPage] = useState<number>(0);
|
||||||
|
|
||||||
const filteredCells = cells
|
const filteredCells = cells
|
||||||
.filter((cell) => !filter || filterResults.has(`${cell.x},${cell.y}`))
|
.filter((cell) => !filter || filterResults.has(`${cell.x},${cell.y}`))
|
||||||
@ -45,10 +49,34 @@ const CellList: React.FC<Props> = ({ cells }) => {
|
|||||||
}
|
}
|
||||||
}, [filter]);
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [filterResults]);
|
||||||
|
|
||||||
|
const renderPagination = () => (
|
||||||
|
<ReactPaginate
|
||||||
|
breakLabel="..."
|
||||||
|
nextLabel=">"
|
||||||
|
forcePage={page}
|
||||||
|
onPageChange={(event) => {
|
||||||
|
setPage(event.selected);
|
||||||
|
document.getElementById("exterior-cells")?.scrollIntoView();
|
||||||
|
}}
|
||||||
|
pageRangeDisplayed={3}
|
||||||
|
marginPagesDisplayed={2}
|
||||||
|
pageCount={Math.ceil(filteredCells.length / PAGE_SIZE)}
|
||||||
|
previousLabel="<"
|
||||||
|
renderOnZeroPageCount={() => null}
|
||||||
|
className={styles.pagination}
|
||||||
|
activeClassName={styles["active-page"]}
|
||||||
|
hrefBuilder={() => "#"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
filteredCells && (
|
filteredCells && (
|
||||||
<>
|
<>
|
||||||
<h2>Exterior Cells ({filteredCells.length})</h2>
|
<h2 id="exterior-cells">Exterior Cells ({filteredCells.length})</h2>
|
||||||
<div className={styles.filters}>
|
<div className={styles.filters}>
|
||||||
<hr />
|
<hr />
|
||||||
<div className={styles["filter-row"]}>
|
<div className={styles["filter-row"]}>
|
||||||
@ -63,26 +91,32 @@ const CellList: React.FC<Props> = ({ cells }) => {
|
|||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
</div>
|
</div>
|
||||||
|
{renderPagination()}
|
||||||
<ul className={styles["cell-list"]}>
|
<ul className={styles["cell-list"]}>
|
||||||
{filteredCells.map((cell) => (
|
{filteredCells
|
||||||
<li
|
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
|
||||||
key={`cell-${cell.x},${cell.y}`}
|
.map((cell) => (
|
||||||
className={styles["cell-list-item"]}
|
<li
|
||||||
>
|
key={`cell-${cell.x},${cell.y}`}
|
||||||
<div className={styles["cell-title"]}>
|
className={styles["cell-list-item"]}
|
||||||
<strong>
|
>
|
||||||
<Link
|
<div className={styles["cell-title"]}>
|
||||||
href={`/?cell=${encodeURIComponent(`${cell.x},${cell.y}`)}`}
|
<strong>
|
||||||
>
|
<Link
|
||||||
<a>
|
href={`/?cell=${encodeURIComponent(
|
||||||
{cell.x}, {cell.y}
|
`${cell.x},${cell.y}`
|
||||||
</a>
|
)}`}
|
||||||
</Link>
|
>
|
||||||
</strong>
|
<a>
|
||||||
</div>
|
{cell.x}, {cell.y}
|
||||||
</li>
|
</a>
|
||||||
))}
|
</Link>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{renderPagination()}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -1,18 +1,23 @@
|
|||||||
import React, { useContext, useRef, useState, useEffect } from "react";
|
import React, { useContext, useRef, useState, useEffect } from "react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
import { WorkerPoolContext } from "../lib/WorkerPool";
|
import { WorkerPoolContext } from "../lib/WorkerPool";
|
||||||
import { useAppSelector } from "../lib/hooks";
|
import { useAppSelector } from "../lib/hooks";
|
||||||
import { isPlugin, parsePluginFiles } from "../lib/plugins";
|
import { isPlugin, parsePluginFiles } from "../lib/plugins";
|
||||||
import styles from "../styles/DataDirPicker.module.css";
|
import styles from "../styles/DataDirPicker.module.css";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
type Props = {};
|
type Props = {};
|
||||||
|
|
||||||
const DataDirPicker: React.FC<Props> = () => {
|
const DataDirPicker: React.FC<Props> = () => {
|
||||||
const workerPool = useContext(WorkerPoolContext);
|
const workerPool = useContext(WorkerPoolContext);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const plugins = useAppSelector((state) => state.plugins.plugins);
|
const plugins = useAppSelector((state) => state.plugins.parsedPlugins);
|
||||||
const pluginsPending = useAppSelector((state) => state.plugins.pending);
|
const pluginsPending = useAppSelector((state) => state.plugins.pending);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [uploadNoticeShown, setUploadNoticeShown] = useState(false);
|
||||||
|
const [ignoreUploadNoticeChecked, setIgnoreUploadNoticeChecked] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pluginsPending === 0 && loading) {
|
if (pluginsPending === 0 && loading) {
|
||||||
@ -46,8 +51,9 @@ const DataDirPicker: React.FC<Props> = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className={styles["no-top-margin"]}>
|
<p>
|
||||||
Select or drag-and-drop your Skyrim{" "}
|
<strong className={styles.step}>1. </strong>Select or drag-and-drop your
|
||||||
|
Skyrim{" "}
|
||||||
<strong>
|
<strong>
|
||||||
<code>Data</code>
|
<code>Data</code>
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
@ -56,7 +62,64 @@ const DataDirPicker: React.FC<Props> = () => {
|
|||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
The Data folder can be found in the installation directory of the game.
|
The Data folder can be found in the installation directory of the game.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
For Mod Organizer users, select the mod directory located at{" "}
|
||||||
|
<strong>
|
||||||
|
<code className={styles["break-word"]}>
|
||||||
|
C:\Users\username\AppData\Local\ModOrganizer\Skyrim Special
|
||||||
|
Edition\mods
|
||||||
|
</code>
|
||||||
|
</strong>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
|
{typeof window !== "undefined" &&
|
||||||
|
createPortal(
|
||||||
|
<dialog open={uploadNoticeShown} className={styles.dialog}>
|
||||||
|
<p>
|
||||||
|
<strong>NOTE:</strong> the following dialog will ask you to upload
|
||||||
|
all the files in your Data folder.
|
||||||
|
<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't show this message again
|
||||||
|
</label>
|
||||||
|
<menu>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setUploadNoticeShown(false);
|
||||||
|
if (ignoreUploadNoticeChecked) {
|
||||||
|
Cookies.set("ignoreDataDirPickerUploadNotice", "true");
|
||||||
|
}
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ok
|
||||||
|
</button>
|
||||||
|
</menu>
|
||||||
|
</dialog>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
webkitdirectory=""
|
webkitdirectory=""
|
||||||
@ -69,7 +132,11 @@ const DataDirPicker: React.FC<Props> = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.value = ""; // clear the value so reloading same directory works
|
inputRef.current.value = ""; // clear the value so reloading same directory works
|
||||||
inputRef.current.click();
|
if (Cookies.get("ignoreDataDirPickerUploadNotice") !== "true") {
|
||||||
|
setUploadNoticeShown(true);
|
||||||
|
} else {
|
||||||
|
inputRef.current.click();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!workerPool || loading}
|
disabled={!workerPool || loading}
|
||||||
|
86
components/DownloadCountsProvider.tsx
Normal file
86
components/DownloadCountsProvider.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import React, { createContext, useEffect, useState } from "react";
|
||||||
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
|
||||||
|
import { csvFetcher } from "../lib/api";
|
||||||
|
|
||||||
|
type DownloadCounts = Record<number, [number, number, number]>;
|
||||||
|
interface GameDownloadCounts {
|
||||||
|
skyrim: {
|
||||||
|
counts: DownloadCounts | null;
|
||||||
|
error?: any;
|
||||||
|
};
|
||||||
|
skyrimspecialedition: {
|
||||||
|
counts: DownloadCounts | null;
|
||||||
|
error?: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadCountsContext = GameDownloadCounts;
|
||||||
|
|
||||||
|
const SSE_LIVE_DOWNLOAD_COUNTS_URL =
|
||||||
|
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
|
||||||
|
const LE_LIVE_DOWNLOAD_COUNTS_URL =
|
||||||
|
"https://staticstats.nexusmods.com/live_download_counts/mods/110.csv";
|
||||||
|
|
||||||
|
export const DownloadCountsContext = createContext<DownloadCountsContext>({
|
||||||
|
skyrim: {
|
||||||
|
counts: null,
|
||||||
|
},
|
||||||
|
skyrimspecialedition: {
|
||||||
|
counts: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseCountsCSV(csv: string): DownloadCounts {
|
||||||
|
const counts: Record<number, [number, number, number]> = {};
|
||||||
|
for (const line of csv.split("\n")) {
|
||||||
|
const nums = line.split(",").map((count) => parseInt(count, 10));
|
||||||
|
counts[nums[0]] = [nums[1], nums[2], nums[3]];
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadCountsProvider: React.FC = ({ children }) => {
|
||||||
|
const [skyrimCounts, setSkyrimCounts] = useState<DownloadCounts | null>(null);
|
||||||
|
const [skyrimspecialeditionCounts, setSkyrimspecialeditionCounts] =
|
||||||
|
useState<DownloadCounts | null>(null);
|
||||||
|
|
||||||
|
// The live download counts are not really immutable, but I'd still rather load them once per session
|
||||||
|
const { data: skyrimspecialeditionCSV, error: skyrimspecialeditionError } =
|
||||||
|
useSWRImmutable(SSE_LIVE_DOWNLOAD_COUNTS_URL, csvFetcher);
|
||||||
|
const { data: skyrimCSV, error: skyrimError } = useSWRImmutable(
|
||||||
|
LE_LIVE_DOWNLOAD_COUNTS_URL,
|
||||||
|
csvFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skyrimCSV) {
|
||||||
|
setSkyrimCounts(parseCountsCSV(skyrimCSV));
|
||||||
|
}
|
||||||
|
}, [setSkyrimCounts, skyrimCSV]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skyrimspecialeditionCSV) {
|
||||||
|
setSkyrimspecialeditionCounts(parseCountsCSV(skyrimspecialeditionCSV));
|
||||||
|
}
|
||||||
|
}, [setSkyrimspecialeditionCounts, skyrimspecialeditionCSV]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DownloadCountsContext.Provider
|
||||||
|
value={{
|
||||||
|
skyrim: {
|
||||||
|
counts: skyrimCounts,
|
||||||
|
error: skyrimError,
|
||||||
|
},
|
||||||
|
skyrimspecialedition: {
|
||||||
|
counts: skyrimspecialeditionCounts,
|
||||||
|
error: skyrimspecialeditionError,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DownloadCountsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadCountsProvider;
|
@ -30,10 +30,7 @@ export const DropZone: React.FC<Props> = ({ children, workerPool }) => {
|
|||||||
}
|
}
|
||||||
if (next.value.kind === "file" && isPluginPath(next.value.name)) {
|
if (next.value.kind === "file" && isPluginPath(next.value.name)) {
|
||||||
plugins.push(next.value);
|
plugins.push(next.value);
|
||||||
} else if (
|
} else if (next.value.kind === "directory") {
|
||||||
next.value.kind === "directory" &&
|
|
||||||
next.value.name === "Data"
|
|
||||||
) {
|
|
||||||
plugins.push(...(await findPluginsInDirHandle(next.value)));
|
plugins.push(...(await findPluginsInDirHandle(next.value)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
30
components/EscapeListener.tsx
Normal file
30
components/EscapeListener.tsx
Normal 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;
|
82
components/FetchedPluginsList.tsx
Normal file
82
components/FetchedPluginsList.tsx
Normal 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;
|
51
components/GamesProvider.tsx
Normal file
51
components/GamesProvider.tsx
Normal 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;
|
@ -5,12 +5,15 @@ import mapboxgl from "mapbox-gl";
|
|||||||
import useSWRImmutable from "swr/immutable";
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
||||||
import { setFetchedPlugin, PluginFile } from "../slices/plugins";
|
import { setSelectedFetchedPlugin, PluginFile } from "../slices/plugins";
|
||||||
import styles from "../styles/Map.module.css";
|
import styles from "../styles/Map.module.css";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
import ToggleLayersControl from "./ToggleLayersControl";
|
import ToggleLayersControl from "./ToggleLayersControl";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import { 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 ?? "";
|
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
|
||||||
|
|
||||||
@ -22,10 +25,7 @@ colorGradient.setGradient(
|
|||||||
"#FFA500",
|
"#FFA500",
|
||||||
"#FF0000"
|
"#FF0000"
|
||||||
);
|
);
|
||||||
colorGradient.setMidpoint(360);
|
colorGradient.setMidpoint(730);
|
||||||
|
|
||||||
const LIVE_DOWNLOAD_COUNTS_URL =
|
|
||||||
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
|
|
||||||
|
|
||||||
const Map: React.FC = () => {
|
const Map: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -55,23 +55,19 @@ const Map: React.FC = () => {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
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 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(
|
const { data: cellsData, error: cellsError } = useSWRImmutable(
|
||||||
"https://cells.modmapper.com/edits.json",
|
"https://cells.modmapper.com/edits.json",
|
||||||
(_) => jsonFetcherWithLastModified<Record<string, number>>(_)
|
(_) => jsonFetcherWithLastModified<Record<string, number>>(_)
|
||||||
);
|
);
|
||||||
// The live download counts are not really immutable, but I'd still rather load them once per session
|
|
||||||
const [counts, setCounts] = useState<Record<
|
|
||||||
number,
|
|
||||||
[number, number, number]
|
|
||||||
> | null>(null);
|
|
||||||
const { data: countsData, error: countsError } = useSWRImmutable(
|
|
||||||
LIVE_DOWNLOAD_COUNTS_URL,
|
|
||||||
csvFetcher
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectMapCell = useCallback(
|
const selectMapCell = useCallback(
|
||||||
(cell: { x: number; y: number }) => {
|
(cell: { x: number; y: number }) => {
|
||||||
@ -243,7 +239,7 @@ const Map: React.FC = () => {
|
|||||||
|
|
||||||
const clearSelectedCells = useCallback(() => {
|
const clearSelectedCells = useCallback(() => {
|
||||||
setSelectedCells(null);
|
setSelectedCells(null);
|
||||||
dispatch(setFetchedPlugin(undefined));
|
dispatch(setSelectedFetchedPlugin(undefined));
|
||||||
if (map.current) {
|
if (map.current) {
|
||||||
map.current.removeFeatureState({ source: "selected-cells-source" });
|
map.current.removeFeatureState({ source: "selected-cells-source" });
|
||||||
map.current.removeFeatureState({ source: "conflicted-cell-source" });
|
map.current.removeFeatureState({ source: "conflicted-cell-source" });
|
||||||
@ -297,8 +293,10 @@ const Map: React.FC = () => {
|
|||||||
} else if (router.query.plugin && typeof router.query.plugin === "string") {
|
} else if (router.query.plugin && typeof router.query.plugin === "string") {
|
||||||
clearSelectedCell();
|
clearSelectedCell();
|
||||||
setSidebarOpen(true);
|
setSidebarOpen(true);
|
||||||
if (plugins && plugins.length > 0 && pluginsPending === 0) {
|
if (parsedPlugins && parsedPlugins.length > 0 && pluginsPending === 0) {
|
||||||
const plugin = plugins.find((p) => p.hash === router.query.plugin);
|
const plugin = parsedPlugins.find(
|
||||||
|
(p) => p.hash === router.query.plugin
|
||||||
|
);
|
||||||
if (plugin && plugin.parsed) {
|
if (plugin && plugin.parsed) {
|
||||||
const cells = [];
|
const cells = [];
|
||||||
const cellSet = new Set<number>();
|
const cellSet = new Set<number>();
|
||||||
@ -323,13 +321,13 @@ const Map: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
plugins &&
|
|
||||||
plugins.length > 0 &&
|
|
||||||
pluginsPending === 0 &&
|
pluginsPending === 0 &&
|
||||||
|
((parsedPlugins && parsedPlugins.length > 0) ||
|
||||||
|
fetchedPlugins.length > 0) &&
|
||||||
!router.query.mod &&
|
!router.query.mod &&
|
||||||
!router.query.plugin
|
!router.query.plugin
|
||||||
) {
|
) {
|
||||||
const cells = plugins.reduce(
|
let cells = parsedPlugins.reduce(
|
||||||
(acc: { x: number; y: number }[], plugin: PluginFile) => {
|
(acc: { x: number; y: number }[], plugin: PluginFile) => {
|
||||||
if (plugin.enabled && plugin.parsed) {
|
if (plugin.enabled && plugin.parsed) {
|
||||||
const newCells = [...acc];
|
const newCells = [...acc];
|
||||||
@ -349,6 +347,11 @@ const Map: React.FC = () => {
|
|||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
cells = cells.concat(
|
||||||
|
fetchedPlugins
|
||||||
|
.filter((plugin) => plugin.enabled)
|
||||||
|
.flatMap((plugin) => plugin.cells)
|
||||||
|
);
|
||||||
selectCells(cells);
|
selectCells(cells);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@ -362,8 +365,9 @@ const Map: React.FC = () => {
|
|||||||
clearSelectedCell,
|
clearSelectedCell,
|
||||||
clearSelectedCells,
|
clearSelectedCells,
|
||||||
heatmapLoaded,
|
heatmapLoaded,
|
||||||
plugins,
|
parsedPlugins,
|
||||||
pluginsPending,
|
pluginsPending,
|
||||||
|
fetchedPlugins,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -371,12 +375,12 @@ const Map: React.FC = () => {
|
|||||||
if (
|
if (
|
||||||
router.query.plugin &&
|
router.query.plugin &&
|
||||||
typeof router.query.plugin === "string" &&
|
typeof router.query.plugin === "string" &&
|
||||||
fetchedPlugin &&
|
selectedFetchedPlugin &&
|
||||||
fetchedPlugin.cells
|
selectedFetchedPlugin.cells
|
||||||
) {
|
) {
|
||||||
const cells = [];
|
const cells = [];
|
||||||
const cellSet = new Set<number>();
|
const cellSet = new Set<number>();
|
||||||
for (const cell of fetchedPlugin.cells) {
|
for (const cell of selectedFetchedPlugin.cells) {
|
||||||
if (
|
if (
|
||||||
cell.x !== undefined &&
|
cell.x !== undefined &&
|
||||||
cell.y !== undefined &&
|
cell.y !== undefined &&
|
||||||
@ -388,7 +392,7 @@ const Map: React.FC = () => {
|
|||||||
}
|
}
|
||||||
selectCells(cells);
|
selectCells(cells);
|
||||||
}
|
}
|
||||||
}, [heatmapLoaded, fetchedPlugin, selectCells, router.query.plugin]);
|
}, [heatmapLoaded, selectedFetchedPlugin, selectCells, router.query.plugin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!heatmapLoaded) return; // wait for all map layers to load
|
if (!heatmapLoaded) return; // wait for all map layers to load
|
||||||
@ -618,12 +622,7 @@ const Map: React.FC = () => {
|
|||||||
paint: {
|
paint: {
|
||||||
"fill-color": ["get", "color"],
|
"fill-color": ["get", "color"],
|
||||||
"fill-opacity": ["get", "opacity"],
|
"fill-opacity": ["get", "opacity"],
|
||||||
"fill-outline-color": [
|
"fill-outline-color": "transparent",
|
||||||
"case",
|
|
||||||
["boolean", ["feature-state", "selected"], false],
|
|
||||||
"white",
|
|
||||||
"transparent",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"grid-labels-layer"
|
"grid-labels-layer"
|
||||||
@ -808,17 +807,6 @@ const Map: React.FC = () => {
|
|||||||
setHeatmapLoaded(true);
|
setHeatmapLoaded(true);
|
||||||
}, [cellsData, mapLoaded, router, setHeatmapLoaded]);
|
}, [cellsData, mapLoaded, router, setHeatmapLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (countsData) {
|
|
||||||
const newCounts: Record<number, [number, number, number]> = {};
|
|
||||||
for (const line of countsData.split("\n")) {
|
|
||||||
const nums = line.split(",").map((count) => parseInt(count, 10));
|
|
||||||
newCounts[nums[0]] = [nums[1], nums[2], nums[3]];
|
|
||||||
}
|
|
||||||
setCounts(newCounts);
|
|
||||||
}
|
|
||||||
}, [setCounts, countsData]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -828,18 +816,66 @@ const Map: React.FC = () => {
|
|||||||
ref={mapWrapper}
|
ref={mapWrapper}
|
||||||
>
|
>
|
||||||
<div ref={mapContainer} className={styles["map-container"]}>
|
<div ref={mapContainer} className={styles["map-container"]}>
|
||||||
<Sidebar
|
<DownloadCountsProvider>
|
||||||
selectedCell={selectedCell}
|
<GamesProvider>
|
||||||
clearSelectedCell={() => router.push({ query: {} })}
|
<SearchProvider>
|
||||||
setSelectedCells={setSelectedCells}
|
<Sidebar
|
||||||
counts={counts}
|
selectedCell={selectedCell}
|
||||||
countsError={countsError}
|
clearSelectedCell={() => router.push({ query: {} })}
|
||||||
open={sidebarOpen}
|
setSelectedCells={setSelectedCells}
|
||||||
setOpen={setSidebarOpenWithResize}
|
open={sidebarOpen}
|
||||||
lastModified={cellsData && cellsData.lastModified}
|
setOpen={setSidebarOpenWithResize}
|
||||||
/>
|
lastModified={cellsData && cellsData.lastModified}
|
||||||
<ToggleLayersControl map={map} />
|
onSelectFile={(selectedFile) => {
|
||||||
<SearchBar counts={counts} sidebarOpen={sidebarOpen} />
|
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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,17 +1,64 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import React, { useEffect } from "react";
|
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||||
import useSWRImmutable from "swr/immutable";
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
|
||||||
|
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
||||||
import CellList from "./CellList";
|
import CellList from "./CellList";
|
||||||
import styles from "../styles/ModData.module.css";
|
import styles from "../styles/ModData.module.css";
|
||||||
import { jsonFetcher } from "../lib/api";
|
import { jsonFetcher } from "../lib/api";
|
||||||
|
import { editionNames } from "../lib/games";
|
||||||
|
import {
|
||||||
|
PluginsByHashWithMods,
|
||||||
|
removeFetchedPlugin,
|
||||||
|
updateFetchedPlugin,
|
||||||
|
} from "../slices/plugins";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { DownloadCountsContext } from "./DownloadCountsProvider";
|
||||||
|
import { GamesContext } from "./GamesProvider";
|
||||||
|
|
||||||
export interface CellCoord {
|
export interface CellCoord {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModFile {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
category: string;
|
||||||
|
nexus_file_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilePlugin {
|
||||||
|
hash: number;
|
||||||
|
file_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileCell {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface File {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
file_name: string;
|
||||||
|
nexus_file_id: number;
|
||||||
|
mod_id: number;
|
||||||
|
category: string;
|
||||||
|
version: string;
|
||||||
|
mod_version: string;
|
||||||
|
size: number;
|
||||||
|
uploaded_at: string;
|
||||||
|
created_at: string;
|
||||||
|
downloaded_at: string;
|
||||||
|
has_plugin: boolean;
|
||||||
|
unable_to_extract_plugins: boolean;
|
||||||
|
cells: FileCell[];
|
||||||
|
plugins: FilePlugin[];
|
||||||
|
plugin_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Mod {
|
export interface Mod {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -30,121 +77,216 @@ export interface Mod {
|
|||||||
first_upload_at: string;
|
first_upload_at: string;
|
||||||
last_updated_files_at: string;
|
last_updated_files_at: string;
|
||||||
cells: CellCoord[];
|
cells: CellCoord[];
|
||||||
|
files: ModFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
|
export const NEXUS_MODS_URL = "https://www.nexusmods.com";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
game: string;
|
||||||
selectedMod: number;
|
selectedMod: number;
|
||||||
counts: Record<number, [number, number, number]> | null;
|
selectedFile: number;
|
||||||
|
selectedPlugin: string;
|
||||||
setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
|
setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
|
||||||
|
onSelectFile: (fileId: number) => void;
|
||||||
|
onSelectPlugin: (hash: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModData: React.FC<Props> = ({
|
const ModData: React.FC<Props> = ({
|
||||||
|
game,
|
||||||
selectedMod,
|
selectedMod,
|
||||||
counts,
|
selectedFile,
|
||||||
|
selectedPlugin,
|
||||||
setSelectedCells,
|
setSelectedCells,
|
||||||
|
onSelectFile,
|
||||||
|
onSelectPlugin,
|
||||||
}) => {
|
}) => {
|
||||||
const { data, error } = useSWRImmutable(
|
const {
|
||||||
`https://mods.modmapper.com/${selectedMod}.json`,
|
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>(_)
|
(_) => jsonFetcher<Mod>(_)
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: fileData, error: fileError } = useSWRImmutable(
|
||||||
if (data) setSelectedCells(data.cells);
|
selectedFile ? `https://files.modmapper.com/${selectedFile}.json` : null,
|
||||||
}, [data, setSelectedCells]);
|
(_) => 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>;
|
return <div>Mod could not be found.</div>;
|
||||||
} else if (error) {
|
} else if (modError) {
|
||||||
return <div>{`Error loading mod data: ${error.message}`}</div>;
|
return <div>{`Error loading mod modData: ${modError.message}`}</div>;
|
||||||
}
|
}
|
||||||
if (data === undefined)
|
if (modData === undefined)
|
||||||
return <div className={styles.status}>Loading...</div>;
|
return <div className={styles.status}>Loading...</div>;
|
||||||
if (data === null)
|
if (modData === null)
|
||||||
return <div className={styles.status}>Mod could not be found.</div>;
|
return <div className={styles.status}>Mod could not be found.</div>;
|
||||||
|
|
||||||
let numberFmt = new Intl.NumberFormat("en-US");
|
let numberFmt = new Intl.NumberFormat("en-US");
|
||||||
const 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 total_downloads = modCounts ? modCounts[0] : 0;
|
||||||
const unique_downloads = modCounts ? modCounts[1] : 0;
|
const unique_downloads = modCounts ? modCounts[1] : 0;
|
||||||
const views = modCounts ? modCounts[2] : 0;
|
const views = modCounts ? modCounts[2] : 0;
|
||||||
|
|
||||||
if (selectedMod && data) {
|
if (selectedMod && modData) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title key="title">{`Modmapper - ${data.name}`}</title>
|
<title key="title">{`Modmapper - ${modData.name}`}</title>
|
||||||
<meta
|
<meta
|
||||||
key="description"
|
key="description"
|
||||||
name="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
|
<meta
|
||||||
key="og:title"
|
key="og:title"
|
||||||
property="og:title"
|
property="og:title"
|
||||||
content={`Modmapper - ${data.name}`}
|
content={`Modmapper - ${modData.name}`}
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
key="og:description"
|
key="og:description"
|
||||||
property="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
|
<meta
|
||||||
key="twitter:title"
|
key="twitter:title"
|
||||||
name="twitter:title"
|
name="twitter:title"
|
||||||
content={`Modmapper - ${data.name}`}
|
content={`Modmapper - ${modData.name}`}
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
key="twitter:description"
|
key="twitter:description"
|
||||||
name="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
|
<meta
|
||||||
key="og:url"
|
key="og:url"
|
||||||
property="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>
|
</Head>
|
||||||
<h1>
|
<h1>
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
className={styles.name}
|
className={styles.name}
|
||||||
>
|
>
|
||||||
{data.name}
|
{modData.name}
|
||||||
</a>
|
</a>
|
||||||
</h1>
|
</h1>
|
||||||
|
<div>
|
||||||
|
<strong>Edition: </strong>
|
||||||
|
{
|
||||||
|
editionNames[
|
||||||
|
getGameNameById(modData.game_id) ?? "skyrimspecialedition"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Category: </strong>
|
<strong>Category: </strong>
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
{data.category_name}
|
{modData.category_name}
|
||||||
</a>
|
</a>
|
||||||
{data.is_translation && <strong> (translation)</strong>}
|
{modData.is_translation && <strong> (translation)</strong>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Author: </strong>
|
<strong>Author: </strong>
|
||||||
<a
|
<a
|
||||||
href={`${NEXUS_MODS_URL}/users/${data.author_id}`}
|
href={`${NEXUS_MODS_URL}/${getGameNameById(
|
||||||
|
modData.game_id
|
||||||
|
)}/users/${modData.author_id}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
{data.author_name}
|
{modData.author_name}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Uploaded:</strong>{" "}
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<strong>Last Update:</strong>{" "}
|
<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>
|
</div>
|
||||||
|
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) &&
|
||||||
|
renderDownloadCountsLoading()}
|
||||||
|
{(!games || gamesError) && renderGamesError(gamesError)}
|
||||||
|
{counts.skyrim.error && renderDownloadCountsError(counts.skyrim.error)}
|
||||||
|
{counts.skyrimspecialedition.error &&
|
||||||
|
renderDownloadCountsError(counts.skyrimspecialedition.error)}
|
||||||
<div>
|
<div>
|
||||||
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
|
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
|
||||||
</div>
|
</div>
|
||||||
@ -152,7 +294,96 @@ const ModData: React.FC<Props> = ({
|
|||||||
<strong>Unique Downloads:</strong>{" "}
|
<strong>Unique Downloads:</strong>{" "}
|
||||||
{numberFmt.format(unique_downloads)}
|
{numberFmt.format(unique_downloads)}
|
||||||
</div>
|
</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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||||
import MiniSearch from "minisearch";
|
import MiniSearch from "minisearch";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import useSWRImmutable from "swr/immutable";
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
import ReactPaginate from "react-paginate";
|
||||||
|
|
||||||
import styles from "../styles/ModList.module.css";
|
import styles from "../styles/ModList.module.css";
|
||||||
import type { Mod } from "./CellData";
|
import type { Mod } from "./CellData";
|
||||||
@ -15,24 +16,35 @@ import {
|
|||||||
setSortBy,
|
setSortBy,
|
||||||
setSortAsc,
|
setSortAsc,
|
||||||
setFilter,
|
setFilter,
|
||||||
|
setGame,
|
||||||
setCategory,
|
setCategory,
|
||||||
setIncludeTranslations,
|
setIncludeTranslations,
|
||||||
} from "../slices/modListFilters";
|
} from "../slices/modListFilters";
|
||||||
|
import { editionNames } from "../lib/games";
|
||||||
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
||||||
|
import { DownloadCountsContext } from "./DownloadCountsProvider";
|
||||||
|
import { GamesContext } from "./GamesProvider";
|
||||||
|
|
||||||
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
|
const NEXUS_MODS_URL = "https://www.nexusmods.com";
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mods: Mod[];
|
mods: Mod[];
|
||||||
files?: File[];
|
files?: File[];
|
||||||
counts: Record<number, [number, number, number]> | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
const ModList: React.FC<Props> = ({ mods, files }) => {
|
||||||
|
const {
|
||||||
|
games,
|
||||||
|
getGameNameById,
|
||||||
|
error: gamesError,
|
||||||
|
} = useContext(GamesContext);
|
||||||
|
const counts = useContext(DownloadCountsContext);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { sortBy, sortAsc, filter, category, includeTranslations } =
|
const { sortBy, sortAsc, filter, category, game, includeTranslations } =
|
||||||
useAppSelector((state) => state.modListFilters);
|
useAppSelector((state) => state.modListFilters);
|
||||||
const [filterResults, setFilterResults] = useState<Set<number>>(new Set());
|
const [filterResults, setFilterResults] = useState<Set<number>>(new Set());
|
||||||
|
const [page, setPage] = useState<number>(0);
|
||||||
|
|
||||||
const { data: cellCounts, error: cellCountsError } = useSWRImmutable(
|
const { data: cellCounts, error: cellCountsError } = useSWRImmutable(
|
||||||
`https://mods.modmapper.com/mod_cell_counts.json`,
|
`https://mods.modmapper.com/mod_cell_counts.json`,
|
||||||
@ -41,7 +53,10 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
|||||||
|
|
||||||
const modsWithCounts: ModWithCounts[] = mods
|
const modsWithCounts: ModWithCounts[] = mods
|
||||||
.map((mod) => {
|
.map((mod) => {
|
||||||
const modCounts = counts && counts[mod.nexus_mod_id];
|
const gameName = getGameNameById(mod.game_id);
|
||||||
|
const gameDownloadCounts = gameName && counts[gameName].counts;
|
||||||
|
const modCounts =
|
||||||
|
gameDownloadCounts && gameDownloadCounts[mod.nexus_mod_id];
|
||||||
return {
|
return {
|
||||||
...mod,
|
...mod,
|
||||||
total_downloads: modCounts ? modCounts[0] : 0,
|
total_downloads: modCounts ? modCounts[0] : 0,
|
||||||
@ -56,6 +71,7 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
|||||||
(mod) =>
|
(mod) =>
|
||||||
(includeTranslations || !mod.is_translation) &&
|
(includeTranslations || !mod.is_translation) &&
|
||||||
(!filter || filterResults.has(mod.id)) &&
|
(!filter || filterResults.has(mod.id)) &&
|
||||||
|
(game === "All" || getGameNameById(mod.game_id) === game) &&
|
||||||
(category === "All" || mod.category_name === category)
|
(category === "All" || mod.category_name === category)
|
||||||
)
|
)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@ -79,6 +95,19 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
|||||||
|
|
||||||
let numberFmt = new Intl.NumberFormat("en-US");
|
let numberFmt = new Intl.NumberFormat("en-US");
|
||||||
|
|
||||||
|
const renderDownloadCountsLoading = () => (
|
||||||
|
<div>Loading live download counts...</div>
|
||||||
|
);
|
||||||
|
const renderDownloadCountsError = (error: Error) => (
|
||||||
|
<div>{`Error loading live download counts: ${error.message}`}</div>
|
||||||
|
);
|
||||||
|
const renderGamesError = (error?: Error) =>
|
||||||
|
error ? (
|
||||||
|
<div>{`Error loading games: ${error.message}`}</div>
|
||||||
|
) : (
|
||||||
|
<div>Error loading games</div>
|
||||||
|
);
|
||||||
|
|
||||||
const modSearch = useRef<MiniSearch<Mod> | null>(
|
const modSearch = useRef<MiniSearch<Mod> | null>(
|
||||||
null
|
null
|
||||||
) as React.MutableRefObject<MiniSearch<Mod>>;
|
) as React.MutableRefObject<MiniSearch<Mod>>;
|
||||||
@ -106,10 +135,34 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
|||||||
}
|
}
|
||||||
}, [filter]);
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [filterResults, category, includeTranslations, sortBy, sortAsc]);
|
||||||
|
|
||||||
|
const renderPagination = () => (
|
||||||
|
<ReactPaginate
|
||||||
|
breakLabel="..."
|
||||||
|
nextLabel=">"
|
||||||
|
forcePage={page}
|
||||||
|
onPageChange={(event) => {
|
||||||
|
setPage(event.selected);
|
||||||
|
document.getElementById("nexus-mods")?.scrollIntoView();
|
||||||
|
}}
|
||||||
|
pageRangeDisplayed={3}
|
||||||
|
marginPagesDisplayed={2}
|
||||||
|
pageCount={Math.ceil(modsWithCounts.length / PAGE_SIZE)}
|
||||||
|
previousLabel="<"
|
||||||
|
renderOnZeroPageCount={() => null}
|
||||||
|
className={styles.pagination}
|
||||||
|
activeClassName={styles["active-page"]}
|
||||||
|
hrefBuilder={() => "#"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
mods && (
|
mods && (
|
||||||
<>
|
<>
|
||||||
<h2>Nexus Mods ({modsWithCounts.length})</h2>
|
<h2 id="nexus-mods">Nexus Mods ({modsWithCounts.length})</h2>
|
||||||
<div className={styles.filters}>
|
<div className={styles.filters}>
|
||||||
<hr />
|
<hr />
|
||||||
<div className={styles["filter-row"]}>
|
<div className={styles["filter-row"]}>
|
||||||
@ -138,7 +191,6 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
|||||||
<div className={styles["sort-direction"]}>
|
<div className={styles["sort-direction"]}>
|
||||||
<button
|
<button
|
||||||
title="Sort ascending"
|
title="Sort ascending"
|
||||||
className={sortAsc ? styles.active : ""}
|
|
||||||
onClick={() => dispatch(setSortAsc(true))}
|
onClick={() => dispatch(setSortAsc(true))}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -153,7 +205,6 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Sort descending"
|
title="Sort descending"
|
||||||
className={!sortAsc ? styles.active : ""}
|
|
||||||
onClick={() => dispatch(setSortAsc(false))}
|
onClick={() => dispatch(setSortAsc(false))}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -178,6 +229,26 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
|||||||
onChange={(event) => dispatch(setFilter(event.target.value))}
|
onChange={(event) => dispatch(setFilter(event.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles["filter-row"]}>
|
||||||
|
<label htmlFor="game">Edition:</label>
|
||||||
|
<select
|
||||||
|
name="game"
|
||||||
|
id="game"
|
||||||
|
className={styles["game"]}
|
||||||
|
value={game}
|
||||||
|
onChange={(event) => dispatch(setGame(event.target.value))}
|
||||||
|
>
|
||||||
|
<option value="All">All</option>
|
||||||
|
{games
|
||||||
|
?.map((game) => game.name)
|
||||||
|
.sort()
|
||||||
|
.map((game) => (
|
||||||
|
<option key={game} value={game}>
|
||||||
|
{editionNames[game]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className={styles["filter-row"]}>
|
<div className={styles["filter-row"]}>
|
||||||
<label htmlFor="category">Category:</label>
|
<label htmlFor="category">Category:</label>
|
||||||
<select
|
<select
|
||||||
@ -220,107 +291,136 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
|
|||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
</div>
|
</div>
|
||||||
|
{renderPagination()}
|
||||||
<ul className={styles["mod-list"]}>
|
<ul className={styles["mod-list"]}>
|
||||||
{modsWithCounts.map((mod) => (
|
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) &&
|
||||||
<li key={mod.id} className={styles["mod-list-item"]}>
|
renderDownloadCountsLoading()}
|
||||||
<div className={styles["mod-title"]}>
|
{(!games || gamesError) && renderGamesError(gamesError)}
|
||||||
<strong>
|
{counts.skyrim.error &&
|
||||||
<Link href={`/?mod=${mod.nexus_mod_id}`}>
|
renderDownloadCountsError(counts.skyrim.error)}
|
||||||
<a>{mod.name}</a>
|
{counts.skyrimspecialedition.error &&
|
||||||
</Link>
|
renderDownloadCountsError(counts.skyrimspecialedition.error)}
|
||||||
</strong>
|
{modsWithCounts
|
||||||
</div>
|
.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
|
||||||
<div>
|
.map((mod) => (
|
||||||
<a
|
<li key={mod.id} className={styles["mod-list-item"]}>
|
||||||
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
|
<div className={styles["mod-title"]}>
|
||||||
target="_blank"
|
<strong>
|
||||||
rel="noreferrer noopener"
|
<Link
|
||||||
>
|
href={`/?game=${getGameNameById(mod.game_id)}&mod=${
|
||||||
View on Nexus Mods
|
mod.nexus_mod_id
|
||||||
</a>
|
}`}
|
||||||
</div>
|
>
|
||||||
<div>
|
<a>{mod.name}</a>
|
||||||
<strong>Category: </strong>
|
</Link>
|
||||||
<a
|
</strong>
|
||||||
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
>
|
|
||||||
{mod.category_name}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Author: </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)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
<ul className={styles["file-list"]}>
|
<a
|
||||||
{files &&
|
href={`${NEXUS_MODS_URL}/${getGameNameById(
|
||||||
files
|
mod.game_id
|
||||||
.filter((file) => file.mod_id === mod.id)
|
)}/mods/${mod.nexus_mod_id}`}
|
||||||
.sort((a, b) => b.nexus_file_id - a.nexus_file_id)
|
target="_blank"
|
||||||
.map((file) => (
|
rel="noreferrer noopener"
|
||||||
<li key={file.id}>
|
>
|
||||||
<div>
|
View on Nexus Mods
|
||||||
<strong>File:</strong> {file.name}
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{file.mod_version && (
|
<div>
|
||||||
|
<strong>Edition: </strong>
|
||||||
|
{
|
||||||
|
editionNames[
|
||||||
|
getGameNameById(mod.game_id) ?? "skyrimspecialedition"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Category: </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: </strong>
|
||||||
|
<a
|
||||||
|
href={`${NEXUS_MODS_URL}/${getGameNameById(
|
||||||
|
mod.game_id
|
||||||
|
)}/users/${mod.author_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{mod.author_name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Uploaded:</strong>{" "}
|
||||||
|
{format(new Date(mod.first_upload_at), "d MMM y")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Last Update:</strong>{" "}
|
||||||
|
{format(new Date(mod.last_update_at), "d MMM y")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Total Downloads:</strong>{" "}
|
||||||
|
{numberFmt.format(mod.total_downloads)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Unique Downloads:</strong>{" "}
|
||||||
|
{numberFmt.format(mod.unique_downloads)}
|
||||||
|
</div>
|
||||||
|
{cellCounts && (
|
||||||
|
<div>
|
||||||
|
<strong>Exterior Cells Edited:</strong>{" "}
|
||||||
|
{numberFmt.format(mod.exterior_cells_edited)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className={styles["file-list"]}>
|
||||||
|
{files &&
|
||||||
|
files
|
||||||
|
.filter((file) => file.mod_id === mod.id)
|
||||||
|
.sort((a, b) => b.nexus_file_id - a.nexus_file_id)
|
||||||
|
.map((file) => (
|
||||||
|
<li key={file.id}>
|
||||||
<div>
|
<div>
|
||||||
<strong>Version:</strong> {file.mod_version}
|
<strong>File:</strong> {file.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{file.mod_version && (
|
||||||
{file.version && file.mod_version !== file.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>
|
<div>
|
||||||
<strong>File Version:</strong> {file.version}
|
<strong>Size:</strong> {formatBytes(file.size)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{file.uploaded_at && (
|
||||||
{file.category && (
|
<div>
|
||||||
<div>
|
<strong>Uploaded:</strong>{" "}
|
||||||
<strong>Category:</strong> {file.category}
|
{format(new Date(file.uploaded_at), "d MMM y")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
</li>
|
||||||
<strong>Size:</strong> {formatBytes(file.size)}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
{file.uploaded_at && (
|
</li>
|
||||||
<div>
|
))}
|
||||||
<strong>Uploaded:</strong>{" "}
|
|
||||||
{format(new Date(file.uploaded_at), "d MMM y")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
{renderPagination()}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -4,21 +4,21 @@ import React from "react";
|
|||||||
import { useAppSelector, useAppDispatch } from "../lib/hooks";
|
import { useAppSelector, useAppDispatch } from "../lib/hooks";
|
||||||
import { excludedPlugins } from "../lib/plugins";
|
import { excludedPlugins } from "../lib/plugins";
|
||||||
import {
|
import {
|
||||||
enableAllPlugins,
|
enableAllParsedPlugins,
|
||||||
disableAllPlugins,
|
disableAllParsedPlugins,
|
||||||
togglePlugin,
|
toggleParsedPlugin,
|
||||||
} from "../slices/plugins";
|
} from "../slices/plugins";
|
||||||
import styles from "../styles/PluginList.module.css";
|
import styles from "../styles/ParsedPluginsList.module.css";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedCell?: { x: number; y: number };
|
selectedCell?: { x: number; y: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
const PluginsList: React.FC<Props> = ({ selectedCell }) => {
|
const ParsedPluginsList: React.FC<Props> = ({ selectedCell }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const plugins = useAppSelector((state) =>
|
const plugins = useAppSelector((state) =>
|
||||||
selectedCell
|
selectedCell
|
||||||
? state.plugins.plugins.filter((plugin) =>
|
? state.plugins.parsedPlugins.filter((plugin) =>
|
||||||
plugin.parsed?.cells.some(
|
plugin.parsed?.cells.some(
|
||||||
(cell) =>
|
(cell) =>
|
||||||
cell.x === selectedCell.x &&
|
cell.x === selectedCell.x &&
|
||||||
@ -28,7 +28,7 @@ const PluginsList: React.FC<Props> = ({ selectedCell }) => {
|
|||||||
plugin.parsed?.header.masters[0] === "Skyrim.esm"
|
plugin.parsed?.header.masters[0] === "Skyrim.esm"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: state.plugins.plugins
|
: state.plugins.parsedPlugins
|
||||||
);
|
);
|
||||||
const pluginsPending = useAppSelector((state) => state.plugins.pending);
|
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>}
|
{plugins.length > 0 && <h2>Loaded Plugins ({plugins.length})</h2>}
|
||||||
{!selectedCell && plugins.length > 0 && (
|
{!selectedCell && plugins.length > 0 && (
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
<button onClick={() => dispatch(enableAllPlugins())}>
|
<button onClick={() => dispatch(enableAllParsedPlugins())}>
|
||||||
Enable all
|
Enable all
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => dispatch(disableAllPlugins())}>
|
<button onClick={() => dispatch(disableAllParsedPlugins())}>
|
||||||
Disable all
|
Disable all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -60,7 +60,7 @@ const PluginsList: React.FC<Props> = ({ selectedCell }) => {
|
|||||||
}
|
}
|
||||||
checked={plugin.enabled ?? false}
|
checked={plugin.enabled ?? false}
|
||||||
value={plugin.enabled ? "on" : "off"}
|
value={plugin.enabled ? "on" : "off"}
|
||||||
onChange={() => dispatch(togglePlugin(plugin.filename))}
|
onChange={() => dispatch(toggleParsedPlugin(plugin.filename))}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={plugin.filename} className={styles["plugin-label"]}>
|
<label htmlFor={plugin.filename} className={styles["plugin-label"]}>
|
||||||
{excludedPlugins.includes(plugin.filename) ? (
|
{excludedPlugins.includes(plugin.filename) ? (
|
||||||
@ -87,4 +87,4 @@ const PluginsList: React.FC<Props> = ({ selectedCell }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PluginsList;
|
export default ParsedPluginsList;
|
@ -16,10 +16,9 @@ export interface Plugin {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
plugin: Plugin;
|
plugin: Plugin;
|
||||||
counts: Record<number, [number, number, number]> | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PluginData: React.FC<Props> = ({ plugin, counts }) => {
|
const PluginData: React.FC<Props> = ({ plugin }) => {
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return <h3>Plugin could not be found.</h3>;
|
return <h3>Plugin could not be found.</h3>;
|
||||||
}
|
}
|
||||||
@ -80,11 +79,13 @@ const PluginData: React.FC<Props> = ({ plugin, counts }) => {
|
|||||||
<strong>Cell edits: </strong>
|
<strong>Cell edits: </strong>
|
||||||
{plugin.cell_count}
|
{plugin.cell_count}
|
||||||
</div>
|
</div>
|
||||||
{plugin.description && (
|
{plugin.description ? (
|
||||||
<div>
|
<div>
|
||||||
<h3>Description:</h3>
|
<h3>Description:</h3>
|
||||||
<p>{plugin.description}</p>
|
<p>{plugin.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.spacer} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import useSWRImmutable from "swr/immutable";
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
import { useAppDispatch, useAppSelector } from "../lib/hooks";
|
||||||
import {
|
import {
|
||||||
setFetchedPlugin,
|
setSelectedFetchedPlugin,
|
||||||
PluginFile,
|
PluginFile,
|
||||||
PluginsByHashWithMods,
|
PluginsByHashWithMods,
|
||||||
|
updateFetchedPlugin,
|
||||||
|
removeFetchedPlugin,
|
||||||
} from "../slices/plugins";
|
} from "../slices/plugins";
|
||||||
import ModList from "./ModList";
|
import ModList from "./ModList";
|
||||||
import CellList from "./CellList";
|
import CellList from "./CellList";
|
||||||
@ -13,6 +15,7 @@ import type { CellCoord } from "./ModData";
|
|||||||
import PluginData, { Plugin as PluginProps } from "./PluginData";
|
import PluginData, { Plugin as PluginProps } from "./PluginData";
|
||||||
import styles from "../styles/PluginDetail.module.css";
|
import styles from "../styles/PluginDetail.module.css";
|
||||||
import { jsonFetcher } from "../lib/api";
|
import { jsonFetcher } from "../lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
const buildPluginProps = (
|
const buildPluginProps = (
|
||||||
data?: PluginsByHashWithMods | null,
|
data?: PluginsByHashWithMods | null,
|
||||||
@ -20,10 +23,7 @@ const buildPluginProps = (
|
|||||||
): PluginProps => {
|
): PluginProps => {
|
||||||
const dataPlugin = data && data.plugins.length > 0 && data.plugins[0];
|
const dataPlugin = data && data.plugins.length > 0 && data.plugins[0];
|
||||||
return {
|
return {
|
||||||
hash:
|
hash: (plugin && plugin.hash) || (dataPlugin && dataPlugin.hash) || "",
|
||||||
(plugin && plugin.hash) ||
|
|
||||||
(dataPlugin && dataPlugin.hash.toString(36)) ||
|
|
||||||
"",
|
|
||||||
size: plugin?.size || (dataPlugin && dataPlugin.size) || 0,
|
size: plugin?.size || (dataPlugin && dataPlugin.size) || 0,
|
||||||
author:
|
author:
|
||||||
plugin?.parsed?.header.author ||
|
plugin?.parsed?.header.author ||
|
||||||
@ -45,53 +45,84 @@ const buildPluginProps = (
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
hash: string;
|
hash: string;
|
||||||
counts: Record<number, [number, number, number]> | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PluginDetail: React.FC<Props> = ({ hash, counts }) => {
|
const PluginDetail: React.FC<Props> = ({ hash }) => {
|
||||||
|
const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
const { data, error } = useSWRImmutable(
|
const { data, error } = useSWRImmutable(
|
||||||
`https://plugins.modmapper.com/${hash}.json`,
|
`https://plugins.modmapper.com/${hash}.json`,
|
||||||
(_) => jsonFetcher<PluginsByHashWithMods>(_)
|
(_) => jsonFetcher<PluginsByHashWithMods>(_)
|
||||||
);
|
);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const plugins = useAppSelector((state) => state.plugins.plugins);
|
const parsedPlugin = useAppSelector((state) =>
|
||||||
const fetchedPlugin = useAppSelector((state) => state.plugins.fetchedPlugin);
|
state.plugins.parsedPlugins.find((plugin) => plugin.hash === hash)
|
||||||
const plugin = plugins.find((plugin) => plugin.hash === hash);
|
);
|
||||||
|
const fetchedPlugin = useAppSelector((state) =>
|
||||||
|
state.plugins.fetchedPlugins.find((plugin) => plugin.hash === hash)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
dispatch(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>;
|
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>;
|
return <div>{`Error loading plugin data: ${error.message}`}</div>;
|
||||||
}
|
}
|
||||||
if (!plugin && data === undefined)
|
if (!parsedPlugin && data === undefined)
|
||||||
return <div className={styles.status}>Loading...</div>;
|
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 <div className={styles.status}>Plugin could not be found.</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PluginData plugin={buildPluginProps(data, plugin)} counts={counts} />
|
<PluginData plugin={buildPluginProps(data, parsedPlugin)} />
|
||||||
{data && <ModList mods={data.mods} files={data.files} counts={counts} />}
|
{data && (
|
||||||
{plugin?.parseError && (
|
<>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={() => {
|
||||||
|
if (fetchedPlugin) {
|
||||||
|
dispatch(removeFetchedPlugin(data.hash));
|
||||||
|
} else {
|
||||||
|
dispatch(updateFetchedPlugin({ ...data, enabled: true }));
|
||||||
|
}
|
||||||
|
setShowAddRemovePluginNotification(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Boolean(fetchedPlugin) ? "Remove plugin" : "Add plugin"}
|
||||||
|
</button>
|
||||||
|
{showAddRemovePluginNotification && (
|
||||||
|
<span>
|
||||||
|
Plugin {Boolean(fetchedPlugin) ? "added" : "removed"}.{" "}
|
||||||
|
<Link href="/#added-plugins">
|
||||||
|
<a>View list</a>
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data && <ModList mods={data.mods} files={data.files} />}
|
||||||
|
{parsedPlugin?.parseError && (
|
||||||
<div className={styles.error}>
|
<div className={styles.error}>
|
||||||
{`Error parsing plugin: ${plugin.parseError}`}
|
{`Error parsing plugin: ${parsedPlugin.parseError}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CellList
|
<CellList
|
||||||
cells={
|
cells={
|
||||||
(plugin?.parsed?.cells.filter(
|
(parsedPlugin?.parsed?.cells.filter(
|
||||||
(cell) =>
|
(cell) =>
|
||||||
cell.x !== undefined &&
|
cell.x !== undefined &&
|
||||||
cell.y !== undefined &&
|
cell.y !== undefined &&
|
||||||
cell.world_form_id === 60 &&
|
cell.world_form_id === 60 &&
|
||||||
plugin.parsed?.header.masters[0] === "Skyrim.esm"
|
parsedPlugin.parsed?.header.masters[0] === "Skyrim.esm"
|
||||||
) as CellCoord[]) ||
|
) as CellCoord[]) ||
|
||||||
data?.cells ||
|
data?.cells ||
|
||||||
[]
|
[]
|
||||||
|
@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { useAppSelector, useAppDispatch } from "../lib/hooks";
|
import { useAppSelector, useAppDispatch } from "../lib/hooks";
|
||||||
import { setPluginsTxtAndApplyLoadOrder } from "../slices/pluginsTxt";
|
import { setPluginsTxtAndApplyLoadOrder } from "../slices/pluginsTxt";
|
||||||
import styles from "../styles/PluginTxtEditor.module.css";
|
import styles from "../styles/PluginTxtEditor.module.css";
|
||||||
|
import EscapeListener from "./EscapeListener";
|
||||||
|
|
||||||
export const excludedPlugins = [
|
export const excludedPlugins = [
|
||||||
"Skyrim.esm",
|
"Skyrim.esm",
|
||||||
@ -32,8 +33,9 @@ const PluginTxtEditor: React.FC<Props> = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EscapeListener onEscape={() => setPluginsTxtShown(false)} />
|
||||||
<p className={styles["top-spacing"]}>
|
<p className={styles["top-spacing"]}>
|
||||||
Paste or drag-and-drop your{" "}
|
<strong className={styles.step}>2. </strong>Paste or drag-and-drop your{" "}
|
||||||
<strong>
|
<strong>
|
||||||
<code>plugins.txt</code>
|
<code>plugins.txt</code>
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
@ -46,6 +48,16 @@ const PluginTxtEditor: React.FC<Props> = () => {
|
|||||||
C:\Users\username\AppData\Local\Skyrim Special Edition
|
C:\Users\username\AppData\Local\Skyrim Special Edition
|
||||||
</code>
|
</code>
|
||||||
</strong>
|
</strong>
|
||||||
|
.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
For Mod Organizer users, it is at{" "}
|
||||||
|
<strong>
|
||||||
|
<code className={styles["break-word"]}>
|
||||||
|
C:\Users\username\AppData\Local\ModOrganizer\Skyrim Special
|
||||||
|
Edition\profiles\profilename\plugins.txt
|
||||||
|
</code>
|
||||||
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
<button onClick={onPluginsTxtButtonClick} className={styles.button}>
|
<button onClick={onPluginsTxtButtonClick} className={styles.button}>
|
||||||
{!pluginsTxt ? "Paste" : "Edit"} Skyrim plugins.txt file
|
{!pluginsTxt ? "Paste" : "Edit"} Skyrim plugins.txt file
|
||||||
@ -70,6 +82,15 @@ const PluginTxtEditor: React.FC<Props> = () => {
|
|||||||
).
|
).
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
For Mod Organizer users, it is at{" "}
|
||||||
|
<strong>
|
||||||
|
<code className={styles["break-word"]}>
|
||||||
|
C:\Users\username\AppData\Local\ModOrganizer\Skyrim Special
|
||||||
|
Edition\profiles\profilename\plugins.txt
|
||||||
|
</code>
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
You can also drag-and-drop the file anywhere on the window to load
|
You can also drag-and-drop the file anywhere on the window to load
|
||||||
the file.
|
the file.
|
||||||
</p>
|
</p>
|
||||||
|
@ -1,73 +1,61 @@
|
|||||||
import { useCombobox } from "downshift";
|
import { useCombobox } from "downshift";
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useContext, useState, useRef } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { SearchResult } from "minisearch";
|
||||||
import MiniSearch, { SearchResult } from "minisearch";
|
|
||||||
import useSWRImmutable from "swr/immutable";
|
|
||||||
|
|
||||||
|
import { SearchContext } from "./SearchProvider";
|
||||||
import styles from "../styles/SearchBar.module.css";
|
import styles from "../styles/SearchBar.module.css";
|
||||||
import { jsonFetcher } from "../lib/api";
|
import { DownloadCountsContext } from "./DownloadCountsProvider";
|
||||||
|
import type { GameName } from "../lib/games";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
counts: Record<number, [number, number, number]> | null;
|
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
onSelectResult: (item: SearchResult | null) => void;
|
||||||
|
includeCells?: boolean;
|
||||||
|
fixed?: boolean;
|
||||||
|
inputRef?: React.MutableRefObject<HTMLInputElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Mod {
|
function gamePrefex(game: GameName): string {
|
||||||
name: string;
|
switch (game) {
|
||||||
id: number;
|
case "skyrim":
|
||||||
}
|
return "[LE]";
|
||||||
|
case "skyrimspecialedition":
|
||||||
let cells = [];
|
return "[SE]";
|
||||||
|
default:
|
||||||
for (let x = -77; x < 76; x++) {
|
return "";
|
||||||
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?/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 searchInput = useRef<HTMLInputElement | null>(null);
|
||||||
const [searchFocused, setSearchFocused] = useState<boolean>(false);
|
const [searchFocused, setSearchFocused] = useState<boolean>(false);
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
|
||||||
|
const renderSearchIndexError = (error: Error) => (
|
||||||
|
<div className={styles.error}>
|
||||||
|
Error loading mod search index: {loadError.message}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const renderDownloadCountsLoading = () => (
|
||||||
|
<div>Loading live download counts...</div>
|
||||||
|
);
|
||||||
|
const renderDownloadCountsError = (error: Error) => (
|
||||||
|
<div
|
||||||
|
className={styles.error}
|
||||||
|
>{`Error loading live download counts: ${error.message}`}</div>
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
getMenuProps,
|
getMenuProps,
|
||||||
@ -82,12 +70,19 @@ const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
|
|||||||
onInputValueChange: ({ inputValue }) => {
|
onInputValueChange: ({ inputValue }) => {
|
||||||
if (inputValue) {
|
if (inputValue) {
|
||||||
let results: SearchResult[] = [];
|
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(
|
results = results.concat(
|
||||||
modSearch.current.search(inputValue).sort((resultA, resultB) => {
|
modSearch.search(inputValue).sort((resultA, resultB) => {
|
||||||
if (counts) {
|
const countsA = counts[resultA.game as GameName].counts;
|
||||||
const countA = counts[resultA.id];
|
const countsB = counts[resultB.game as GameName].counts;
|
||||||
const countB = counts[resultB.id];
|
if (countsA && countsB) {
|
||||||
|
const countA = countsA[resultA.id];
|
||||||
|
const countB = countsB[resultB.id];
|
||||||
const scoreA = resultA.score;
|
const scoreA = resultA.score;
|
||||||
const scoreB = resultB.score;
|
const scoreB = resultB.score;
|
||||||
if (countA && countB && scoreA && scoreB) {
|
if (countA && countB && scoreA && scoreB) {
|
||||||
@ -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));
|
setResults(results.splice(0, 30));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelectedItemChange: ({ selectedItem }) => {
|
onSelectedItemChange: ({ selectedItem }) => {
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
setSearchFocused(false);
|
setSearchFocused(false);
|
||||||
if (selectedItem.x && selectedItem.y) {
|
onSelectResult(selectedItem);
|
||||||
router.push({
|
|
||||||
query: { cell: `${selectedItem.x},${selectedItem.y}` },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
router.push({ query: { mod: selectedItem.id } });
|
|
||||||
}
|
|
||||||
if (searchInput.current) searchInput.current.blur();
|
if (searchInput.current) searchInput.current.blur();
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
@ -127,19 +118,25 @@ const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
|
|||||||
<div
|
<div
|
||||||
className={`${styles["search-bar"]} ${
|
className={`${styles["search-bar"]} ${
|
||||||
searchFocused ? styles["search-bar-focused"] : ""
|
searchFocused ? styles["search-bar-focused"] : ""
|
||||||
} ${sidebarOpen ? styles["search-bar-sidebar-open"] : ""}`}
|
} ${fixed ? styles["search-bar-fixed"] : ""} ${
|
||||||
|
sidebarOpen ? styles["search-bar-sidebar-open"] : ""
|
||||||
|
}`}
|
||||||
{...getComboboxProps()}
|
{...getComboboxProps()}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
{...getInputProps({
|
{...getInputProps({
|
||||||
type: "text",
|
type: "text",
|
||||||
placeholder: "Search mods or cells…",
|
placeholder:
|
||||||
|
modSearch && !loading ? placeholder : "Search (loading...)",
|
||||||
onFocus: () => setSearchFocused(true),
|
onFocus: () => setSearchFocused(true),
|
||||||
onBlur: () => {
|
onBlur: () => {
|
||||||
if (!isOpen) setSearchFocused(false);
|
if (!isOpen) setSearchFocused(false);
|
||||||
},
|
},
|
||||||
disabled: !data,
|
disabled: !modSearch,
|
||||||
ref: searchInput,
|
ref: (ref) => {
|
||||||
|
searchInput.current = ref;
|
||||||
|
if (inputRef) inputRef.current = ref;
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<ul
|
<ul
|
||||||
@ -156,9 +153,16 @@ const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
|
|||||||
highlightedIndex === index ? styles["highlighted-result"] : ""
|
highlightedIndex === index ? styles["highlighted-result"] : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{result.name}
|
{gamePrefex(result.game)} {result.name}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{loadError && renderSearchIndexError(loadError)}
|
||||||
|
{counts.skyrim.error &&
|
||||||
|
renderDownloadCountsError(counts.skyrim.error)}
|
||||||
|
{counts.skyrimspecialedition.error &&
|
||||||
|
renderDownloadCountsError(counts.skyrimspecialedition.error)}
|
||||||
|
{(!counts.skyrim.counts || !counts.skyrimspecialedition.counts) &&
|
||||||
|
renderDownloadCountsLoading()}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
131
components/SearchProvider.tsx
Normal file
131
components/SearchProvider.tsx
Normal 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;
|
@ -1,73 +1,44 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { formatRelative } from "date-fns";
|
import { formatRelative } from "date-fns";
|
||||||
|
|
||||||
import arrow from "../public/img/arrow.svg";
|
import AddModDialog from "./AddModDialog";
|
||||||
import close from "../public/img/close.svg";
|
|
||||||
import CellData from "./CellData";
|
import CellData from "./CellData";
|
||||||
import ModData from "./ModData";
|
import ModData from "./ModData";
|
||||||
import PluginDetail from "./PluginDetail";
|
import PluginDetail from "./PluginDetail";
|
||||||
import DataDirPicker from "./DataDirPicker";
|
import DataDirPicker from "./DataDirPicker";
|
||||||
import PluginTxtEditor from "./PluginTxtEditor";
|
import PluginTxtEditor from "./PluginTxtEditor";
|
||||||
import PluginsList from "./PluginsList";
|
import ParsedPluginsList from "./ParsedPluginsList";
|
||||||
|
import FetchedPluginsList from "./FetchedPluginsList";
|
||||||
import styles from "../styles/Sidebar.module.css";
|
import styles from "../styles/Sidebar.module.css";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedCell: { x: number; y: number } | null;
|
selectedCell: { x: number; y: number } | null;
|
||||||
clearSelectedCell: () => void;
|
clearSelectedCell: () => void;
|
||||||
setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
|
setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
|
||||||
counts: Record<number, [number, number, number]> | null;
|
|
||||||
countsError: Error | null;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
lastModified: string | null | undefined;
|
lastModified: string | null | undefined;
|
||||||
|
onSelectFile: (fileId: number) => void;
|
||||||
|
onSelectPlugin: (hash: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Sidebar: React.FC<Props> = ({
|
const Sidebar: React.FC<Props> = ({
|
||||||
selectedCell,
|
selectedCell,
|
||||||
clearSelectedCell,
|
clearSelectedCell,
|
||||||
setSelectedCells,
|
setSelectedCells,
|
||||||
counts,
|
|
||||||
countsError,
|
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
lastModified,
|
lastModified,
|
||||||
|
onSelectFile,
|
||||||
|
onSelectPlugin,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const renderLoadError = (error: Error) => (
|
useEffect(() => {
|
||||||
<div>{`Error loading live download counts: ${error.message}`}</div>
|
document.getElementById("sidebar")?.scrollTo(0, 0);
|
||||||
);
|
}, [selectedCell, router.query.mod, router.query.plugin]);
|
||||||
|
|
||||||
const renderLoading = () => <div>Loading...</div>;
|
|
||||||
|
|
||||||
const renderCellData = (selectedCell: { x: number; y: number }) => {
|
|
||||||
if (countsError) return renderLoadError(countsError);
|
|
||||||
if (!counts) return renderLoading();
|
|
||||||
|
|
||||||
return <CellData selectedCell={selectedCell} counts={counts} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderModData = (selectedMod: number) => {
|
|
||||||
if (countsError) return renderLoadError(countsError);
|
|
||||||
if (!counts) return renderLoading();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModData
|
|
||||||
selectedMod={selectedMod}
|
|
||||||
counts={counts}
|
|
||||||
setSelectedCells={setSelectedCells}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPluginData = (plugin: string) => {
|
|
||||||
if (countsError) return renderLoadError(countsError);
|
|
||||||
if (!counts) return renderLoading();
|
|
||||||
|
|
||||||
return <PluginDetail hash={plugin} counts={counts} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLastModified = (lastModified: string | null | undefined) => {
|
const renderLastModified = (lastModified: string | null | undefined) => {
|
||||||
if (lastModified) {
|
if (lastModified) {
|
||||||
@ -86,6 +57,7 @@ const Sidebar: React.FC<Props> = ({
|
|||||||
<div
|
<div
|
||||||
className={styles.sidebar}
|
className={styles.sidebar}
|
||||||
style={!open ? { display: "none" } : {}}
|
style={!open ? { display: "none" } : {}}
|
||||||
|
id="sidebar"
|
||||||
>
|
>
|
||||||
<div className={styles["sidebar-content"]}>
|
<div className={styles["sidebar-content"]}>
|
||||||
<div className={styles["sidebar-header"]}>
|
<div className={styles["sidebar-header"]}>
|
||||||
@ -96,17 +68,28 @@ const Sidebar: React.FC<Props> = ({
|
|||||||
<h1 className={styles["cell-name-header"]}>
|
<h1 className={styles["cell-name-header"]}>
|
||||||
Cell {selectedCell.x}, {selectedCell.y}
|
Cell {selectedCell.x}, {selectedCell.y}
|
||||||
</h1>
|
</h1>
|
||||||
{renderCellData(selectedCell)}
|
<CellData selectedCell={selectedCell} />
|
||||||
{renderLastModified(lastModified)}
|
{renderLastModified(lastModified)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (router.query.mod) {
|
} else if (router.query.mod) {
|
||||||
|
if (!router.query.game) {
|
||||||
|
router.replace(`/?game=skyrimspecialedition&mod=${router.query.mod}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const game =
|
||||||
|
typeof router.query.game === "string"
|
||||||
|
? router.query.game
|
||||||
|
: router.query.game[0];
|
||||||
const modId = parseInt(router.query.mod as string, 10);
|
const modId = parseInt(router.query.mod as string, 10);
|
||||||
|
const fileId = parseInt(router.query.file as string, 10);
|
||||||
|
const pluginHash = router.query.plugin as string;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.sidebar}
|
className={styles.sidebar}
|
||||||
style={!open ? { display: "none" } : {}}
|
style={!open ? { display: "none" } : {}}
|
||||||
|
id="sidebar"
|
||||||
>
|
>
|
||||||
<div className={styles["sidebar-content"]}>
|
<div className={styles["sidebar-content"]}>
|
||||||
<div className={styles["sidebar-header"]}>
|
<div className={styles["sidebar-header"]}>
|
||||||
@ -114,7 +97,17 @@ const Sidebar: React.FC<Props> = ({
|
|||||||
<img src="/img/close.svg" width={24} height={24} alt="close" />
|
<img src="/img/close.svg" width={24} height={24} alt="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!Number.isNaN(modId) && renderModData(modId)}
|
{!Number.isNaN(modId) && (
|
||||||
|
<ModData
|
||||||
|
game={game}
|
||||||
|
selectedMod={modId}
|
||||||
|
selectedFile={fileId}
|
||||||
|
selectedPlugin={pluginHash}
|
||||||
|
setSelectedCells={setSelectedCells}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onSelectPlugin={onSelectPlugin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{renderLastModified(lastModified)}
|
{renderLastModified(lastModified)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -124,6 +117,7 @@ const Sidebar: React.FC<Props> = ({
|
|||||||
<div
|
<div
|
||||||
className={styles.sidebar}
|
className={styles.sidebar}
|
||||||
style={!open ? { display: "none" } : {}}
|
style={!open ? { display: "none" } : {}}
|
||||||
|
id="sidebar"
|
||||||
>
|
>
|
||||||
<div className={styles["sidebar-content"]}>
|
<div className={styles["sidebar-content"]}>
|
||||||
<div className={styles["sidebar-header"]}>
|
<div className={styles["sidebar-header"]}>
|
||||||
@ -131,11 +125,13 @@ const Sidebar: React.FC<Props> = ({
|
|||||||
<img src="/img/close.svg" width={24} height={24} alt="close" />
|
<img src="/img/close.svg" width={24} height={24} alt="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{renderPluginData(
|
<PluginDetail
|
||||||
typeof router.query.plugin === "string"
|
hash={
|
||||||
? router.query.plugin
|
typeof router.query.plugin === "string"
|
||||||
: router.query.plugin[0]
|
? router.query.plugin
|
||||||
)}
|
: router.query.plugin[0]
|
||||||
|
}
|
||||||
|
/>
|
||||||
{renderLastModified(lastModified)}
|
{renderLastModified(lastModified)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -145,6 +141,7 @@ const Sidebar: React.FC<Props> = ({
|
|||||||
<div
|
<div
|
||||||
className={styles.sidebar}
|
className={styles.sidebar}
|
||||||
style={!open ? { display: "none" } : {}}
|
style={!open ? { display: "none" } : {}}
|
||||||
|
id="sidebar"
|
||||||
>
|
>
|
||||||
<div className={styles["sidebar-content"]}>
|
<div className={styles["sidebar-content"]}>
|
||||||
<h1 className={styles.title}>Modmapper</h1>
|
<h1 className={styles.title}>Modmapper</h1>
|
||||||
@ -153,7 +150,9 @@ const Sidebar: React.FC<Props> = ({
|
|||||||
</p>
|
</p>
|
||||||
<DataDirPicker />
|
<DataDirPicker />
|
||||||
<PluginTxtEditor />
|
<PluginTxtEditor />
|
||||||
<PluginsList />
|
<ParsedPluginsList />
|
||||||
|
<FetchedPluginsList />
|
||||||
|
<AddModDialog />
|
||||||
{renderLastModified(lastModified)}
|
{renderLastModified(lastModified)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addPluginInOrder,
|
addParsedPluginInOrder,
|
||||||
decrementPending,
|
decrementPending,
|
||||||
PluginFile,
|
PluginFile,
|
||||||
} from "../slices/plugins";
|
} from "../slices/plugins";
|
||||||
@ -31,12 +31,6 @@ export class WorkerPool {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addWorker() {
|
|
||||||
const worker = await this.createWorker();
|
|
||||||
this.availableWorkers.push(worker);
|
|
||||||
this.assignWorker();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createWorker(): Promise<Worker> {
|
public async createWorker(): Promise<Worker> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const worker = new Worker(new URL("../workers/PluginsLoader.worker.ts", import.meta.url));
|
const worker = new Worker(new URL("../workers/PluginsLoader.worker.ts", import.meta.url));
|
||||||
@ -48,13 +42,10 @@ export class WorkerPool {
|
|||||||
resolve(worker);
|
resolve(worker);
|
||||||
} else if (typeof data !== "string") {
|
} else if (typeof data !== "string") {
|
||||||
store.dispatch(decrementPending(1));
|
store.dispatch(decrementPending(1));
|
||||||
store.dispatch(addPluginInOrder(data));
|
store.dispatch(addParsedPluginInOrder(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
|
this.availableWorkers.push(worker);
|
||||||
// new worker is worth it. In practice, there are usually more workers than tasks, so the delay does not slow
|
this.assignWorker()
|
||||||
// down processing.
|
|
||||||
worker.terminate();
|
|
||||||
this.addWorker();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
12
lib/api.ts
12
lib/api.ts
@ -1,8 +1,12 @@
|
|||||||
export async function jsonFetcher<T>(url: string): Promise<T | null> {
|
interface Options {
|
||||||
|
notFoundOk: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jsonFetcher<T>(url: string, options: Options = { notFoundOk: true}): Promise<T | null> {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 404) {
|
if (res.status === 404 && options.notFoundOk) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const error = new Error("An error occurred while fetching the data.");
|
const error = new Error("An error occurred while fetching the data.");
|
||||||
@ -12,11 +16,11 @@ export async function jsonFetcher<T>(url: string): Promise<T | null> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function jsonFetcherWithLastModified<T>(url: string): Promise<{ data: T, lastModified: string | null } | null> {
|
export async function jsonFetcherWithLastModified<T>(url: string, options: Options = { notFoundOk: true}): Promise<{ data: T, lastModified: string | null } | null> {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 404) {
|
if (res.status === 404 && options.notFoundOk) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const error = new Error("An error occurred while fetching the data.");
|
const error = new Error("An error occurred while fetching the data.");
|
||||||
|
8
lib/games.ts
Normal file
8
lib/games.ts
Normal 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
8
lib/logrocketSetup.ts
Normal 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);
|
@ -1,6 +1,6 @@
|
|||||||
import { WorkerPool } from "./WorkerPool";
|
import { WorkerPool } from "./WorkerPool";
|
||||||
import store from "./store";
|
import store from "./store";
|
||||||
import { clearPlugins, setPending } from "../slices/plugins";
|
import { clearParsedPlugins, setPending } from "../slices/plugins";
|
||||||
|
|
||||||
export const excludedPlugins = [
|
export const excludedPlugins = [
|
||||||
"Skyrim.esm",
|
"Skyrim.esm",
|
||||||
@ -12,7 +12,7 @@ export const excludedPlugins = [
|
|||||||
|
|
||||||
export const isPluginPath = (path: string) => {
|
export const isPluginPath = (path: string) => {
|
||||||
if (
|
if (
|
||||||
/^((Skyrim Special Edition|Skyrim|SkyrimVR)\/)?(Data\/)?[^/\\]*\.es[mpl]$/i.test(path)
|
/^.*\.es[mpl]$/i.test(path)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -28,7 +28,7 @@ export const parsePluginFiles = (pluginFiles: File[], workerPool: WorkerPool) =>
|
|||||||
alert("Found no plugins in the folder. Please select the Data folder underneath the Skyrim installation folder.");
|
alert("Found no plugins in the folder. Please select the Data folder underneath the Skyrim installation folder.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
store.dispatch(clearPlugins());
|
store.dispatch(clearParsedPlugins());
|
||||||
store.dispatch(setPending(pluginFiles.length));
|
store.dispatch(setPending(pluginFiles.length));
|
||||||
|
|
||||||
pluginFiles.forEach(async (plugin) => {
|
pluginFiles.forEach(async (plugin) => {
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
|
import LogRocket from "logrocket"
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"
|
import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
import pluginsReducer from "../slices/plugins"
|
import pluginsReducer from "../slices/plugins"
|
||||||
import pluginsTxtReducer from "../slices/pluginsTxt"
|
import pluginsTxtReducer from "../slices/pluginsTxt"
|
||||||
import modListFiltersReducer from "../slices/modListFilters"
|
import modListFiltersReducer from "../slices/modListFilters"
|
||||||
|
|
||||||
|
const sentryReduxEnhancer = Sentry.createReduxEnhancer();
|
||||||
|
|
||||||
export function makeStore() {
|
export function makeStore() {
|
||||||
return configureStore({
|
return configureStore({
|
||||||
reducer: { pluginsTxt: pluginsTxtReducer, plugins: pluginsReducer, modListFilters: modListFiltersReducer },
|
reducer: { pluginsTxt: pluginsTxtReducer, plugins: pluginsReducer, modListFilters: modListFiltersReducer },
|
||||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }),
|
middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }).concat(LogRocket.reduxMiddleware()),
|
||||||
|
enhancers: [sentryReduxEnhancer],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
/** @type {import('next-sitemap').IConfig} */
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
||||||
|
|
||||||
const MOD_SEARCH_INDEX_URL = 'https://mods.modmapper.com/mod_search_index.json';
|
const SSE_MOD_SEARCH_INDEX_URL = 'https://mods.modmapper.com/skyrimspecialedition/mod_search_index.json';
|
||||||
|
const LE_MOD_SEARCH_INDEX_URL = 'https://mods.modmapper.com/skyrim/mod_search_index.json';
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
siteUrl: process.env.SITE_URL || 'https://modmapper.com',
|
siteUrl: process.env.SITE_URL || 'https://modmapper.com',
|
||||||
@ -9,12 +10,21 @@ module.exports = {
|
|||||||
additionalPaths: async (config) => {
|
additionalPaths: async (config) => {
|
||||||
const result = []
|
const result = []
|
||||||
|
|
||||||
const response = await fetch(MOD_SEARCH_INDEX_URL);
|
const skyrimResponse = await fetch(LE_MOD_SEARCH_INDEX_URL);
|
||||||
const index = await response.json();
|
const skyrimIndex = await skyrimResponse.json();
|
||||||
|
|
||||||
for (const mod of index) {
|
const skyrimspecialeditionResponse = await fetch(SSE_MOD_SEARCH_INDEX_URL);
|
||||||
|
const skyrimspecialeditionIndex = await skyrimspecialeditionResponse.json();
|
||||||
|
|
||||||
|
for (const mod of skyrimIndex) {
|
||||||
result.push({
|
result.push({
|
||||||
loc: '/?mod=' + mod.id,
|
loc: `/?game=skyrim&mod=${mod.id}`,
|
||||||
|
changefreq: 'daily',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const mod of skyrimspecialeditionIndex) {
|
||||||
|
result.push({
|
||||||
|
loc: `/?game=skyrimspecialedition&mod=${mod.id}`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
const { withSentryConfig } = require('@sentry/nextjs');
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
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
8294
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -9,22 +9,31 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/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",
|
"date-fns": "^2.28.0",
|
||||||
"downshift": "^6.1.7",
|
"downshift": "^6.1.7",
|
||||||
"javascript-color-gradient": "^1.3.2",
|
"javascript-color-gradient": "^1.3.2",
|
||||||
"mapbox-gl": "^2.6.1",
|
"js-cookie": "^3.0.1",
|
||||||
"minisearch": "^3.2.0",
|
"logrocket": "^3.0.1",
|
||||||
"next": "12.1.1-canary.15",
|
"logrocket-react": "^5.0.1",
|
||||||
|
"mapbox-gl": "^2.10.0",
|
||||||
|
"minisearch": "^5.0.0",
|
||||||
|
"next": "12.2.5",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
|
"react-paginate": "^8.1.3",
|
||||||
"react-redux": "^7.2.6",
|
"react-redux": "^7.2.6",
|
||||||
"skyrim-cell-dump-wasm": "0.1.0",
|
"skyrim-cell-dump-wasm": "0.1.4",
|
||||||
"swr": "^1.1.2"
|
"swr": "^1.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/js-cookie": "^3.0.2",
|
||||||
|
"@types/logrocket-react": "^3.0.0",
|
||||||
"@types/node": "17.0.8",
|
"@types/node": "17.0.8",
|
||||||
"@types/react": "17.0.38",
|
"@types/react": "17.0.38",
|
||||||
"@types/react-dom": "^17.0.11",
|
"@types/react-dom": "^17.0.11",
|
||||||
|
3
pages/404.js
Normal file
3
pages/404.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function Custom404() {
|
||||||
|
return <h1>404 - Page Not Found</h1>
|
||||||
|
}
|
@ -1,10 +1,19 @@
|
|||||||
|
import "../lib/logrocketSetup";
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
|
|
||||||
|
import LogRocket from "logrocket";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
|
|
||||||
import store from "../lib/store";
|
import store from "../lib/store";
|
||||||
|
|
||||||
|
LogRocket.getSessionURL((sessionURL) => {
|
||||||
|
Sentry.configureScope((scope) => {
|
||||||
|
scope.setExtra("sessionURL", sessionURL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }: AppProps) {
|
function MyApp({ Component, pageProps }: AppProps) {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
39
pages/_error.js
Normal file
39
pages/_error.js
Normal 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;
|
47
pages/sentry_sample_error.js
Normal file
47
pages/sentry_sample_error.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
BIN
public/img/full-screenshot.jpg
Normal file
BIN
public/img/full-screenshot.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 MiB |
37
sentry.client.config.js
Normal file
37
sentry.client.config.js
Normal 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
4
sentry.properties
Normal 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
19
sentry.server.config.js
Normal 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
|
||||||
|
});
|
@ -1,6 +1,5 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
import type { AppState, AppThunk } from "../lib/store"
|
|
||||||
import type { Mod } from '../components/CellData';
|
import type { Mod } from '../components/CellData';
|
||||||
|
|
||||||
export type ModWithCounts = Mod & {
|
export type ModWithCounts = Mod & {
|
||||||
@ -14,6 +13,7 @@ export type ModListFiltersState = {
|
|||||||
sortBy: keyof ModWithCounts,
|
sortBy: keyof ModWithCounts,
|
||||||
sortAsc: boolean,
|
sortAsc: boolean,
|
||||||
filter?: string,
|
filter?: string,
|
||||||
|
game: string,
|
||||||
category: string,
|
category: string,
|
||||||
includeTranslations: boolean,
|
includeTranslations: boolean,
|
||||||
}
|
}
|
||||||
@ -22,6 +22,7 @@ const initialState: ModListFiltersState = {
|
|||||||
sortBy: "unique_downloads",
|
sortBy: "unique_downloads",
|
||||||
sortAsc: false,
|
sortAsc: false,
|
||||||
filter: undefined,
|
filter: undefined,
|
||||||
|
game: "All",
|
||||||
category: "All",
|
category: "All",
|
||||||
includeTranslations: true,
|
includeTranslations: true,
|
||||||
};
|
};
|
||||||
@ -42,6 +43,10 @@ export const modListFiltersSlice = createSlice({
|
|||||||
...state,
|
...state,
|
||||||
filter: action.payload,
|
filter: action.payload,
|
||||||
}),
|
}),
|
||||||
|
setGame: (state, action: PayloadAction<string>) => ({
|
||||||
|
...state,
|
||||||
|
game: action.payload,
|
||||||
|
}),
|
||||||
setCategory: (state, action: PayloadAction<string>) => ({
|
setCategory: (state, action: PayloadAction<string>) => ({
|
||||||
...state,
|
...state,
|
||||||
category: action.payload,
|
category: action.payload,
|
||||||
@ -54,6 +59,6 @@ export const modListFiltersSlice = createSlice({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setSortBy, setSortAsc, setFilter, setCategory, setIncludeTranslations, clearFilters } = modListFiltersSlice.actions
|
export const { setSortBy, setSortAsc, setFilter, setGame, setCategory, setIncludeTranslations, clearFilters } = modListFiltersSlice.actions
|
||||||
|
|
||||||
export default modListFiltersSlice.reducer
|
export default modListFiltersSlice.reducer
|
@ -27,14 +27,14 @@ export interface World {
|
|||||||
form_id: number;
|
form_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Plugin {
|
export interface ParsedPlugin {
|
||||||
header: Header;
|
header: Header;
|
||||||
cells: Cell[];
|
cells: Cell[];
|
||||||
worlds: World[];
|
worlds: World[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginFile {
|
export interface PluginFile {
|
||||||
parsed?: Plugin;
|
parsed?: ParsedPlugin;
|
||||||
filename: string;
|
filename: string;
|
||||||
lastModified: number;
|
lastModified: number;
|
||||||
hash: string;
|
hash: string;
|
||||||
@ -65,7 +65,7 @@ export interface File {
|
|||||||
export interface FetchedPlugin {
|
export interface FetchedPlugin {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
hash: bigint;
|
hash: string;
|
||||||
file_id: number;
|
file_id: number;
|
||||||
mod_id: number;
|
mod_id: number;
|
||||||
version: number;
|
version: number;
|
||||||
@ -79,86 +79,130 @@ export interface FetchedPlugin {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FetchedCell {
|
||||||
|
x: 0;
|
||||||
|
y: 0;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginsByHashWithMods {
|
export interface PluginsByHashWithMods {
|
||||||
hash: number;
|
hash: string;
|
||||||
plugins: FetchedPlugin[];
|
plugins: FetchedPlugin[];
|
||||||
files: File[];
|
files: File[];
|
||||||
mods: Mod[];
|
mods: Mod[];
|
||||||
cells: Cell[];
|
cells: FetchedCell[];
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginsState = {
|
export type PluginsState = {
|
||||||
plugins: PluginFile[];
|
parsedPlugins: PluginFile[];
|
||||||
fetchedPlugin?: PluginsByHashWithMods;
|
fetchedPlugins: PluginsByHashWithMods[];
|
||||||
|
selectedFetchedPlugin?: PluginsByHashWithMods;
|
||||||
pending: number;
|
pending: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: PluginsState = { plugins: [], pending: 0 };
|
const initialState: PluginsState = { parsedPlugins: [], fetchedPlugins: [], pending: 0 };
|
||||||
|
|
||||||
export const pluginsSlice = createSlice({
|
export const pluginsSlice = createSlice({
|
||||||
name: "plugins",
|
name: "plugins",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
addPlugin: (state, action: PayloadAction<PluginFile>) => ({
|
addParsedPlugin: (state: PluginsState, action: PayloadAction<PluginFile>) => ({
|
||||||
plugins: [...state.plugins, action.payload],
|
...state,
|
||||||
pending: state.pending,
|
parsedPlugins: [...state.parsedPlugins, action.payload],
|
||||||
fetchedPlugin: state.fetchedPlugin,
|
|
||||||
}),
|
}),
|
||||||
updatePlugin: (state, action: PayloadAction<PluginFile>) => ({
|
addFetchedPlugin: (state: PluginsState, action: PayloadAction<PluginsByHashWithMods>) => ({
|
||||||
plugins: [...state.plugins.filter(plugin => plugin.hash !== action.payload.hash), action.payload],
|
...state,
|
||||||
pending: state.pending,
|
fetchedPlugins: [...state.fetchedPlugins, action.payload],
|
||||||
fetchedPlugin: state.fetchedPlugin,
|
|
||||||
}),
|
}),
|
||||||
setPlugins: (state, action: PayloadAction<PluginFile[]>) => ({
|
updateParsedPlugin: (state: PluginsState, action: PayloadAction<PluginFile>) => ({
|
||||||
plugins: action.payload,
|
...state,
|
||||||
pending: state.pending,
|
parsedPlugins: [...state.parsedPlugins.filter(plugin => plugin.hash !== action.payload.hash), action.payload],
|
||||||
fetchedPlugin: state.fetchedPlugin,
|
|
||||||
}),
|
}),
|
||||||
setPending: (state, action: PayloadAction<number>) => ({
|
updateFetchedPlugin: (state: PluginsState, action: PayloadAction<PluginsByHashWithMods>) => ({
|
||||||
plugins: state.plugins,
|
...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,
|
pending: action.payload,
|
||||||
fetchedPlugin: state.fetchedPlugin,
|
|
||||||
}),
|
}),
|
||||||
decrementPending: (state, action: PayloadAction<number>) => ({
|
decrementPending: (state: PluginsState, action: PayloadAction<number>) => ({
|
||||||
plugins: state.plugins,
|
...state,
|
||||||
pending: state.pending - action.payload,
|
pending: state.pending - action.payload,
|
||||||
fetchedPlugin: state.fetchedPlugin,
|
|
||||||
}),
|
}),
|
||||||
togglePlugin: (state, action: PayloadAction<string>) => ({
|
toggleParsedPlugin: (state: PluginsState, action: PayloadAction<string>) => ({
|
||||||
plugins: state.plugins.map((plugin) => (plugin.filename === action.payload ? { ...plugin, enabled: !plugin.enabled } : plugin)),
|
...state,
|
||||||
pending: state.pending,
|
parsedPlugins: state.parsedPlugins.map((plugin) => (plugin.filename === action.payload ? { ...plugin, enabled: !plugin.enabled } : plugin)),
|
||||||
fetchedPlugin: state.fetchedPlugin,
|
|
||||||
}),
|
}),
|
||||||
enableAllPlugins: (state) => ({
|
toggleFetchedPlugin: (state: PluginsState, action: PayloadAction<string>) => ({
|
||||||
plugins: state.plugins.map((plugin) => ({ ...plugin, enabled: !plugin.parseError && !excludedPlugins.includes(plugin.filename) && true })),
|
...state,
|
||||||
pending: state.pending,
|
fetchedPlugins: state.fetchedPlugins.map((plugin) => (plugin.hash === action.payload ? { ...plugin, enabled: !plugin.enabled } : plugin)),
|
||||||
fetchedPlugin: state.fetchedPlugin,
|
|
||||||
}),
|
}),
|
||||||
disableAllPlugins: (state) => ({
|
enableAllParsedPlugins: (state: PluginsState) => ({
|
||||||
plugins: state.plugins.map((plugin) => ({ ...plugin, enabled: false })),
|
...state,
|
||||||
pending: state.pending,
|
parsedPlugins: state.parsedPlugins.map((plugin) => ({ ...plugin, enabled: !plugin.parseError && !excludedPlugins.includes(plugin.filename) && true })),
|
||||||
fetchedPlugin: state.fetchedPlugin,
|
|
||||||
}),
|
}),
|
||||||
setFetchedPlugin: (state, action: PayloadAction<PluginsByHashWithMods | undefined>) => ({
|
enableAllFetchedPlugins: (state: PluginsState) => ({
|
||||||
plugins: state.plugins,
|
...state,
|
||||||
pending: state.pending,
|
fetchedPlugins: state.fetchedPlugins.map((plugin) => ({ ...plugin, enabled: true })),
|
||||||
fetchedPlugin: action.payload,
|
|
||||||
}),
|
}),
|
||||||
clearPlugins: () => ({
|
disableAllParsedPlugins: (state: PluginsState) => ({
|
||||||
plugins: [],
|
...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,
|
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 selectPlugins = (state: AppState) => state.plugins
|
||||||
|
|
||||||
export const applyLoadOrder = (): AppThunk => (dispatch, getState) => {
|
export const applyLoadOrder = (): AppThunk => (dispatch, getState) => {
|
||||||
const { plugins, pluginsTxt } = getState();
|
const { plugins, pluginsTxt } = getState();
|
||||||
const originalPlugins = [...plugins.plugins];
|
const originalPlugins = [...plugins.parsedPlugins];
|
||||||
let newPlugins = [];
|
let newPlugins = [];
|
||||||
for (let line of pluginsTxt.split("\n")) {
|
for (let line of pluginsTxt.split("\n")) {
|
||||||
let enabled = false;
|
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) => {
|
export const addParsedPluginInOrder = (plugin: PluginFile): AppThunk => (dispatch) => {
|
||||||
dispatch(addPlugin(plugin));
|
dispatch(updateParsedPlugin(plugin));
|
||||||
dispatch(applyLoadOrder());
|
dispatch(applyLoadOrder());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
29
styles/AddModData.module.css
Normal file
29
styles/AddModData.module.css
Normal 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;
|
||||||
|
}
|
29
styles/AddModDialog.module.css
Normal file
29
styles/AddModDialog.module.css
Normal 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;
|
||||||
|
}
|
@ -48,3 +48,27 @@
|
|||||||
.filter {
|
.filter {
|
||||||
width: 175px;
|
width: 175px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination li {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination li a {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-page a {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
@ -1,3 +1,35 @@
|
|||||||
.no-top-margin {
|
.no-top-margin {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-word {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
top: 12px;
|
||||||
|
z-index: 8;
|
||||||
|
background-color: #fbefd5;
|
||||||
|
box-shadow: 0 0 1em black;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog label {
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog menu {
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog menu button {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
60
styles/FetchedPluginsList.module.css
Normal file
60
styles/FetchedPluginsList.module.css
Normal 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;
|
||||||
|
}
|
@ -6,3 +6,47 @@ a.name {
|
|||||||
line-height: 1.75rem;
|
line-height: 1.75rem;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select-container {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding-right: 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-link {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
flex: 1;
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
@ -70,6 +70,11 @@
|
|||||||
width: 175px;
|
width: 175px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game {
|
||||||
|
min-width: 175px;
|
||||||
|
width: 175px;
|
||||||
|
}
|
||||||
|
|
||||||
.filter {
|
.filter {
|
||||||
width: 175px;
|
width: 175px;
|
||||||
}
|
}
|
||||||
@ -107,3 +112,27 @@
|
|||||||
.desc {
|
.desc {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination li {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination li a {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-page a {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
@ -2,3 +2,7 @@ h1.name {
|
|||||||
line-height: 1.75rem;
|
line-height: 1.75rem;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
@ -2,3 +2,10 @@
|
|||||||
color: red;
|
color: red;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
@ -30,6 +30,18 @@
|
|||||||
margin-top: 34px;
|
margin-top: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-top-margin {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-bottom-margin {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.break-word {
|
.break-word {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
.search-bar {
|
.search-bar-fixed {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
width: 150px;
|
width: 150px;
|
||||||
@ -6,29 +6,33 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar.search-bar-focused {
|
.search-bar-fixed.search-bar-focused {
|
||||||
width: max(40vw, 250px);
|
width: max(40vw, 250px);
|
||||||
left: calc(50% - max(20vw, 125px));
|
left: calc(50% - max(20vw, 125px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (min-width: 600px) {
|
@media only screen and (min-width: 600px) {
|
||||||
.search-bar.search-bar-sidebar-open {
|
.search-bar-fixed.search-bar-sidebar-open {
|
||||||
left: calc(50% + 75px);
|
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);
|
left: calc(50% - max(20vw, 125px) + 125px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar input {
|
.search-bar input {
|
||||||
width: 150px;
|
width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
padding-right: 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);
|
width: max(40vw, 250px);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
@ -58,3 +62,7 @@
|
|||||||
.highlighted-result {
|
.highlighted-result {
|
||||||
background-color: #bde4ff;
|
background-color: #bde4ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover {
|
||||||
color: #888888;
|
filter: invert(40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hide {
|
.hide {
|
||||||
|
Loading…
Reference in New Issue
Block a user