Default sidebar and WIP PluginLoader

This commit is contained in:
Tyler Hallada 2022-02-27 01:17:52 -05:00
parent bf994d896f
commit 761ef80669
23 changed files with 2421 additions and 120 deletions

View File

@ -43,7 +43,7 @@ const CellModList: React.FC<Props> = ({ mods, counts }) => {
<div className={styles["mod-title"]}> <div className={styles["mod-title"]}>
<strong> <strong>
<Link href={`/?mod=${mod.nexus_mod_id}`}> <Link href={`/?mod=${mod.nexus_mod_id}`}>
<a className={styles.link}>{mod.name}</a> <a>{mod.name}</a>
</Link> </Link>
</strong> </strong>
</div> </div>
@ -52,7 +52,6 @@ const CellModList: React.FC<Props> = ({ mods, counts }) => {
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`} href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className={styles.link}
> >
View on Nexus Mods View on Nexus Mods
</a> </a>
@ -63,7 +62,6 @@ const CellModList: React.FC<Props> = ({ mods, counts }) => {
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`} href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className={styles.link}
> >
{mod.category_name} {mod.category_name}
</a> </a>
@ -74,7 +72,6 @@ const CellModList: React.FC<Props> = ({ mods, counts }) => {
href={`${NEXUS_MODS_URL}/users/${mod.author_id}`} href={`${NEXUS_MODS_URL}/users/${mod.author_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className={styles.link}
> >
{mod.author_name} {mod.author_name}
</a> </a>

View File

@ -4,6 +4,8 @@ import Gradient from "javascript-color-gradient";
import mapboxgl from "mapbox-gl"; import mapboxgl from "mapbox-gl";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { useAppSelector } from "../lib/hooks";
import { 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";
@ -24,7 +26,11 @@ colorGradient.setMidpoint(360);
const LIVE_DOWNLOAD_COUNTS_URL = const LIVE_DOWNLOAD_COUNTS_URL =
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv"; "https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
const jsonFetcher = (url: string) => fetch(url).then((res) => res.json()); const jsonFetcher = (url: string) =>
fetch(url).then(async (res) => ({
lastModified: res.headers.get("Last-Modified"),
data: await res.json(),
}));
const csvFetcher = (url: string) => fetch(url).then((res) => res.text()); const csvFetcher = (url: string) => fetch(url).then((res) => res.text());
const Map: React.FC = () => { const Map: React.FC = () => {
@ -52,7 +58,10 @@ const Map: React.FC = () => {
}[] }[]
| null | null
>(null); >(null);
const sidebarOpen = selectedCell !== null || router.query.mod !== undefined; const [sidebarOpen, setSidebarOpen] = useState(true);
const plugins = useAppSelector((state) => state.plugins.plugins);
const pluginsPending = useAppSelector((state) => state.plugins.pending);
const { data: cellsData, error: cellsError } = useSWRImmutable( const { data: cellsData, error: cellsError } = useSWRImmutable(
"https://cells.modmapper.com/edits.json", "https://cells.modmapper.com/edits.json",
@ -77,17 +86,18 @@ const Map: React.FC = () => {
map.current.setFeatureState( map.current.setFeatureState(
{ {
source: "grid-source", source: "grid-source",
id: (cell.x + 57) * 100 + 50 - cell.y, id: (cell.x + 57) * 1000 + 50 - cell.y,
}, },
{ {
selected: true, selected: true,
} }
); );
map.current.removeFeatureState({ source: "selected-cell-source" }); map.current.removeFeatureState({ source: "selected-cell-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
map.current.setFeatureState( map.current.setFeatureState(
{ {
source: "selected-cell-source", source: "selected-cell-source",
id: (cell.x + 57) * 100 + 50 - cell.y, id: (cell.x + 57) * 1000 + 50 - cell.y,
}, },
{ {
cellSelected: true, cellSelected: true,
@ -137,17 +147,30 @@ const Map: React.FC = () => {
if (map.current && !map.current.getSource("grid-source")) return; if (map.current && !map.current.getSource("grid-source")) return;
map.current.removeFeatureState({ source: "selected-cell-source" }); map.current.removeFeatureState({ source: "selected-cell-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
const visited: { [id: number]: boolean } = {};
for (let cell of cells) { for (let cell of cells) {
const id = (cell.x + 57) * 1000 + 50 - cell.y;
map.current.setFeatureState( map.current.setFeatureState(
{ {
source: "selected-cell-source", source: "selected-cell-source",
id: (cell.x + 57) * 100 + 50 - cell.y, id,
}, },
{ {
modSelected: true, modSelected: true,
cellSelected: false, cellSelected: false,
} }
); );
map.current.setFeatureState(
{
source: "conflicted-cell-source",
id,
},
{
conflicted: visited[id] === true ? true : false,
}
);
visited[id] = true;
} }
let bounds: mapboxgl.LngLatBounds | null = null; let bounds: mapboxgl.LngLatBounds | null = null;
@ -203,6 +226,7 @@ const Map: React.FC = () => {
(cell) => { (cell) => {
router.push({ query: { cell: cell.x + "," + cell.y } }); router.push({ query: { cell: cell.x + "," + cell.y } });
setSelectedCell(cell); setSelectedCell(cell);
setSidebarOpen(true);
selectMapCell(cell); selectMapCell(cell);
}, },
[setSelectedCell, selectMapCell, router] [setSelectedCell, selectMapCell, router]
@ -213,6 +237,7 @@ const Map: React.FC = () => {
if (map.current) map.current.removeFeatureState({ source: "grid-source" }); if (map.current) map.current.removeFeatureState({ source: "grid-source" });
if (map.current) { if (map.current) {
map.current.removeFeatureState({ source: "selected-cell-source" }); map.current.removeFeatureState({ source: "selected-cell-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (map.current) map.current.resize(); if (map.current) map.current.resize();
@ -223,6 +248,7 @@ const Map: React.FC = () => {
setSelectedCells(null); setSelectedCells(null);
if (map.current) { if (map.current) {
map.current.removeFeatureState({ source: "selected-cell-source" }); map.current.removeFeatureState({ source: "selected-cell-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (map.current) map.current.resize(); if (map.current) map.current.resize();
@ -235,6 +261,16 @@ const Map: React.FC = () => {
}); });
}, [map]); }, [map]);
const setSidebarOpenWithResize = useCallback(
(open) => {
setSidebarOpen(open);
requestAnimationFrame(() => {
if (map.current) map.current.resize();
});
},
[map]
);
useEffect(() => { useEffect(() => {
if (!heatmapLoaded) return; // wait for all map layers to load if (!heatmapLoaded) return; // wait for all map layers to load
if (router.query.cell && typeof router.query.cell === "string") { if (router.query.cell && typeof router.query.cell === "string") {
@ -257,6 +293,7 @@ const Map: React.FC = () => {
selectedCells selectedCells
) { ) {
clearSelectedCell(); clearSelectedCell();
setSidebarOpen(true);
selectCells(selectedCells); selectCells(selectedCells);
} else { } else {
if (selectedCell) { if (selectedCell) {
@ -285,6 +322,33 @@ const Map: React.FC = () => {
} }
}, [router.query.mod, clearSelectedMod, heatmapLoaded]); }, [router.query.mod, clearSelectedMod, heatmapLoaded]);
useEffect(() => {
if (!heatmapLoaded) return; // wait for all map layers to load
if (plugins && plugins.length > 0 && pluginsPending === 0) {
clearSelectedCells();
const cells = plugins.reduce(
(acc: { x: number; y: number }[], plugin: PluginFile) => {
if (plugin.enabled && plugin.parsed) {
const newCells = [...acc];
for (const cell of plugin.parsed.cells) {
if (
cell.x !== undefined &&
cell.y !== undefined &&
cell.world_form_id === 60
) {
newCells.push({ x: cell.x, y: cell.y });
}
}
return newCells;
}
return acc;
},
[]
);
selectCells(cells);
}
}, [plugins, pluginsPending, heatmapLoaded, clearSelectedCells, selectCells]);
useEffect(() => { useEffect(() => {
if (map.current) return; // initialize map only once if (map.current) return; // initialize map only once
map.current = new mapboxgl.Map({ map.current = new mapboxgl.Map({
@ -452,12 +516,12 @@ const Map: React.FC = () => {
x * cellSize + viewportNW.x, x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y + cellSize, y * cellSize + viewportNW.y + cellSize,
]); ]);
const editCount = (cellsData as Record<string, number>)[ const editCount = (cellsData.data as Record<string, number>)[
`${x - 57},${50 - y}` `${x - 57},${50 - y}`
]; ];
grid.features.push({ grid.features.push({
type: "Feature", type: "Feature",
id: x * 100 + y, id: x * 1000 + y,
geometry: { geometry: {
type: "Polygon", type: "Polygon",
coordinates: [ coordinates: [
@ -546,7 +610,7 @@ const Map: React.FC = () => {
]); ]);
selectedCellLines.features.push({ selectedCellLines.features.push({
type: "Feature", type: "Feature",
id: x * 100 + y, id: x * 1000 + y,
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [ coordinates: [
@ -555,6 +619,7 @@ const Map: React.FC = () => {
[se.lng, se.lat], [se.lng, se.lat],
[sw.lng, sw.lat], [sw.lng, sw.lat],
[nw.lng, nw.lat], [nw.lng, nw.lat],
[ne.lng, ne.lat],
], ],
}, },
properties: { x: x, y: y }, properties: { x: x, y: y },
@ -586,6 +651,75 @@ const Map: React.FC = () => {
3, 3,
], ],
}, },
layout: {
"line-join": "round",
},
});
const conflictedCellLines: GeoJSON.FeatureCollection<
GeoJSON.Geometry,
GeoJSON.GeoJsonProperties
> = {
type: "FeatureCollection",
features: [],
};
for (let x = 0; x < 128; x += 1) {
for (let y = 0; y < 128; y += 1) {
let nw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y,
]);
let ne = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y,
]);
let se = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y + cellSize,
]);
let sw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y + cellSize,
]);
conflictedCellLines.features.push({
type: "Feature",
id: x * 1000 + y,
geometry: {
type: "LineString",
coordinates: [
[nw.lng, nw.lat],
[ne.lng, ne.lat],
[se.lng, se.lat],
[sw.lng, sw.lat],
[nw.lng, nw.lat],
[ne.lng, ne.lat],
],
},
properties: { x: x, y: y },
});
}
}
map.current.addSource("conflicted-cell-source", {
type: "geojson",
data: conflictedCellLines,
});
map.current.addLayer({
id: "conflicted-cell-layer",
type: "line",
source: "conflicted-cell-source",
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "conflicted"], false],
"red",
"transparent",
],
"line-width": 4,
},
layout: {
"line-join": "round",
},
}); });
const fullscreenControl = new mapboxgl.FullscreenControl(); const fullscreenControl = new mapboxgl.FullscreenControl();
@ -632,16 +766,14 @@ const Map: React.FC = () => {
selectedCell={selectedCell} selectedCell={selectedCell}
clearSelectedCell={() => router.push({ query: {} })} clearSelectedCell={() => router.push({ query: {} })}
setSelectedCells={setSelectedCells} setSelectedCells={setSelectedCells}
map={map}
counts={counts} counts={counts}
countsError={countsError} countsError={countsError}
open={sidebarOpen}
setOpen={setSidebarOpenWithResize}
lastModified={cellsData && cellsData.lastModified}
/> />
<ToggleLayersControl map={map} /> <ToggleLayersControl map={map} />
<SearchBar <SearchBar counts={counts} sidebarOpen={sidebarOpen} />
map={map}
clearSelectedCell={() => router.push({ query: {} })}
counts={counts}
/>
</div> </div>
</div> </div>
</> </>

View File

@ -30,7 +30,7 @@ const ModCellList: React.FC<Props> = ({ cells }) => {
`${cell.x},${cell.y}` `${cell.x},${cell.y}`
)}`} )}`}
> >
<a className={styles.link}> <a>
{cell.x}, {cell.y} {cell.x}, {cell.y}
</a> </a>
</Link> </Link>

View File

@ -120,7 +120,7 @@ const ModData: React.FC<Props> = ({
href={`${NEXUS_MODS_URL}/mods/${data.nexus_mod_id}`} href={`${NEXUS_MODS_URL}/mods/${data.nexus_mod_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className={`${styles.link} ${styles.name}`} className={styles.name}
> >
{data.name} {data.name}
</a> </a>
@ -131,7 +131,6 @@ const ModData: React.FC<Props> = ({
href={`${NEXUS_MODS_URL}/mods/categories/${data.category_id}`} href={`${NEXUS_MODS_URL}/mods/categories/${data.category_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className={styles.link}
> >
{data.category_name} {data.category_name}
</a> </a>
@ -142,7 +141,6 @@ const ModData: React.FC<Props> = ({
href={`${NEXUS_MODS_URL}/users/${data.author_id}`} href={`${NEXUS_MODS_URL}/users/${data.author_id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className={styles.link}
> >
{data.author_name} {data.author_name}
</a> </a>

View File

@ -0,0 +1,218 @@
import Link from "next/link";
import { createPortal } from "react-dom";
import React, { useEffect, useRef, useState } from "react";
import { useAppSelector, useAppDispatch } from "../lib/hooks";
import { setPluginsTxt } from "../slices/pluginsTxt";
import {
addPluginInOrder,
applyLoadOrder,
clearPlugins,
setPending,
decrementPending,
togglePlugin,
PluginFile,
} from "../slices/plugins";
import styles from "../styles/PluginLoader.module.css";
const excludedPlugins = [
"Skyrim.esm",
"Update.esm",
"Dawnguard.esm",
"HearthFires.esm",
"Dragonborn.esm",
];
type Props = {};
const PluginsLoader: React.FC<Props> = () => {
const workerRef = useRef<Worker>();
const [editPluginsTxt, setEditPluginsTxt] = useState<string | null>(null);
const [pluginsTxtShown, setPluginsTxtShown] = useState(false);
const dispatch = useAppDispatch();
const plugins = useAppSelector((state) => state.plugins.plugins);
const pluginsPending = useAppSelector((state) => state.plugins.pending);
const pluginsTxt = useAppSelector((state) => state.pluginsTxt);
useEffect(() => {
setPluginsTxtShown(false);
console.log("going to apply!");
dispatch(applyLoadOrder());
}, [dispatch, pluginsTxt]);
useEffect(() => {
async function loadWorker() {
const { default: Worker } = await import(
"worker-loader?filename=static/[fullhash].worker.js!../workers/PluginsLoader.worker"
);
console.log(Worker);
workerRef.current = new Worker();
workerRef.current.onmessage = (evt: { data: PluginFile }) => {
const { data } = evt;
console.log(`WebWorker Response =>`);
dispatch(decrementPending(1));
console.log(data.parsed);
dispatch(addPluginInOrder(data));
};
}
loadWorker();
return () => {
if (workerRef.current) {
workerRef.current.terminate();
}
};
}, [dispatch]);
const onDataDirButtonClick = async () => {
if (!workerRef.current) {
return alert("Worker not loaded yet");
}
const dirHandle = await (
window as Window & typeof globalThis & { showDirectoryPicker: () => any }
).showDirectoryPicker();
dispatch(clearPlugins());
const values = dirHandle.values();
const plugins = [];
while (true) {
const next = await values.next();
if (next.done) {
break;
}
if (
next.value.kind == "file" &&
(next.value.name.endsWith(".esp") ||
next.value.name.endsWith(".esm") ||
next.value.name.endsWith(".esl"))
) {
console.log(next.value);
plugins.push(next.value);
}
}
dispatch(setPending(plugins.length));
for (const plugin of plugins) {
const file = await plugin.getFile();
console.log(file.lastModified);
console.log(file.lastModifiedDate);
const contents = new Uint8Array(await file.arrayBuffer());
try {
workerRef.current.postMessage(
{
skipParsing: excludedPlugins.includes(plugin.name),
filename: plugin.name,
lastModified: file.lastModified,
contents,
},
[contents.buffer]
);
} catch (error) {
console.error(error);
}
}
};
const onPluginsTxtButtonClick = async () => {
setEditPluginsTxt(pluginsTxt);
setPluginsTxtShown(true);
};
return (
<>
<p className={styles["no-top-margin"]}>
To see all of the cell edits and conflicts for your current mod load
order select your <code>Data</code> directory below to load the plugins.
</p>
<button onClick={onDataDirButtonClick}>
{plugins.length === 0 ? "Open" : "Reload"} Skyrim Data directory
</button>
<p>
Paste or drag-and-drop your <code>plugins.txt</code> below to sort and
enable the loaded plugins by your current load order.
</p>
<button
onClick={onPluginsTxtButtonClick}
className={styles["plugins-txt-button"]}
>
{!pluginsTxt ? "Paste" : "Edit"} Skyrim plugins.txt file
</button>
<ol className={styles["plugin-list"]}>
{plugins.map((plugin) => (
<li key={plugin.filename} title={plugin.filename}>
<input
id={plugin.filename}
type="checkbox"
disabled={
excludedPlugins.includes(plugin.filename) || !!plugin.parseError
}
checked={plugin.enabled}
onChange={() => dispatch(togglePlugin(plugin.filename))}
/>
<label htmlFor={plugin.filename} className={styles["plugin-label"]}>
{excludedPlugins.includes(plugin.filename) ? (
<span>{plugin.filename}</span>
) : (
<Link href={`/?plugin=${plugin.hash}`}>
<a
className={plugin.parseError ? styles["plugin-error"] : ""}
>
{plugin.filename}
</a>
</Link>
)}
</label>
{/* <p>{plugin.parsed && plugin.parsed.header.description}</p> */}
</li>
))}
</ol>
{pluginsPending > 0 && (
<span className={styles.processing}>
Loading {pluginsPending} plugin{pluginsPending === 1 ? "" : "s"}
</span>
)}
{process.browser &&
createPortal(
<dialog
open={pluginsTxtShown}
className={styles["plugins-txt-dialog"]}
>
<h3>Paste plugins.txt</h3>
<p>
The plugins.txt file is typically found at{" "}
<code>
C:\Users\username\AppData\Local\Skyrim Special Edition
</code>
. You can also drag-and-drop the file anywhere on the window to
load the file.
</p>
<textarea
value={editPluginsTxt ?? undefined}
onChange={(e) => setEditPluginsTxt(e.target.value)}
/>
<menu>
<button
onClick={() => {
setEditPluginsTxt(null);
setPluginsTxtShown(false);
}}
>
Cancel
</button>
<button
onClick={() => {
dispatch(setPluginsTxt(editPluginsTxt ?? ""));
setPluginsTxtShown(false);
}}
>
Save
</button>
</menu>
</dialog>,
document.body
)}
{process.browser &&
createPortal(<div className={styles["drop-area"]} />, document.body)}
</>
);
};
export default PluginsLoader;

View File

@ -1,16 +1,14 @@
import { useCombobox } from "downshift"; import { useCombobox } from "downshift";
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import type mapboxgl from "mapbox-gl";
import MiniSearch, { SearchResult } from "minisearch"; import MiniSearch, { SearchResult } from "minisearch";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import styles from "../styles/SearchBar.module.css"; import styles from "../styles/SearchBar.module.css";
type Props = { type Props = {
clearSelectedCell: () => void;
map: React.MutableRefObject<mapboxgl.Map>;
counts: Record<number, [number, number, number]> | null; counts: Record<number, [number, number, number]> | null;
sidebarOpen: boolean;
}; };
interface Mod { interface Mod {
@ -51,7 +49,7 @@ const cellSearch = new MiniSearch({
}); });
cellSearch.addAll(cells); cellSearch.addAll(cells);
const SearchBar: React.FC<Props> = ({ clearSelectedCell, counts, map }) => { const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
const router = useRouter(); const router = useRouter();
const modSearch = useRef<MiniSearch<Mod> | null>( const modSearch = useRef<MiniSearch<Mod> | null>(
@ -141,7 +139,7 @@ const SearchBar: React.FC<Props> = ({ clearSelectedCell, counts, map }) => {
<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"] : ""}`}
{...getComboboxProps()} {...getComboboxProps()}
> >
<input <input

View File

@ -1,25 +1,21 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWRImmutable from "swr/immutable"; import { formatRelative } from "date-fns";
import CellData from "./CellData"; import CellData from "./CellData";
import ModData from "./ModData"; import ModData from "./ModData";
import PluginsLoader from "./PluginsLoader";
import styles from "../styles/Sidebar.module.css"; import styles from "../styles/Sidebar.module.css";
import { render } from "react-dom";
interface Cell {
x: number;
y: number;
form_id: number;
}
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;
map: React.MutableRefObject<mapboxgl.Map | null>;
counts: Record<number, [number, number, number]> | null; counts: Record<number, [number, number, number]> | null;
countsError: Error | null; countsError: Error | null;
open: boolean;
setOpen: (open: boolean) => void;
lastModified: string | null | undefined;
}; };
const Sidebar: React.FC<Props> = ({ const Sidebar: React.FC<Props> = ({
@ -28,7 +24,9 @@ const Sidebar: React.FC<Props> = ({
setSelectedCells, setSelectedCells,
counts, counts,
countsError, countsError,
map, open,
setOpen,
lastModified,
}) => { }) => {
const router = useRouter(); const router = useRouter();
@ -58,13 +56,13 @@ const Sidebar: React.FC<Props> = ({
); );
}; };
const onClose = () => { const renderOpenSidebar = () => {
clearSelectedCell();
};
if (selectedCell) { if (selectedCell) {
return ( return (
<div className={styles.sidebar}> <div
className={styles.sidebar}
style={!open ? { display: "none" } : {}}
>
<div className={styles["sidebar-header"]}> <div className={styles["sidebar-header"]}>
<button className={styles.close} onClick={onClose}> <button className={styles.close} onClick={onClose}>
@ -79,7 +77,10 @@ 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);
return ( return (
<div className={styles.sidebar}> <div
className={styles.sidebar}
style={!open ? { display: "none" } : {}}
>
<div className={styles["sidebar-header"]}> <div className={styles["sidebar-header"]}>
<button className={styles.close} onClick={onClose}> <button className={styles.close} onClick={onClose}>
@ -89,8 +90,51 @@ const Sidebar: React.FC<Props> = ({
</div> </div>
); );
} else { } else {
return null; return (
<div
className={styles.sidebar}
style={!open ? { display: "none" } : {}}
>
<div className={styles["default-sidebar"]}>
<h2>Modmapper</h2>
<p className={styles.subheader}>
An interactive map of Skyrim mods.
</p>
<PluginsLoader />
{lastModified && (
<div className={styles["sidebar-modified-date"]}>
<strong>Last updated:</strong>{" "}
{formatRelative(new Date(lastModified), new Date())}
</div>
)}
</div>
</div>
);
} }
};
const onClose = () => {
clearSelectedCell();
};
return (
<>
{!open ? (
<button
className={styles.open}
onClick={() => setOpen(true)}
title="Show sidebar"
></button>
) : (
<button
className={styles.hide}
onClick={() => setOpen(false)}
title="Hide sidebar"
></button>
)}
{renderOpenSidebar()}
</>
);
}; };
export default Sidebar; export default Sidebar;

7
lib/hooks.ts Normal file
View File

@ -0,0 +1,7 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
import type { AppDispatch, AppState } from "./store"
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector

26
lib/store.ts Normal file
View File

@ -0,0 +1,26 @@
import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"
import plugins from "../slices/plugins"
import pluginsReducer from "../slices/plugins"
import pluginsTxtReducer from "../slices/pluginsTxt"
export function makeStore() {
return configureStore({
reducer: { pluginsTxt: pluginsTxtReducer, plugins: pluginsReducer },
})
}
const store = makeStore()
export type AppState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
AppState,
unknown,
Action<string>
>
export default store

View File

@ -1,6 +1,11 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
webpack: (config) => {
const experiments = config.experiments || {};
config.experiments = {...experiments, asyncWebAssembly: true};
return config
},
} }
module.exports = nextConfig module.exports = nextConfig

1559
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.7.2",
"@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",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
@ -19,6 +20,8 @@
"next": "12.0.8", "next": "12.0.8",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-redux": "^7.2.6",
"skyrim-cell-dump-wasm": "0.1.0",
"swr": "^1.1.2" "swr": "^1.1.2"
}, },
"devDependencies": { "devDependencies": {
@ -29,6 +32,7 @@
"eslint-config-next": "12.0.8", "eslint-config-next": "12.0.8",
"next-sitemap": "^2.1.14", "next-sitemap": "^2.1.14",
"node-fetch": "^3.2.0", "node-fetch": "^3.2.0",
"typescript": "4.5.4" "typescript": "4.5.4",
"worker-loader": "3.0.8"
} }
} }

View File

@ -1,8 +1,16 @@
import '../styles/globals.css' import "../styles/globals.css";
import type { AppProps } from 'next/app'
import { Provider } from "react-redux";
import type { AppProps } from "next/app";
import store from "../lib/store";
function MyApp({ Component, pageProps }: AppProps) { function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} /> return (
<Provider store={store}>
<Component {...pageProps} />
</Provider>
);
} }
export default MyApp export default MyApp;

View File

@ -3,8 +3,11 @@ import Head from "next/head";
import "mapbox-gl/dist/mapbox-gl.css"; import "mapbox-gl/dist/mapbox-gl.css";
import Map from "../components/Map"; import Map from "../components/Map";
import { useAppDispatch } from "../lib/hooks";
import { setPluginsTxt } from "../slices/pluginsTxt";
const Home: NextPage = () => { const Home: NextPage = () => {
const dispatch = useAppDispatch();
return ( return (
<> <>
<Head> <Head>
@ -53,7 +56,31 @@ const Home: NextPage = () => {
<meta name="twitter:site" content="@tyhallada" /> <meta name="twitter:site" content="@tyhallada" />
<meta name="twitter:creator" content="@tyhallada" /> <meta name="twitter:creator" content="@tyhallada" />
</Head> </Head>
<div
style={{
margin: 0,
padding: 0,
width: "100%",
height: "100%",
}}
onDragOver={(evt) => {
console.log("drag over!");
evt.preventDefault();
}}
onDrop={async (evt) => {
console.log("drop!");
evt.preventDefault();
if (evt.dataTransfer.items && evt.dataTransfer.items.length > 0) {
const file = evt.dataTransfer.items[0].getAsFile();
if (file) {
dispatch(setPluginsTxt(await file.text()));
}
}
}}
>
<Map /> <Map />
</div>
</> </>
); );
}; };

106
slices/plugins.ts Normal file
View File

@ -0,0 +1,106 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
import { StateChangeTypes } from "downshift";
import type { AppState, AppThunk } from "../lib/store"
export interface Header {
author?: string;
description?: string;
masters: string[];
next_object_id: number;
num_records_and_groups: number;
version: number;
}
export interface Cell {
editor_id?: string;
form_id: number;
is_persistent: boolean;
world_form_id?: number;
x?: 0;
y?: 0;
}
export interface World {
editor_id: string;
form_id: number;
}
export interface Plugin {
header: Header;
cells: Cell[];
worlds: World[];
}
export interface PluginFile {
parsed?: Plugin;
filename: string;
lastModified: number;
hash: string;
parseError?: string;
enabled: boolean;
}
export type PluginsState = {
plugins: PluginFile[];
pending: number;
}
const initialState: PluginsState = { plugins: [], pending: 0 };
export const pluginsSlice = createSlice({
name: "plugins",
initialState,
reducers: {
addPlugin: (state, action: PayloadAction<PluginFile>) => ({ plugins: [...state.plugins, action.payload], pending: state.pending }),
setPlugins: (state, action: PayloadAction<PluginFile[]>) => ({ plugins: action.payload, pending: state.pending }),
setPending: (state, action: PayloadAction<number>) => ({ plugins: state.plugins, pending: action.payload }),
decrementPending: (state, action: PayloadAction<number>) => ({ plugins: state.plugins, pending: state.pending - action.payload }),
togglePlugin: (state, action: PayloadAction<string>) => ({ plugins: state.plugins.map((plugin) => (plugin.filename === action.payload ? { ...plugin, enabled: !plugin.enabled } : plugin)), pending: state.pending }),
clearPlugins: () => ({ plugins: [], pending: 0 }),
},
})
export const { addPlugin, setPlugins, setPending, decrementPending, togglePlugin, clearPlugins } = pluginsSlice.actions
export const selectPlugins = (state: AppState) => state.plugins
export const applyLoadOrder = (): AppThunk => (dispatch, getState) => {
const { plugins, pluginsTxt } = getState();
console.log("applying load order!", pluginsTxt);
const originalPlugins = [...plugins.plugins];
console.log(originalPlugins);
console.log(originalPlugins[0] && originalPlugins[0].filename);
let newPlugins = [];
for (let line of pluginsTxt.split("\n")) {
let enabled = false;
line = line.trim(); // remove carriage return at end of line
if (line.startsWith("#")) {
continue;
}
if (line.startsWith("*")) {
enabled = true;
line = line.slice(1);
}
console.log(line);
const originalIndex = originalPlugins.findIndex((p) => p.filename === line);
if (originalIndex >= 0) {
const original = originalPlugins.splice(originalIndex, 1)[0];
console.log(original);
if (original) {
newPlugins.push({ ...original, enabled });
}
}
}
console.log(originalPlugins);
console.log(newPlugins);
dispatch(setPlugins([...originalPlugins.sort((a, b) => b.lastModified - a.lastModified), ...newPlugins]));
}
export const addPluginInOrder = (plugin: PluginFile): AppThunk => (dispatch, getState) => {
dispatch(addPlugin(plugin));
dispatch(applyLoadOrder());
}
export default pluginsSlice.reducer

22
slices/pluginsTxt.ts Normal file
View File

@ -0,0 +1,22 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
import type { AppState } from "../lib/store"
export type PluginsTxtState = string;
const initialState: PluginsTxtState = "";
export const pluginsTxtSlice = createSlice({
name: "pluginsTxt",
initialState,
reducers: {
setPluginsTxt: (state, action: PayloadAction<string>) => action.payload,
clearPluginsTxt: (state) => "",
},
})
export const { setPluginsTxt, clearPluginsTxt } = pluginsTxtSlice.actions
export const selectPluginsTxt = (state: AppState) => state.pluginsTxt
export default pluginsTxtSlice.reducer

View File

@ -0,0 +1,77 @@
.no-top-margin {
margin-top: 0;
}
.plugin-list {
list-style-type: none;
padding: 0;
}
.plugin-list li {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
.plugin-error {
color: #ff0000;
}
.plugin-label {
margin-left: 8px;
}
/* From: https://stackoverflow.com/a/28074607/6620612 */
.processing:after {
overflow: hidden;
display: inline-block;
vertical-align: bottom;
-webkit-animation: ellipsis steps(4, end) 900ms infinite;
animation: ellipsis steps(4, end) 900ms infinite;
content: "\2026"; /* ascii code for the ellipsis character */
width: 0px;
}
@keyframes ellipsis {
to {
width: 1.25em;
}
}
@-webkit-keyframes ellipsis {
to {
width: 1.25em;
}
}
.plugins-txt-dialog {
top: 12px;
z-index: 4;
background-color: #fbefd5;
box-shadow: 0 0 1em black;
max-width: 400px;
}
.plugins-txt-dialog h3 {
margin-top: 0px;
}
.plugins-txt-dialog textarea {
width: 100%;
min-height: 100px;
resize: vertical;
}
.plugins-txt-dialog menu {
padding: 0;
display: flex;
justify-content: space-between;
}
.drop-area {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
}

View File

@ -11,6 +11,10 @@
left: calc(50% - max(20vw, 125px)); left: calc(50% - max(20vw, 125px));
} }
.search-bar.search-bar-sidebar-open {
left: calc(50% + 75px);
}
.search-bar input { .search-bar input {
width: 150px; width: 150px;
border-radius: 8px; border-radius: 8px;

View File

@ -9,8 +9,8 @@
width: 300px; width: 300px;
padding: 12px; padding: 12px;
padding-top: 24px; padding-top: 24px;
border-right: 1px solid #222222;
font-size: 0.875rem; font-size: 0.875rem;
border: 1px solid #222222;
} }
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
@ -29,13 +29,12 @@
position: sticky; position: sticky;
top: 0px; top: 0px;
width: 100%; width: 100%;
display: flex;
justify-content: right;
} }
.close { .close {
position: absolute;
display: block; display: block;
top: 0px;
right: 0px;
font-size: 24px; font-size: 24px;
border: none; border: none;
background: none; background: none;
@ -47,7 +46,114 @@
color: #888888; color: #888888;
} }
.hide {
position: fixed;
display: block;
font-size: 16px;
border: none;
background: none;
cursor: pointer;
top: 46%;
left: 299px;
z-index: 4;
background-color: #fbefd5;
padding-top: 12px;
padding-bottom: 12px;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
border-top: 2px solid #222222;
border-bottom: 2px solid #222222;
border-right: 2px solid #222222;
}
.hide:after {
content: "🠈";
}
@media only screen and (max-width: 600px) {
.hide {
top: calc(55% - 22px);
left: 47%;
padding-top: 0px;
padding-bottom: 0px;
border-top-right-radius: 8px;
border-bottom-right-radius: 0px;
border-top-left-radius: 8px;
padding-left: 12px;
padding-right: 12px;
border-bottom: none;
border-left: 2px solid #222222;
}
.hide:after {
content: "🠋";
}
}
.hide:hover {
color: #888888;
}
.open {
position: fixed;
display: block;
font-size: 16px;
border: none;
background: none;
cursor: pointer;
top: 46%;
left: -1px;
z-index: 3;
background-color: #fbefd5;
padding-top: 12px;
padding-bottom: 12px;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
border-top: 2px solid #222222;
border-bottom: 2px solid #222222;
border-right: 2px solid #222222;
}
.open:after {
content: "🠊";
}
@media only screen and (max-width: 600px) {
.open {
top: initial;
bottom: 0;
left: 47%;
padding-top: 0px;
padding-bottom: 0px;
border-top-right-radius: 8px;
border-bottom-right-radius: 0px;
border-top-left-radius: 8px;
padding-left: 12px;
padding-right: 12px;
border-bottom: none;
border-left: 2px solid #222222;
}
.open:after {
content: "🠉";
}
}
.cell-name-header { .cell-name-header {
line-height: 1.75rem; line-height: 1.75rem;
word-wrap: break-word; word-wrap: break-word;
} }
.default-sidebar {
display: flex;
flex-direction: column;
height: 100%;
}
.sidebar-modified-date {
margin-top: auto;
}
.subheader {
margin-top: 0;
}

View File

@ -11,7 +11,8 @@
} }
.heatmap-toggle span { .heatmap-toggle span {
background-image: url(/img/heatmap.svg); background: rgba(255, 255, 255, 0.2) url(/img/heatmap.svg);
background-blend-mode: lighten;
} }
.grid-toggle { .grid-toggle {
@ -19,7 +20,8 @@
} }
.grid-toggle span { .grid-toggle span {
background-image: url(/img/grid.svg); background: rgba(255, 255, 255, 0.2) url(/img/grid.svg);
background-blend-mode: lighten;
} }
.labels-toggle { .labels-toggle {
@ -27,7 +29,8 @@
} }
.labels-toggle span { .labels-toggle span {
background-image: url(/img/labels.svg); background: rgba(255, 255, 255, 0.2) url(/img/labels.svg);
background-blend-mode: lighten;
} }
.toggle-off { .toggle-off {

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext", "webworker"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,

7
worker.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare module 'worker-loader?filename=static/[fullhash].worker.js!*' {
class WebpackWorker extends Worker {
constructor();
}
export default WebpackWorker;
}

View File

@ -0,0 +1,31 @@
import { hash_plugin, parse_plugin } from "skyrim-cell-dump-wasm";
self.addEventListener('message', async (event: MessageEvent<{ skipParsing?: boolean; filename: string; lastModified: number; contents: Uint8Array}>) => {
const { skipParsing, filename, lastModified, contents } = event.data;
let parsed = undefined;
let parseError = undefined;
try {
if (!skipParsing) {
try {
parsed = parse_plugin(contents);
} catch (e) {
if (e instanceof Error) {
parseError = e.message;
} else {
parseError = "unknown error";
}
}
}
const hash = hash_plugin(contents).toString(36);
console.log(filename)
console.log(parsed);
self.postMessage({ filename, lastModified, parsed, hash, parseError, enabled: parsed && !parseError });
} catch (error) {
console.error(error);
self.postMessage(error);
}
});
//! To avoid isolatedModules error
// eslint-disable-next-line import/no-anonymous-default-export
export default {};