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"]}>
<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,13 +56,13 @@ const Sidebar: React.FC<Props> = ({
);
};
const onClose = () => {
clearSelectedCell();
};
const renderOpenSidebar = () => {
if (selectedCell) {
return (
<div className={styles.sidebar}>
<div
className={styles.sidebar}
style={!open ? { display: "none" } : {}}
>
<div className={styles["sidebar-header"]}>
<button className={styles.close} onClick={onClose}>
@ -79,7 +77,10 @@ const Sidebar: React.FC<Props> = ({
} else if (router.query.mod) {
const modId = parseInt(router.query.mod as string, 10);
return (
<div className={styles.sidebar}>
<div
className={styles.sidebar}
style={!open ? { display: "none" } : {}}
>
<div className={styles["sidebar-header"]}>
<button className={styles.close} onClick={onClose}>
@ -89,8 +90,51 @@ const Sidebar: React.FC<Props> = ({
</div>
);
} 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;

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} */
const nextConfig = {
reactStrictMode: true,
webpack: (config) => {
const experiments = config.experiments || {};
config.experiments = {...experiments, asyncWebAssembly: true};
return config
},
}
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"
},
"dependencies": {
"@reduxjs/toolkit": "^1.7.2",
"@types/javascript-color-gradient": "^1.3.0",
"@types/mapbox-gl": "^2.6.0",
"date-fns": "^2.28.0",
@ -19,6 +20,8 @@
"next": "12.0.8",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "^7.2.6",
"skyrim-cell-dump-wasm": "0.1.0",
"swr": "^1.1.2"
},
"devDependencies": {
@ -29,6 +32,7 @@
"eslint-config-next": "12.0.8",
"next-sitemap": "^2.1.14",
"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 type { AppProps } from 'next/app'
import "../styles/globals.css";
import { Provider } from "react-redux";
import type { AppProps } from "next/app";
import store from "../lib/store";
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 Map from "../components/Map";
import { useAppDispatch } from "../lib/hooks";
import { setPluginsTxt } from "../slices/pluginsTxt";
const Home: NextPage = () => {
const dispatch = useAppDispatch();
return (
<>
<Head>
@ -53,7 +56,31 @@ const Home: NextPage = () => {
<meta name="twitter:site" content="@tyhallada" />
<meta name="twitter:creator" content="@tyhallada" />
</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 />
</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));
}
.search-bar.search-bar-sidebar-open {
left: calc(50% + 75px);
}
.search-bar input {
width: 150px;
border-radius: 8px;

View File

@ -9,8 +9,8 @@
width: 300px;
padding: 12px;
padding-top: 24px;
border-right: 1px solid #222222;
font-size: 0.875rem;
border: 1px solid #222222;
}
@media only screen and (max-width: 600px) {
@ -29,13 +29,12 @@
position: sticky;
top: 0px;
width: 100%;
display: flex;
justify-content: right;
}
.close {
position: absolute;
display: block;
top: 0px;
right: 0px;
font-size: 24px;
border: none;
background: none;
@ -47,7 +46,114 @@
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 {
line-height: 1.75rem;
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 {
background-image: url(/img/heatmap.svg);
background: rgba(255, 255, 255, 0.2) url(/img/heatmap.svg);
background-blend-mode: lighten;
}
.grid-toggle {
@ -19,7 +20,8 @@
}
.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 {
@ -27,7 +29,8 @@
}
.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 {

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": ["dom", "dom.iterable", "esnext", "webworker"],
"allowJs": true,
"skipLibCheck": 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 {};