Merge pull request #5 from thallada/add-mod-to-list

Add mod to list, select files & plugins of mod, and perf improvements
This commit is contained in:
Tyler Hallada 2022-08-30 00:18:28 -04:00 committed by GitHub
commit d103451a40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 9494 additions and 634 deletions

4
.babelrc Normal file
View File

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

3
.gitignore vendored
View File

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

155
components/AddModData.tsx Normal file
View File

@ -0,0 +1,155 @@
import { format } from "date-fns";
import React, { useCallback, 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";
type Props = {
selectedMod: number;
selectedPlugin: string | null;
setSelectedPlugin: (plugin: string) => void;
counts: Record<number, [number, number, number]> | null;
};
const AddModData: React.FC<Props> = ({
selectedMod,
selectedPlugin,
setSelectedPlugin,
counts,
}) => {
const [selectedFile, setSelectedFile] = useState<number | null>(null);
const { data: modData, error: modError } = useSWRImmutable(
selectedMod ? `https://mods.modmapper.com/${selectedMod}.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 modCounts = counts && counts[modData.nexus_mod_id];
const total_downloads = modCounts ? modCounts[0] : 0;
const unique_downloads = modCounts ? modCounts[1] : 0;
const views = modCounts ? modCounts[2] : 0;
if (selectedMod && modData) {
return (
<div className={styles.wrapper}>
<h3>
<a
href={`${NEXUS_MODS_URL}/mods/${modData.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
className={styles.name}
>
{modData.name}
</a>
</h3>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${modData.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{modData.category_name}
</a>
{modData.is_translation && <strong>&nbsp;(translation)</strong>}
</div>
<div>
<strong>Author:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/users/${modData.author_id}`}
target="_blank"
rel="noreferrer noopener"
>
{modData.author_name}
</a>
</div>
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(modData.first_upload_at), "d MMM y")}
</div>
<div>
<strong>Last Update:</strong>{" "}
{format(new Date(modData.last_update_at), "d MMM y")}
</div>
<div>
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
</div>
<div>
<strong>Unique Downloads:</strong>{" "}
{numberFmt.format(unique_downloads)}
</div>
<div className={styles["select-container"]}>
<label htmlFor="mod-file-select" className={styles.label}>
Select file:
</label>
<select
name="file"
id="mod-file-select"
className={styles.select}
onChange={handleFileChange}
>
<option value="">--Select file--</option>
{[...modData.files].reverse().map((file) => (
<option key={file.nexus_file_id} value={file.nexus_file_id}>
{file.name} (v{file.version}) ({file.category})
</option>
))}
</select>
</div>
{fileData && (
<div className={styles["select-container"]}>
<label htmlFor="file-plugin-select" className={styles.label}>
Select plugin:
</label>
<select
name="plugin"
id="file-plugin-select"
className={styles.select}
onChange={handlePluginChange}
>
<option value="">--Select plugin--</option>
{fileData.plugins.map((plugin) => (
<option key={plugin.hash} value={plugin.hash}>
{plugin.file_path}
</option>
))}
</select>
</div>
)}
</div>
);
}
return null;
};
export default AddModData;

View File

@ -0,0 +1,94 @@
import { createPortal } from "react-dom";
import React, { useCallback, useEffect, 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";
type Props = {
counts: Record<number, [number, number, number]> | null;
};
const AddModDialog: React.FC<Props> = ({ counts }) => {
const [selectedMod, setSelectedMod] = useState<number | 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
counts={counts}
sidebarOpen={false}
placeholder="Search mods…"
onSelectResult={(selectedItem) => {
if (selectedItem) {
setSelectedMod(selectedItem.id);
}
}}
inputRef={searchInput}
/>
{selectedMod && (
<AddModData
selectedMod={selectedMod}
selectedPlugin={selectedPlugin}
setSelectedPlugin={setSelectedPlugin}
counts={counts}
/>
)}
<menu>
<button
onClick={() => {
setSelectedMod(null);
setDialogShown(false);
if (searchInput.current) searchInput.current.value = "";
}}
>
Cancel
</button>
<button
onClick={() => {
if (data)
dispatch(updateFetchedPlugin({ ...data, enabled: true }));
setDialogShown(false);
}}
disabled={!selectedMod || !selectedPlugin || !data}
>
Add
</button>
</menu>
</dialog>,
document.body
)}
</>
);
};
export default AddModDialog;

View File

@ -4,8 +4,9 @@ import useSWRImmutable from "swr/immutable";
import styles from "../styles/CellData.module.css"; import 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;
@ -110,7 +111,8 @@ 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} />
<FetchedPluginsList selectedCell={selectedCell} />
<ModList mods={data.mods} counts={counts} /> <ModList mods={data.mods} counts={counts} />
</> </>
) )

View File

@ -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()}
</> </>
) )
); );

View File

@ -12,7 +12,7 @@ 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 [uploadNoticeShown, setUploadNoticeShown] = useState(false);

View File

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

View File

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

View File

@ -5,11 +5,12 @@ import mapboxgl from "mapbox-gl";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { useAppDispatch, useAppSelector } from "../lib/hooks"; import { useAppDispatch, useAppSelector } from "../lib/hooks";
import { 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 SearchProvider from "./SearchProvider";
import { csvFetcher, jsonFetcherWithLastModified } from "../lib/api"; import { csvFetcher, jsonFetcherWithLastModified } from "../lib/api";
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? ""; mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
@ -55,9 +56,14 @@ 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",
@ -243,7 +249,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 +303,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 +331,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 +357,11 @@ const Map: React.FC = () => {
}, },
[] []
); );
cells = cells.concat(
fetchedPlugins
.filter((plugin) => plugin.enabled)
.flatMap((plugin) => plugin.cells)
);
selectCells(cells); selectCells(cells);
} }
}, [ }, [
@ -362,8 +375,9 @@ const Map: React.FC = () => {
clearSelectedCell, clearSelectedCell,
clearSelectedCells, clearSelectedCells,
heatmapLoaded, heatmapLoaded,
plugins, parsedPlugins,
pluginsPending, pluginsPending,
fetchedPlugins,
]); ]);
useEffect(() => { useEffect(() => {
@ -371,12 +385,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 +402,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
@ -828,18 +842,60 @@ const Map: React.FC = () => {
ref={mapWrapper} ref={mapWrapper}
> >
<div ref={mapContainer} className={styles["map-container"]}> <div ref={mapContainer} className={styles["map-container"]}>
<Sidebar <SearchProvider>
selectedCell={selectedCell} <Sidebar
clearSelectedCell={() => router.push({ query: {} })} selectedCell={selectedCell}
setSelectedCells={setSelectedCells} clearSelectedCell={() => router.push({ query: {} })}
counts={counts} setSelectedCells={setSelectedCells}
countsError={countsError} counts={counts}
open={sidebarOpen} countsError={countsError}
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
counts={counts}
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: { mod: selectedItem.id } });
}
}}
includeCells
fixed
/>
</SearchProvider>
</div> </div>
</div> </div>
</> </>

View File

@ -1,17 +1,61 @@
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, 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 {
PluginsByHashWithMods,
removeFetchedPlugin,
updateFetchedPlugin,
} from "../slices/plugins";
import Link from "next/link";
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,120 +74,171 @@ 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/skyrimspecialedition";
type Props = { type Props = {
selectedMod: number; selectedMod: number;
selectedFile: number;
selectedPlugin: string;
counts: Record<number, [number, number, number]> | null; counts: Record<number, [number, number, number]> | null;
setSelectedCells: (cells: { x: number; y: number }[] | null) => void; setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
onSelectFile: (fileId: number) => void;
onSelectPlugin: (hash: string) => void;
}; };
const ModData: React.FC<Props> = ({ const ModData: React.FC<Props> = ({
selectedMod, selectedMod,
selectedFile,
selectedPlugin,
counts, counts,
setSelectedCells, setSelectedCells,
onSelectFile,
onSelectPlugin,
}) => { }) => {
const { data, error } = useSWRImmutable( const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] =
useState<boolean>(false);
const { data: modData, error: modError } = useSWRImmutable(
`https://mods.modmapper.com/${selectedMod}.json`, `https://mods.modmapper.com/${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]);
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 modCounts = counts && counts[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/?mod=${modData.nexus_mod_id}`}
/> />
</Head> </Head>
<h1> <h1>
<a <a
href={`${NEXUS_MODS_URL}/mods/${data.nexus_mod_id}`} href={`${NEXUS_MODS_URL}/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> <div>
<strong>Category:&nbsp;</strong> <strong>Category:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/mods/categories/${data.category_id}`} href={`${NEXUS_MODS_URL}/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>&nbsp;(translation)</strong>} {modData.is_translation && <strong>&nbsp;(translation)</strong>}
</div> </div>
<div> <div>
<strong>Author:&nbsp;</strong> <strong>Author:&nbsp;</strong>
<a <a
href={`${NEXUS_MODS_URL}/users/${data.author_id}`} href={`${NEXUS_MODS_URL}/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>
<div> <div>
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)} <strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
@ -152,7 +247,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}
/>
</> </>
); );
} }

View File

@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
import MiniSearch from "minisearch"; import MiniSearch from "minisearch";
import Link from "next/link"; import Link from "next/link";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import ReactPaginate from "react-paginate";
import styles from "../styles/ModList.module.css"; import styles from "../styles/ModList.module.css";
import type { Mod } from "./CellData"; import type { Mod } from "./CellData";
@ -21,6 +22,7 @@ import {
import { useAppDispatch, useAppSelector } from "../lib/hooks"; import { useAppDispatch, useAppSelector } from "../lib/hooks";
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition"; const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
const PAGE_SIZE = 50;
type Props = { type Props = {
mods: Mod[]; mods: Mod[];
@ -33,6 +35,7 @@ const ModList: React.FC<Props> = ({ mods, files, counts }) => {
const { sortBy, sortAsc, filter, category, includeTranslations } = const { sortBy, sortAsc, filter, category, includeTranslations } =
useAppSelector((state) => state.modListFilters); useAppSelector((state) => state.modListFilters);
const [filterResults, setFilterResults] = useState<Set<number>>(new Set()); const [filterResults, setFilterResults] = useState<Set<number>>(new Set());
const [page, setPage] = useState<number>(0);
const { data: cellCounts, error: cellCountsError } = useSWRImmutable( const { data: cellCounts, error: cellCountsError } = useSWRImmutable(
`https://mods.modmapper.com/mod_cell_counts.json`, `https://mods.modmapper.com/mod_cell_counts.json`,
@ -106,10 +109,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 +165,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 +179,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
@ -220,107 +245,111 @@ 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) => ( {modsWithCounts
<li key={mod.id} className={styles["mod-list-item"]}> .slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
<div className={styles["mod-title"]}> .map((mod) => (
<strong> <li key={mod.id} className={styles["mod-list-item"]}>
<Link href={`/?mod=${mod.nexus_mod_id}`}> <div className={styles["mod-title"]}>
<a>{mod.name}</a> <strong>
</Link> <Link href={`/?mod=${mod.nexus_mod_id}`}>
</strong> <a>{mod.name}</a>
</div> </Link>
<div> </strong>
<a
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
>
View on Nexus Mods
</a>
</div>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.category_name}
</a>
</div>
<div>
<strong>Author:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/users/${mod.author_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.author_name}
</a>
</div>
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(mod.first_upload_at), "d MMM y")}
</div>
<div>
<strong>Last Update:</strong>{" "}
{format(new Date(mod.last_update_at), "d MMM y")}
</div>
<div>
<strong>Total Downloads:</strong>{" "}
{numberFmt.format(mod.total_downloads)}
</div>
<div>
<strong>Unique Downloads:</strong>{" "}
{numberFmt.format(mod.unique_downloads)}
</div>
{cellCounts && (
<div>
<strong>Exterior Cells Edited:</strong>{" "}
{numberFmt.format(mod.exterior_cells_edited)}
</div> </div>
)} <div>
<ul className={styles["file-list"]}> <a
{files && href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
files target="_blank"
.filter((file) => file.mod_id === mod.id) rel="noreferrer noopener"
.sort((a, b) => b.nexus_file_id - a.nexus_file_id) >
.map((file) => ( View on Nexus Mods
<li key={file.id}> </a>
<div> </div>
<strong>File:</strong> {file.name} <div>
</div> <strong>Category:&nbsp;</strong>
{file.mod_version && ( <a
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.category_name}
</a>
</div>
<div>
<strong>Author:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/users/${mod.author_id}`}
target="_blank"
rel="noreferrer noopener"
>
{mod.author_name}
</a>
</div>
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(mod.first_upload_at), "d MMM y")}
</div>
<div>
<strong>Last Update:</strong>{" "}
{format(new Date(mod.last_update_at), "d MMM y")}
</div>
<div>
<strong>Total Downloads:</strong>{" "}
{numberFmt.format(mod.total_downloads)}
</div>
<div>
<strong>Unique Downloads:</strong>{" "}
{numberFmt.format(mod.unique_downloads)}
</div>
{cellCounts && (
<div>
<strong>Exterior Cells Edited:</strong>{" "}
{numberFmt.format(mod.exterior_cells_edited)}
</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()}
</> </>
) )
); );

View File

@ -4,21 +4,21 @@ import React from "react";
import { useAppSelector, useAppDispatch } from "../lib/hooks"; import { useAppSelector, useAppDispatch } from "../lib/hooks";
import { excludedPlugins } from "../lib/plugins"; import { excludedPlugins } from "../lib/plugins";
import { import {
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;

View File

@ -80,11 +80,13 @@ const PluginData: React.FC<Props> = ({ plugin, counts }) => {
<strong>Cell edits:&nbsp;</strong> <strong>Cell edits:&nbsp;</strong>
{plugin.cell_count} {plugin.cell_count}
</div> </div>
{plugin.description && ( {plugin.description ? (
<div> <div>
<h3>Description:</h3> <h3>Description:</h3>
<p>{plugin.description}</p> <p>{plugin.description}</p>
</div> </div>
) : (
<div className={styles.spacer} />
)} )}
</> </>
); );

View File

@ -1,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 ||
@ -49,49 +49,84 @@ type Props = {
}; };
const PluginDetail: React.FC<Props> = ({ hash, counts }) => { const PluginDetail: React.FC<Props> = ({ hash, counts }) => {
const [showAddRemovePluginNotification, setShowAddRemovePluginNotification] =
useState<boolean>(false);
const { data, error } = useSWRImmutable( const { data, error } = useSWRImmutable(
`https://plugins.modmapper.com/${hash}.json`, `https://plugins.modmapper.com/${hash}.json`,
(_) => jsonFetcher<PluginsByHashWithMods>(_) (_) => jsonFetcher<PluginsByHashWithMods>(_)
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const 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)}
counts={counts}
/>
{data && (
<>
<button
className={styles.button}
onClick={() => {
if (fetchedPlugin) {
dispatch(removeFetchedPlugin(data.hash));
} else {
dispatch(updateFetchedPlugin({ ...data, enabled: true }));
}
setShowAddRemovePluginNotification(true);
}}
>
{Boolean(fetchedPlugin) ? "Remove plugin" : "Add plugin"}
</button>
{showAddRemovePluginNotification && (
<span>
Plugin {Boolean(fetchedPlugin) ? "added" : "removed"}.{" "}
<Link href="/#added-plugins">
<a>View list</a>
</Link>
.
</span>
)}
</>
)}
{data && <ModList mods={data.mods} files={data.files} counts={counts} />} {data && <ModList mods={data.mods} files={data.files} counts={counts} />}
{plugin?.parseError && ( {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 ||
[] []

View File

@ -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,6 +33,7 @@ const PluginTxtEditor: React.FC<Props> = () => {
return ( return (
<> <>
<EscapeListener onEscape={() => setPluginsTxtShown(false)} />
<p className={styles["top-spacing"]}> <p className={styles["top-spacing"]}>
<strong className={styles.step}>2. </strong>Paste or drag-and-drop your{" "} <strong className={styles.step}>2. </strong>Paste or drag-and-drop your{" "}
<strong> <strong>

View File

@ -1,15 +1,18 @@
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";
type Props = { type Props = {
counts: Record<number, [number, number, number]> | null; 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 { interface Mod {
@ -17,53 +20,17 @@ interface Mod {
id: number; id: number;
} }
let cells = []; const SearchBar: React.FC<Props> = ({
counts,
for (let x = -77; x < 76; x++) { sidebarOpen,
for (let y = -50; y < 45; y++) { placeholder,
const id = `${x},${y}`; onSelectResult,
cells.push({ id, name: `Cell ${id}`, x, y }); includeCells = false,
} fixed = false,
} inputRef,
const cellSearch = new MiniSearch({ }) => {
fields: ["id"], const { cellSearch, modSearch, loading, loadError } =
storeFields: ["id", "name", "x", "y"], useContext(SearchContext);
tokenize: (s) => [s.replace(/(cell\s?)|\s/gi, "")],
searchOptions: {
fields: ["id"],
prefix: true,
fuzzy: false,
},
});
cellSearch.addAll(cells);
const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
const router = useRouter();
const modSearch = useRef<MiniSearch<Mod> | null>(
null
) as React.MutableRefObject<MiniSearch<Mod>>;
const { data, error } = useSWRImmutable(
`https://mods.modmapper.com/mod_search_index.json`,
(_) => jsonFetcher<Mod[]>(_)
);
useEffect(() => {
if (data && !modSearch.current) {
modSearch.current = new MiniSearch({
fields: ["name"],
storeFields: ["name", "id"],
searchOptions: {
fields: ["name"],
fuzzy: 0.2,
prefix: true,
},
});
modSearch.current.addAll(data);
}
}, [data]);
const searchInput = useRef<HTMLInputElement | null>(null); const searchInput = useRef<HTMLInputElement | null>(null);
const [searchFocused, setSearchFocused] = useState<boolean>(false); const [searchFocused, setSearchFocused] = useState<boolean>(false);
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
@ -83,13 +50,13 @@ const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
if (inputValue) { if (inputValue) {
let results: SearchResult[] = []; let results: SearchResult[] = [];
if ( if (
modSearch.current && modSearch &&
!/(^cell\s?-?\d+\s?,?\s?-?\d*$)|(^-?\d+\s?,\s?-?\d*$)/i.test( !/(^cell\s?-?\d+\s?,?\s?-?\d*$)|(^-?\d+\s?,\s?-?\d*$)/i.test(
inputValue inputValue
) )
) { ) {
results = results.concat( results = results.concat(
modSearch.current.search(inputValue).sort((resultA, resultB) => { modSearch.search(inputValue).sort((resultA, resultB) => {
if (counts) { if (counts) {
const countA = counts[resultA.id]; const countA = counts[resultA.id];
const countB = counts[resultB.id]; const countB = counts[resultB.id];
@ -107,20 +74,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 !== undefined && selectedItem.y !== undefined) { 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();
} }
@ -132,19 +95,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
@ -164,6 +133,11 @@ const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
{result.name} {result.name}
</li> </li>
))} ))}
{loadError && (
<div className={styles.error}>
Error loading mod search index: {loadError}.
</div>
)}
</ul> </ul>
</div> </div>
</> </>

View File

@ -0,0 +1,86 @@
import React, { createContext, useEffect, useRef, useState } from "react";
import MiniSearch from "minisearch";
import useSWRImmutable from "swr/immutable";
import { jsonFetcher } from "../lib/api";
interface Mod {
name: string;
id: number;
}
let cells = [];
for (let x = -77; x < 76; x++) {
for (let y = -50; y < 45; y++) {
const id = `${x},${y}`;
cells.push({ id, name: `Cell ${id}`, x, y });
}
}
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<Mod> | null>(
null
) as React.MutableRefObject<MiniSearch<Mod>>;
const [loading, setLoading] = useState(true);
const { data, error } = useSWRImmutable(
`https://mods.modmapper.com/mod_search_index.json`,
(_) => jsonFetcher<Mod[]>(_)
);
useEffect(() => {
if (data && !modSearch.current) {
modSearch.current = new MiniSearch({
fields: ["name"],
storeFields: ["name", "id"],
searchOptions: {
fields: ["name"],
fuzzy: 0.2,
prefix: true,
},
});
modSearch.current.addAllAsync(data).then(() => {
setLoading(false);
});
}
}, [data]);
return (
<SearchContext.Provider
value={{
modSearch: modSearch.current,
cellSearch,
loading,
loadError: error,
}}
>
{children}
</SearchContext.Provider>
);
};
export default SearchProvider;

View File

@ -1,16 +1,18 @@
/* 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 arrow from "../public/img/arrow.svg";
import close from "../public/img/close.svg"; import close from "../public/img/close.svg";
import AddModDialog from "./AddModDialog";
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 = {
@ -22,6 +24,8 @@ type Props = {
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> = ({
@ -33,9 +37,15 @@ const Sidebar: React.FC<Props> = ({
open, open,
setOpen, setOpen,
lastModified, lastModified,
onSelectFile,
onSelectPlugin,
}) => { }) => {
const router = useRouter(); const router = useRouter();
useEffect(() => {
document.getElementById("sidebar")?.scrollTo(0, 0);
}, [selectedCell, router.query.mod, router.query.plugin]);
const renderLoadError = (error: Error) => ( const renderLoadError = (error: Error) => (
<div>{`Error loading live download counts: ${error.message}`}</div> <div>{`Error loading live download counts: ${error.message}`}</div>
); );
@ -49,15 +59,23 @@ const Sidebar: React.FC<Props> = ({
return <CellData selectedCell={selectedCell} counts={counts} />; return <CellData selectedCell={selectedCell} counts={counts} />;
}; };
const renderModData = (selectedMod: number) => { const renderModData = (
selectedMod: number,
selectedFile: number,
selectedPlugin: string
) => {
if (countsError) return renderLoadError(countsError); if (countsError) return renderLoadError(countsError);
if (!counts) return renderLoading(); if (!counts) return renderLoading();
return ( return (
<ModData <ModData
selectedMod={selectedMod} selectedMod={selectedMod}
selectedFile={selectedFile}
selectedPlugin={selectedPlugin}
counts={counts} counts={counts}
setSelectedCells={setSelectedCells} setSelectedCells={setSelectedCells}
onSelectFile={onSelectFile}
onSelectPlugin={onSelectPlugin}
/> />
); );
}; };
@ -86,6 +104,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"]}>
@ -103,10 +122,13 @@ const Sidebar: React.FC<Props> = ({
); );
} else if (router.query.mod) { } else if (router.query.mod) {
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 +136,7 @@ const Sidebar: React.FC<Props> = ({
<img src="/img/close.svg" width={24} height={24} alt="close" /> <img src="/img/close.svg" width={24} height={24} alt="close" />
</button> </button>
</div> </div>
{!Number.isNaN(modId) && renderModData(modId)} {!Number.isNaN(modId) && renderModData(modId, fileId, pluginHash)}
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>
@ -124,6 +146,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"]}>
@ -145,6 +168,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 +177,9 @@ const Sidebar: React.FC<Props> = ({
</p> </p>
<DataDirPicker /> <DataDirPicker />
<PluginTxtEditor /> <PluginTxtEditor />
<PluginsList /> <ParsedPluginsList />
<FetchedPluginsList />
<AddModDialog counts={counts} />
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</div> </div>
</div> </div>

View File

@ -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";
@ -48,7 +48,7 @@ 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 // 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 // invocations on the same worker instance. Repeated invocations are so slow that the delay in creating a
// new worker is worth it. In practice, there are usually more workers than tasks, so the delay does not slow // new worker is worth it. In practice, there are usually more workers than tasks, so the delay does not slow

8
lib/logrocketSetup.ts Normal file
View File

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

View File

@ -1,6 +1,6 @@
import { WorkerPool } from "./WorkerPool"; import { 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",
@ -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) => {

View File

@ -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],
}) })
} }

View File

@ -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);

8177
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,24 +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.6.0",
"babel-plugin-add-react-displayname": "^0.0.5",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"downshift": "^6.1.7", "downshift": "^6.1.7",
"javascript-color-gradient": "^1.3.2", "javascript-color-gradient": "^1.3.2",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"logrocket": "^3.0.1",
"logrocket-react": "^5.0.1",
"mapbox-gl": "^2.6.1", "mapbox-gl": "^2.6.1",
"minisearch": "^3.2.0", "minisearch": "^5.0.0",
"next": "12.1.1-canary.15", "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.0",
"swr": "^1.1.2" "swr": "^1.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/js-cookie": "^3.0.2", "@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
View File

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

View File

@ -1,10 +1,19 @@
import "../lib/logrocketSetup";
import "../styles/globals.css"; import "../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
View File

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

View File

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

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

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

4
sentry.properties Normal file
View File

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

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

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

View File

@ -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, updatePlugin, 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(updatePlugin(plugin)); dispatch(updateParsedPlugin(plugin));
dispatch(applyLoadOrder()); dispatch(applyLoadOrder());
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -107,3 +107,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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -48,7 +48,7 @@
} }
.close:hover { .close:hover {
color: #888888; filter: invert(40%);
} }
.hide { .hide {
@ -142,7 +142,6 @@
.sidebar-content { .sidebar-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
} }
.sidebar-modified-date { .sidebar-modified-date {