From bddde30be4d99a050fa27d59a556c3ba5e00c493 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 24 Sep 2022 21:17:18 +0200 Subject: [PATCH] Add web UI for mod packs Implement create, import, show, export, update, and delete UI for mod packs on the master server. --- packages/master/src/routes.js | 1 + .../web_ui/src/components/ModPackViewPage.jsx | 651 ++++++++++++++++++ packages/web_ui/src/components/ModsPage.jsx | 157 ++++- packages/web_ui/src/components/SiteLayout.jsx | 8 +- packages/web_ui/src/index.css | 32 +- packages/web_ui/src/pages.jsx | 8 +- 6 files changed, 850 insertions(+), 7 deletions(-) create mode 100644 packages/web_ui/src/components/ModPackViewPage.jsx diff --git a/packages/master/src/routes.js b/packages/master/src/routes.js index a34a2259..0318bcea 100644 --- a/packages/master/src/routes.js +++ b/packages/master/src/routes.js @@ -622,6 +622,7 @@ const webRoutes = [ "/instances", "/instances/:id/view", "/mods", + "/mods/mod-packs/:id/view", "/users", "/users/:id/view", "/roles", diff --git a/packages/web_ui/src/components/ModPackViewPage.jsx b/packages/web_ui/src/components/ModPackViewPage.jsx new file mode 100644 index 00000000..1d01ca5e --- /dev/null +++ b/packages/web_ui/src/components/ModPackViewPage.jsx @@ -0,0 +1,651 @@ +import React, { useEffect, useContext, useRef, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; +import { + Button, Card, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, PageHeader, Pagination, + Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip, +} from "antd"; +import InfoCircleOutlined from "@ant-design/icons/InfoCircleOutlined"; +import ExportOutlined from "@ant-design/icons/ExportOutlined"; +import CloseOutlined from "@ant-design/icons/CloseOutlined"; +import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; +import PlusOutlined from "@ant-design/icons/PlusOutlined"; +import UploadOutlined from "@ant-design/icons/UploadOutlined"; + +import { libData, libLink, libLogging } from "@clusterio/lib"; +import ControlContext from "./ControlContext"; +import { useAccount } from "../model/account"; +import { useModPack } from "../model/mod_pack"; +import { useModList } from "../model/mods"; +import { useClipboard } from "../util/clipboard"; +import notify, { notifyErrorHandler } from "../util/notify"; +import PageLayout from "./PageLayout"; +import SectionHeader from "./SectionHeader"; +import ModDetails from "./ModDetails"; + +const { logger } = libLogging; +const { Paragraph, Text } = Typography; +const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; + +function SearchModsTable(props) { + const control = useContext(ControlContext); + const [searchText, setSearchText] = useState(""); + const [modResultSort, setModResultSort] = useState("title"); + const [modResultSortOrder, setModResultSortOrder] = useState("asc"); + const [modResults, setModResults] = useState([]); + const [modResultPage, setModResultPage] = useState(1); + const [modResultPageSize, setModResultPageSize] = useState(10); + const [modResultCount, setModResultCount] = useState(2); + const [modResultSelectedVersion, setModResultSelectedVersion] = useState(new Map()); + + const factorioVersion = props.modPack.factorioVersion.split(".").slice(0, 2).join("."); + + useEffect(() => { + let canceled = false; + libLink.messages.searchMods.send(control, { + query: searchText, + factorio_version: factorioVersion, + page_size: modResultPageSize, + page: modResultPage, + sort: modResultSort, + sort_order: modResultSortOrder, + }).then(response => { + if (canceled) { + return; + } + // In React < v18 this causes 3 renders and a partial state necessitating the use + // of || 0 on modResultSelectedVersion.get() calls. Remove when updating React. + setModResults(response.results.map( + ({ name, versions }) => ({ name, versions: versions.map(mod => new libData.ModInfo(mod)) }) + )); + setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0]))); + setModResultCount(response.result_count); + }); + + return () => { + canceled = true; + }; + }, [searchText, factorioVersion, modResultPageSize, modResultPage, modResultSort, modResultSortOrder]); + + function modResultPageChanged(page, pageSize) { + setModResultPage(page); + setModResultPageSize(pageSize); + } + + function modResultsTableChanged(pagination, filters, sorters, extra) { + if (extra.action === "sort") { + if (sorters.order) { + const mapping = { + ascend: "asc", + descend: "desc", + }; + setModResultSort(sorters.columnKey); + setModResultSortOrder(mapping[sorters.order]); + } else { + setModResultSort(undefined); + setModResultSortOrder(undefined); + } + } + } + + function actions(mod) { + return + {(!props.modPack.mods.has(mod.name) || props.modPack.mods.get(mod.name).version !== mod.version) + && { + props.onChange({ + type: "mods.set", + name: mod.name, + value: { name: mod.name, version: mod.version, hash: mod.hash }, + }); + }} + >{props.modPack.mods.has(mod.name) ? "change" : "add"} + } + {props.modPack.mods.has(mod.name) + && { + props.onChange({ + type: "mods.delete", + name: mod.name, + value: { name: mod.name, version: mod.version, hash: mod.hash }, + }); + }} + >remove + } + ; + } + + return <> + { + let search = event.target.value.trim(); + setSearchText(search); + setModResultPage(1); + }} + /> + `${total} results`} + showSizeChanger={true} + size="small" + />} + /> + "No results"}> + results.versions[0].title, + defaultSortOrder: "ascend", + sorter: true, + }, + { + title: "Author", + key: "author", + render: results => results.versions[0].author, + responsive: ["lg"], + sorter: true, + }, + { + title: "Version", + key: "version", + align: "right", + render: result =>
mod.title || mod.name, + defaultSortOrder: "ascend", + sorter: (a, b) => strcmp(a.name, b.name), + }, + { + title: "Version", + dataIndex: "version", + sorter: (a, b) => strcmp(a.version, b.version), + }, + { + title: "Action", + key: "action", + responsive: ["lg"], + render: mod => actions(mod), + }, + ]} + expandable={{ + expandedRowRender: mod => , + expandedRowClassName: () => "no-expanded-padding", + }} + dataSource={mods} + pagination={false} + rowKey={mod => mod.name} + rowClassName={mod => { + if (deletedMods.has(mod.name)) { + return "deleted"; + } + if (changedMods.has(mod.name)) { + return "changed"; + } + return ""; + }} + /> + ; +} + +// Returns true if a change record with the given fields exist +function hasChange(changes, field) { + return changes.some(change => Object.entries(field).every(([key, value]) => change[key] === value)); +} + +// picks out the value property from settings. +function pickValue(map) { + return [...map].map(([name, value]) => [name, value.value]); +} + +function SettingsTable(props) { + function controls(scope, fields) { + return fields.map(([fieldName, fieldValue]) => { + const isBoolean = typeof fieldValue === "boolean"; + const isNumber = typeof fieldValue === "number"; + + return + {isBoolean ? : } + ; + }); + } + + return <> + + Use clusterioctl to add and remove fields. + Startup + {controls("startup", pickValue(props.modPack.settings["startup"]))} + + Map + {controls("runtime-global", pickValue(props.modPack.settings["runtime-global"]))} + + Per Player + {controls("runtime-per-user", pickValue(props.modPack.settings["runtime-per-user"]))} + ; +} + +function CopyButton(props) { + let clipboard = useClipboard(); + let [copiedVisible, setCopiedVisible] = useState(false); + let copiedTimeout = useRef(); + + useEffect(() => () => { + clearTimeout(copiedTimeout.current); + setCopiedVisible(false); + }, []); + + let denied = clipboard.readPermissionState === "denied"; + return + + ; +} + +function ExportButton(props) { + let [visible, setVisible] = useState(false); + function close() { + setVisible(false); + } + + let exportString = props.modPack.toModPackString(); + + return <> + + + + + } + > + + {exportString} + + + ; +} + +export default function ModPackViewPage() { + let account = useAccount(); + let history = useHistory(); + let control = useContext(ControlContext); + let params = useParams(); + let modPackId = Number(params.id); + + const [form] = Form.useForm(); + let [modPack] = useModPack(modPackId); + let [changes, setChanges] = useState([]); + let [modifiedModPack, setModifiedModPack] = useState(modPack); + let syncTimeout = useRef(); + + useEffect(() => () => { clearTimeout(syncTimeout.current); }); + + function pushChange(change) { + setChanges(oldChanges => { + const newChanges = [...oldChanges]; + if ( + newChanges.length + && newChanges.slice(-1)[0].type === change.type + && newChanges.slice(-1)[0].name === change.name + && newChanges.slice(-1)[0].scope === change.scope + ) { + newChanges.splice(-1, 1, change); + } else { + newChanges.push(change); + } + return newChanges; + }); + } + + function revertChange(change) { + const newChanges = [...changes]; + const index = newChanges.findLastIndex(reference => ( + change.type === reference.type && change.name === reference.name + )); + if (index === -1) { + logger.error(`Unable to revert ${change.type} ${change.name}: change not found`); + return; + } + newChanges.splice(index, 1); + setChanges(newChanges); + } + + function syncPack(fields) { + for (let field of fields) { + if (!field.touched || field.validating || field.errors.length) { + continue; + } + if (["name", "description", "factorioVersion"].includes(field.name[0])) { + if (modifiedModPack[field.name[0]] !== field.value) { + pushChange({ type: field.name[0], value: field.value }); + } + + } else if (["startup", "runtime-global", "runtime-per-user"].includes(field.name[0])) { + let [scope, settingName] = field.name; + let value = field.value; + if (typeof modPack.settings[scope].get(settingName) === "number") { + value = Number(value); + if (Number.isNaN(value)) { + continue; + } + } + if (modifiedModPack.settings[scope].get(settingName) !== value) { + pushChange({ type: "settings.set", scope, name: settingName, value: { value }}); + } + } + } + } + + useEffect(() => { + // Loading or invalid id + if (!(modPack instanceof libData.ModPack)) { + setModifiedModPack(modPack); + return; + } + + const modified = new libData.ModPack(modPack.toJSON()); + for (let change of changes) { + if (change.type === "name") { + modified.name = change.value; + } else if (change.type === "description") { + modified.description = change.value; + } else if (change.type === "factorioVersion") { + modified.factorioVersion = change.value; + } else if (change.type === "mods.set") { + modified.mods.set(change.name, change.value); + } else if (change.type === "mods.delete") { + modified.mods.delete(change.name); + } else if (change.type === "settings.set") { + modified.settings[change.scope].set(change.name, change.value); + } else if (change.type === "settings.delete") { + modified.settings[change.scope].delete(change.name); + } else { + throw new Error(`Unknown change type ${change.type}`); + } + } + setModifiedModPack(modified); + }, [modPack, changes]); + + let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack.name || modPackId }]; + if (modifiedModPack.loading) { + return + + + ; + } + + if (modifiedModPack.missing) { + return + +

Mod pack with id {modPackId} was not found on the master server.

+
; + } + + return + + + {account.hasPermission("core.mod-pack.delete") && { + libLink.messages.deleteModPack.send( + control, { id: modPack.id } + ).then(() => { + history.push("/mods"); + }).catch(notifyErrorHandler("Error deleting mod pack")); + }} + > + + } + } + /> +
{ + clearTimeout(syncTimeout.current); + if (typeof changedFields[0].value === "boolean") { + syncPack(allFields); + } else { + // Defer sync if this is not a checkbox to make the UI responsive. + syncTimeout.current = setTimeout(syncPack.bind(undefined, allFields), 500); + } + }} + initialValues={{ + name: modPack.name, + description: modPack.description, + factorioVersion: modPack.factorioVersion, + "startup": Object.fromEntries(pickValue(modPack.settings["startup"])), + "runtime-global": Object.fromEntries(pickValue(modPack.settings["runtime-global"])), + "runtime-per-user": Object.fromEntries(pickValue(modPack.settings["runtime-per-user"])), + }} + > + + + + + + + + + + + + + {modifiedModPack.mods.size} + + + + + + + + + + + +
+
+ +
You have unsaved changes + + + + + + + + + + ; + +} diff --git a/packages/web_ui/src/components/ModsPage.jsx b/packages/web_ui/src/components/ModsPage.jsx index bd97c786..71a1cca6 100644 --- a/packages/web_ui/src/components/ModsPage.jsx +++ b/packages/web_ui/src/components/ModsPage.jsx @@ -1,24 +1,138 @@ -import React, { Fragment, useContext } from "react"; -import { Button, Descriptions, Grid, PageHeader, Popconfirm, Space, Table, Typography, Upload } from "antd"; +import React, { Fragment, useState, useContext } from "react"; +import { useHistory } from "react-router-dom"; +import { Button, Form, Input, Modal, PageHeader, Popconfirm, Space, Table, Typography, Upload } from "antd"; +import ImportOutlined from "@ant-design/icons/ImportOutlined"; +import PlusOutlined from "@ant-design/icons/PlusOutlined"; import UploadOutlined from "@ant-design/icons/UploadOutlined"; -import { libHelpers, libLink } from "@clusterio/lib"; +import { libData, libHelpers, libLink, libLogging } from "@clusterio/lib"; import { useAccount } from "../model/account"; import { useModList } from "../model/mods"; +import { useModPackList } from "../model/mod_pack"; import { notifyErrorHandler } from "../util/notify"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; +import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; +const { logger } = libLogging; + + +function ImportModPackButton() { + let control = useContext(ControlContext); + let history = useHistory(); + let [visible, setVisible] = useState(false); + let [form] = Form.useForm(); + function close() { + setVisible(false); + } + return <> + + { setVisible(false); }} + onOk={() => { + (async () => { + let values; + try { + values = await form.validateFields(); + } catch { + return; // Validation failed + } + const modPack = libData.ModPack.fromModPackString(values.string); + await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() }); + history.push(`/mods/mod-packs/${modPack.id}/view`); + })().catch(notifyErrorHandler("Error creating mod pack")); + }} + > +
+ + + + +
+ ; +} + +function CreateModPackButton() { + let control = useContext(ControlContext); + let history = useHistory(); + let [visible, setVisible] = useState(false); + let [form] = Form.useForm(); + function close() { + setVisible(false); + } + return <> + + { setVisible(false); }} + onOk={() => { + (async () => { + let values; + try { + values = await form.validateFields(); + } catch { + return; // Validation failed + } + const modPack = new libData.ModPack(); + modPack.name = values.name; + modPack.factorioVersion = values.factorioVersion; + if (values.description) { modPack.description = values.description; } + await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() }); + history.push(`/mods/mod-packs/${modPack.id}/view`); + })().catch(notifyErrorHandler("Error creating mod pack")); + }} + > +
+ + + + + + + + + + +
+ ; +} export default function ModsPage() { let account = useAccount(); let control = useContext(ControlContext); + let history = useHistory(); let [modList] = useModList(); + let [modPackList] = useModPackList(); function actions(mod) { return @@ -73,8 +187,43 @@ export default function ModsPage() { + + {account.hasPermission("core.mod-pack.create") && } + {account.hasPermission("core.mod-pack.create") && } + } + /> +
strcmp(a.name, b.name), + }, + { + title: "Factorio Version", + dataIndex: "factorioVersion", + sorter: (a, b) => a.integerFactorioVersion - b.integerFactorioVersion, + }, + { + title: "Mods", + key: "roles", + render: modPack => modPack.mods.size, + }, + ]} + dataSource={modPackList} + pagination={false} + rowKey={modPack => modPack.id} + onRow={(modPack, rowIndex) => ({ + onClick: event => { + history.push(`/mods/mod-packs/${modPack.id}/view`); + }, + })} + /> +
code { + display: block; +} + /* * Render codeblock with options for syntax highlighting * https://user-images.githubusercontent.com/6792749/152066701-21420461-710b-4fae-a80a-89ef90a0b18f.png @@ -321,6 +325,32 @@ body { border: none; } -tr.no-expanded-padding > td { +tr.no-expanded-padding > td, +.ant-table.ant-table-small .ant-table-tbody > tr.no-expanded-padding > td { padding: 0; } + +.ant-form-item.changed, +input.ant-input.changed, +textarea.ant-input.changed, +tr.ant-table-row.changed { + background: #2a1912; +} + +.ant-table-tbody > tr.ant-table-row.changed:hover > td, +.ant-table-tbody > tr.changed > td.ant-table-cell-row-hover +tr.ant-table-row.deleted { + background: #3a2922; +} + +tr.ant-table-row.deleted { + color: rgba(255, 255, 255, 0.45); + background: #340c08; + text-decoration: line-through; +} + +.ant-table-tbody > tr.ant-table-row.deleted:hover > td, +.ant-table-tbody > tr.deleted > td.ant-table-cell-row-hover +tr.ant-table-row.deleted { + background: #441c18; +} diff --git a/packages/web_ui/src/pages.jsx b/packages/web_ui/src/pages.jsx index 7f190564..ed85e7a5 100644 --- a/packages/web_ui/src/pages.jsx +++ b/packages/web_ui/src/pages.jsx @@ -5,6 +5,7 @@ import SlavesPage from "./components/SlavesPage"; import SlaveViewPage from "./components/SlaveViewPage"; import InstancesPage from "./components/InstancesPage"; import InstanceViewPage from "./components/InstanceViewPage"; +import ModPackViewPage from "./components/ModPackViewPage"; import ModsPage from "./components/ModsPage"; import UsersPage from "./components/UsersPage"; import UserViewPage from "./components/UserViewPage"; @@ -45,9 +46,14 @@ export const pages = [ { path: "/mods", sidebarName: "Mods", - permission: "core.mod.list", + permission: account => account.hasAnyPermission("core.mod.list", "core.mod-pack.list"), content: , }, + { + path: "/mods/mod-packs/:id/view", + sidebarPath: "/mods", + content: , + }, { path: "/users", sidebarName: "Users",