Default sidebar and WIP PluginLoader

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

View File

@@ -4,6 +4,8 @@ import Gradient from "javascript-color-gradient";
import mapboxgl from "mapbox-gl";
import useSWRImmutable from "swr/immutable";
import { useAppSelector } from "../lib/hooks";
import { PluginFile } from "../slices/plugins";
import styles from "../styles/Map.module.css";
import Sidebar from "./Sidebar";
import ToggleLayersControl from "./ToggleLayersControl";
@@ -24,7 +26,11 @@ colorGradient.setMidpoint(360);
const LIVE_DOWNLOAD_COUNTS_URL =
"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 Map: React.FC = () => {
@@ -52,7 +58,10 @@ const Map: React.FC = () => {
}[]
| 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(
"https://cells.modmapper.com/edits.json",
@@ -77,17 +86,18 @@ const Map: React.FC = () => {
map.current.setFeatureState(
{
source: "grid-source",
id: (cell.x + 57) * 100 + 50 - cell.y,
id: (cell.x + 57) * 1000 + 50 - cell.y,
},
{
selected: true,
}
);
map.current.removeFeatureState({ source: "selected-cell-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
map.current.setFeatureState(
{
source: "selected-cell-source",
id: (cell.x + 57) * 100 + 50 - cell.y,
id: (cell.x + 57) * 1000 + 50 - cell.y,
},
{
cellSelected: true,
@@ -137,17 +147,30 @@ const Map: React.FC = () => {
if (map.current && !map.current.getSource("grid-source")) return;
map.current.removeFeatureState({ source: "selected-cell-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
const visited: { [id: number]: boolean } = {};
for (let cell of cells) {
const id = (cell.x + 57) * 1000 + 50 - cell.y;
map.current.setFeatureState(
{
source: "selected-cell-source",
id: (cell.x + 57) * 100 + 50 - cell.y,
id,
},
{
modSelected: true,
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;
@@ -203,6 +226,7 @@ const Map: React.FC = () => {
(cell) => {
router.push({ query: { cell: cell.x + "," + cell.y } });
setSelectedCell(cell);
setSidebarOpen(true);
selectMapCell(cell);
},
[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: "selected-cell-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
}
requestAnimationFrame(() => {
if (map.current) map.current.resize();
@@ -223,6 +248,7 @@ const Map: React.FC = () => {
setSelectedCells(null);
if (map.current) {
map.current.removeFeatureState({ source: "selected-cell-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
}
requestAnimationFrame(() => {
if (map.current) map.current.resize();
@@ -235,6 +261,16 @@ const Map: React.FC = () => {
});
}, [map]);
const setSidebarOpenWithResize = useCallback(
(open) => {
setSidebarOpen(open);
requestAnimationFrame(() => {
if (map.current) map.current.resize();
});
},
[map]
);
useEffect(() => {
if (!heatmapLoaded) return; // wait for all map layers to load
if (router.query.cell && typeof router.query.cell === "string") {
@@ -257,6 +293,7 @@ const Map: React.FC = () => {
selectedCells
) {
clearSelectedCell();
setSidebarOpen(true);
selectCells(selectedCells);
} else {
if (selectedCell) {
@@ -285,6 +322,33 @@ const Map: React.FC = () => {
}
}, [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(() => {
if (map.current) return; // initialize map only once
map.current = new mapboxgl.Map({
@@ -452,12 +516,12 @@ const Map: React.FC = () => {
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y + cellSize,
]);
const editCount = (cellsData as Record<string, number>)[
const editCount = (cellsData.data as Record<string, number>)[
`${x - 57},${50 - y}`
];
grid.features.push({
type: "Feature",
id: x * 100 + y,
id: x * 1000 + y,
geometry: {
type: "Polygon",
coordinates: [
@@ -546,7 +610,7 @@ const Map: React.FC = () => {
]);
selectedCellLines.features.push({
type: "Feature",
id: x * 100 + y,
id: x * 1000 + y,
geometry: {
type: "LineString",
coordinates: [
@@ -555,6 +619,7 @@ const Map: React.FC = () => {
[se.lng, se.lat],
[sw.lng, sw.lat],
[nw.lng, nw.lat],
[ne.lng, ne.lat],
],
},
properties: { x: x, y: y },
@@ -586,6 +651,75 @@ const Map: React.FC = () => {
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();
@@ -632,16 +766,14 @@ const Map: React.FC = () => {
selectedCell={selectedCell}
clearSelectedCell={() => router.push({ query: {} })}
setSelectedCells={setSelectedCells}
map={map}
counts={counts}
countsError={countsError}
open={sidebarOpen}
setOpen={setSidebarOpenWithResize}
lastModified={cellsData && cellsData.lastModified}
/>
<ToggleLayersControl map={map} />
<SearchBar
map={map}
clearSelectedCell={() => router.push({ query: {} })}
counts={counts}
/>
<SearchBar counts={counts} sidebarOpen={sidebarOpen} />
</div>
</div>
</>

View File

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

View File

@@ -120,7 +120,7 @@ const ModData: React.FC<Props> = ({
href={`${NEXUS_MODS_URL}/mods/${data.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
className={`${styles.link} ${styles.name}`}
className={styles.name}
>
{data.name}
</a>
@@ -131,7 +131,6 @@ const ModData: React.FC<Props> = ({
href={`${NEXUS_MODS_URL}/mods/categories/${data.category_id}`}
target="_blank"
rel="noreferrer noopener"
className={styles.link}
>
{data.category_name}
</a>
@@ -142,7 +141,6 @@ const ModData: React.FC<Props> = ({
href={`${NEXUS_MODS_URL}/users/${data.author_id}`}
target="_blank"
rel="noreferrer noopener"
className={styles.link}
>
{data.author_name}
</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 React, { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import type mapboxgl from "mapbox-gl";
import MiniSearch, { SearchResult } from "minisearch";
import useSWRImmutable from "swr/immutable";
import styles from "../styles/SearchBar.module.css";
type Props = {
clearSelectedCell: () => void;
map: React.MutableRefObject<mapboxgl.Map>;
counts: Record<number, [number, number, number]> | null;
sidebarOpen: boolean;
};
interface Mod {
@@ -51,7 +49,7 @@ const cellSearch = new MiniSearch({
});
cellSearch.addAll(cells);
const SearchBar: React.FC<Props> = ({ clearSelectedCell, counts, map }) => {
const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
const router = useRouter();
const modSearch = useRef<MiniSearch<Mod> | null>(
@@ -141,7 +139,7 @@ const SearchBar: React.FC<Props> = ({ clearSelectedCell, counts, map }) => {
<div
className={`${styles["search-bar"]} ${
searchFocused ? styles["search-bar-focused"] : ""
}`}
} ${sidebarOpen ? styles["search-bar-sidebar-open"] : ""}`}
{...getComboboxProps()}
>
<input

View File

@@ -1,25 +1,21 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { useRouter } from "next/router";
import useSWRImmutable from "swr/immutable";
import { formatRelative } from "date-fns";
import CellData from "./CellData";
import ModData from "./ModData";
import PluginsLoader from "./PluginsLoader";
import styles from "../styles/Sidebar.module.css";
import { render } from "react-dom";
interface Cell {
x: number;
y: number;
form_id: number;
}
type Props = {
selectedCell: { x: number; y: number } | null;
clearSelectedCell: () => void;
setSelectedCells: (cells: { x: number; y: number }[] | null) => void;
map: React.MutableRefObject<mapboxgl.Map | null>;
counts: Record<number, [number, number, number]> | null;
countsError: Error | null;
open: boolean;
setOpen: (open: boolean) => void;
lastModified: string | null | undefined;
};
const Sidebar: React.FC<Props> = ({
@@ -28,7 +24,9 @@ const Sidebar: React.FC<Props> = ({
setSelectedCells,
counts,
countsError,
map,
open,
setOpen,
lastModified,
}) => {
const router = useRouter();
@@ -58,39 +56,85 @@ const Sidebar: React.FC<Props> = ({
);
};
const renderOpenSidebar = () => {
if (selectedCell) {
return (
<div
className={styles.sidebar}
style={!open ? { display: "none" } : {}}
>
<div className={styles["sidebar-header"]}>
<button className={styles.close} onClick={onClose}>
</button>
</div>
<h1 className={styles["cell-name-header"]}>
Cell {selectedCell.x}, {selectedCell.y}
</h1>
{renderCellData(selectedCell)}
</div>
);
} else if (router.query.mod) {
const modId = parseInt(router.query.mod as string, 10);
return (
<div
className={styles.sidebar}
style={!open ? { display: "none" } : {}}
>
<div className={styles["sidebar-header"]}>
<button className={styles.close} onClick={onClose}>
</button>
</div>
{!Number.isNaN(modId) && renderModData(modId)}
</div>
);
} else {
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();
};
if (selectedCell) {
return (
<div className={styles.sidebar}>
<div className={styles["sidebar-header"]}>
<button className={styles.close} onClick={onClose}>
</button>
</div>
<h1 className={styles["cell-name-header"]}>
Cell {selectedCell.x}, {selectedCell.y}
</h1>
{renderCellData(selectedCell)}
</div>
);
} else if (router.query.mod) {
const modId = parseInt(router.query.mod as string, 10);
return (
<div className={styles.sidebar}>
<div className={styles["sidebar-header"]}>
<button className={styles.close} onClick={onClose}>
</button>
</div>
{!Number.isNaN(modId) && renderModData(modId)}
</div>
);
} else {
return null;
}
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;