Select file and plugin, add to new plugins state

This commit is contained in:
Tyler Hallada 2022-08-17 23:19:55 -04:00
parent a067f21f15
commit 2065b5fa3a
15 changed files with 398 additions and 104 deletions

View File

@ -1,79 +1,105 @@
import { format } from "date-fns"; import { format } from "date-fns";
import React from "react"; import React, { useCallback, useState } from "react";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
import { Mod, NEXUS_MODS_URL } from "./ModData"; import { Mod, File, NEXUS_MODS_URL } from "./ModData";
import styles from "../styles/AddModData.module.css"; import styles from "../styles/AddModData.module.css";
import { jsonFetcher } from "../lib/api"; import { jsonFetcher } from "../lib/api";
type Props = { type Props = {
selectedMod: number; selectedMod: number;
selectedPlugin: string | null;
setSelectedPlugin: (plugin: string) => void;
counts: Record<number, [number, number, number]> | null; counts: Record<number, [number, number, number]> | null;
}; };
const AddModData: React.FC<Props> = ({ selectedMod, counts }) => { const AddModData: React.FC<Props> = ({
const { data, error } = useSWRImmutable( selectedMod,
`https://mods.modmapper.com/${selectedMod}.json`, 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>(_) (_) => jsonFetcher<Mod>(_)
); );
const { data: fileData, error: fileError } = useSWRImmutable(
selectedFile ? `https://files.modmapper.com/${selectedFile}.json` : null,
(_) => jsonFetcher<File>(_)
);
if (error && error.status === 404) { 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>; 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 data: ${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 (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<h3> <h3>
<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>
</h3> </h3>
<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)}
@ -82,6 +108,44 @@ const AddModData: React.FC<Props> = ({ selectedMod, counts }) => {
<strong>Unique Downloads:</strong>{" "} <strong>Unique Downloads:</strong>{" "}
{numberFmt.format(unique_downloads)} {numberFmt.format(unique_downloads)}
</div> </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> </div>
); );
} }

View File

@ -1,8 +1,12 @@
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import React, { useState, useRef } from "react"; import React, { useCallback, useState, useRef } from "react";
import { useDispatch } from "react-redux";
import useSWRImmutable from "swr/immutable";
import AddModData from "./AddModData"; import AddModData from "./AddModData";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import { jsonFetcher } from "../lib/api";
import { updateFetchedPlugin, PluginsByHashWithMods } from "../slices/plugins";
import styles from "../styles/AddModDialog.module.css"; import styles from "../styles/AddModDialog.module.css";
type Props = { type Props = {
@ -11,16 +15,25 @@ type Props = {
const AddModDialog: React.FC<Props> = ({ counts }) => { const AddModDialog: React.FC<Props> = ({ counts }) => {
const [selectedMod, setSelectedMod] = useState<number | null>(null); const [selectedMod, setSelectedMod] = useState<number | null>(null);
const [selectedPlugin, setSelectedPlugin] = useState<string | null>(null);
const [dialogShown, setDialogShown] = useState(false); const [dialogShown, setDialogShown] = useState(false);
const searchInput = useRef<HTMLInputElement | null>(null); const searchInput = useRef<HTMLInputElement | null>(null);
const dispatch = useDispatch();
const onAddModButtonClick = async () => { const { data, error } = useSWRImmutable(
selectedPlugin
? `https://plugins.modmapper.com/${selectedPlugin}.json`
: null,
(_) => jsonFetcher<PluginsByHashWithMods>(_)
);
const onAddModButtonClick = useCallback(async () => {
setSelectedMod(null); setSelectedMod(null);
setDialogShown(true); setDialogShown(true);
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (searchInput.current) searchInput.current.focus(); if (searchInput.current) searchInput.current.focus();
}); });
}; }, [setSelectedMod, setDialogShown]);
return ( return (
<> <>
@ -41,7 +54,12 @@ const AddModDialog: React.FC<Props> = ({ counts }) => {
inputRef={searchInput} inputRef={searchInput}
/> />
{selectedMod && ( {selectedMod && (
<AddModData selectedMod={selectedMod} counts={counts} /> <AddModData
selectedMod={selectedMod}
selectedPlugin={selectedPlugin}
setSelectedPlugin={setSelectedPlugin}
counts={counts}
/>
)} )}
<menu> <menu>
<button <button
@ -55,10 +73,11 @@ const AddModDialog: React.FC<Props> = ({ counts }) => {
</button> </button>
<button <button
onClick={() => { onClick={() => {
console.log(`Adding mod ${selectedMod}`); console.log(`Adding mod ${selectedMod} ${selectedPlugin}`);
if (data) dispatch(updateFetchedPlugin(data));
setDialogShown(false); setDialogShown(false);
}} }}
disabled={!selectedMod} disabled={!selectedMod || !selectedPlugin || !data}
> >
Add Add
</button> </button>

View File

@ -4,7 +4,7 @@ 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 PluginList from "./ParsedPluginsList";
import { jsonFetcher } from "../lib/api"; import { jsonFetcher } from "../lib/api";
export interface Mod { export interface Mod {

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,68 @@
import Link from "next/link";
import React from "react";
import { useAppSelector, useAppDispatch } from "../lib/hooks";
import {
disableAllFetchedPlugins,
enableAllFetchedPlugins,
toggleFetchedPlugin,
} from "../slices/plugins";
import styles from "../styles/FetchedPluginList.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>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}>
<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>
</li>
))}
</ol>
</>
);
};
export default FetchedPluginsList;

View File

@ -5,7 +5,7 @@ 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";
@ -55,9 +55,11 @@ 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 plugins = useAppSelector((state) => state.plugins.parsedPlugins);
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 +245,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" });
@ -371,12 +373,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 +390,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

View File

@ -12,6 +12,43 @@ export interface CellCoord {
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,6 +67,7 @@ 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[];
} }
export const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition"; export const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";

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/ParsedPluginList.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

@ -11,7 +11,8 @@ 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 = {
@ -154,7 +155,8 @@ const Sidebar: React.FC<Props> = ({
</p> </p>
<DataDirPicker /> <DataDirPicker />
<PluginTxtEditor /> <PluginTxtEditor />
<PluginsList /> <ParsedPluginsList />
<FetchedPluginsList />
<AddModDialog counts={counts} /> <AddModDialog counts={counts} />
{renderLastModified(lastModified)} {renderLastModified(lastModified)}
</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

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

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

@ -7,3 +7,23 @@ a.name {
margin-top: 24px; margin-top: 24px;
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;
}

View File

@ -0,0 +1,42 @@
.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-error {
color: #ff0000;
}
.plugin-label {
margin-left: 8px;
}
.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;
}