mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 07:04:53 +09:00
Add web UI for mod packs
Implement create, import, show, export, update, and delete UI for mod packs on the master server.
This commit is contained in:
1 parent
3eb63db313
commit
bddde30be4
6 files changed
+850
-7
No files matched your search
@@ -622,6 +622,7 @@ const webRoutes = [
|
||||
"/instances",
|
||||
"/instances/:id/view",
|
||||
"/mods",
|
||||
"/mods/mod-packs/:id/view",
|
||||
"/users",
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
|
||||
@@ -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 <Space>
|
||||
{(!props.modPack.mods.has(mod.name) || props.modPack.mods.get(mod.name).version !== mod.version)
|
||||
&& <Typography.Link
|
||||
onClick={() => {
|
||||
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"}</Typography.Link>
|
||||
}
|
||||
{props.modPack.mods.has(mod.name)
|
||||
&& <Typography.Link
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
props.onChange({
|
||||
type: "mods.delete",
|
||||
name: mod.name,
|
||||
value: { name: mod.name, version: mod.version, hash: mod.hash },
|
||||
});
|
||||
}}
|
||||
>remove</Typography.Link>
|
||||
}
|
||||
</Space>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Input
|
||||
placeholder="Search Mods"
|
||||
style={{ marginBottom: 8 }}
|
||||
onChange={(event) => {
|
||||
let search = event.target.value.trim();
|
||||
setSearchText(search);
|
||||
setModResultPage(1);
|
||||
}}
|
||||
/>
|
||||
<SectionHeader
|
||||
title="Stored Mods"
|
||||
extra={<Pagination
|
||||
current={modResultPage}
|
||||
onChange={modResultPageChanged}
|
||||
total={modResultCount}
|
||||
showTotal={total => `${total} results`}
|
||||
showSizeChanger={true}
|
||||
size="small"
|
||||
/>}
|
||||
/>
|
||||
<ConfigProvider renderEmpty={() => "No results"}>
|
||||
<Table
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: "Title",
|
||||
key: "title",
|
||||
render: 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 => <Select
|
||||
size="small"
|
||||
bordered={false}
|
||||
value={modResultSelectedVersion.get(result.name) || 0}
|
||||
onChange={index => {
|
||||
const newVersions = new Map(modResultSelectedVersion);
|
||||
newVersions.set(result.name, index);
|
||||
setModResultSelectedVersion(newVersions);
|
||||
}}
|
||||
options={result.versions.map((mod, index) => ({ label: mod.version, value: index }))}
|
||||
/>,
|
||||
},
|
||||
{
|
||||
title: "Action",
|
||||
key: "action",
|
||||
responsive: ["lg"],
|
||||
render: result => actions(result.versions[modResultSelectedVersion.get(result.name) || 0]),
|
||||
},
|
||||
]}
|
||||
expandable={{
|
||||
expandedRowRender: result => <ModDetails
|
||||
mod={result.versions[modResultSelectedVersion.get(result.name) || 0]}
|
||||
actions={actions}
|
||||
/>,
|
||||
expandedRowClassName: () => "no-expanded-padding",
|
||||
}}
|
||||
onChange={modResultsTableChanged}
|
||||
dataSource={modResults}
|
||||
pagination={false}
|
||||
rowKey={result => result.name}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ModsTable(props) {
|
||||
const [showAddMods, setShowAddMods] = useState(false);
|
||||
const [modList] = useModList();
|
||||
|
||||
const deletedMods = new Map();
|
||||
const changedMods = new Map();
|
||||
for (let change of props.changes) {
|
||||
if (props.modPack.mods.has(change.name)) {
|
||||
if (change.type === "mods.set") {
|
||||
changedMods.set(change.name, change.value);
|
||||
}
|
||||
} else if (change.type === "mods.delete") {
|
||||
deletedMods.set(change.name, change.value);
|
||||
}
|
||||
}
|
||||
|
||||
const modListMap = new Map(modList.map(mod => [`${mod.name}_${mod.version}`, mod]));
|
||||
const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map(
|
||||
mod => modListMap.get(`${mod.name}_${mod.version}`) || mod
|
||||
);
|
||||
|
||||
function actions(mod) {
|
||||
return <Space>
|
||||
{!deletedMods.has(mod.name) && <Typography.Link
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
props.onChange({
|
||||
type: "mods.delete",
|
||||
name: mod.name,
|
||||
value: mod,
|
||||
});
|
||||
}}
|
||||
>remove</Typography.Link>}
|
||||
{(deletedMods.has(mod.name) || changedMods.has(mod.name)) && <Typography.Link
|
||||
onClick={() => {
|
||||
props.onRevert({
|
||||
type: deletedMods.has(mod.name) ? "mods.delete" : "mods.set",
|
||||
name: mod.name,
|
||||
value: mod,
|
||||
});
|
||||
}}
|
||||
>revert</Typography.Link>}
|
||||
</Space>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<SectionHeader
|
||||
title="Mods"
|
||||
extra={<Space>
|
||||
<Button icon={<PlusOutlined />} onClick={() => setShowAddMods(true)}>Add</Button>
|
||||
</Space>}
|
||||
/>
|
||||
{showAddMods && <Card
|
||||
title="Add mods"
|
||||
size="small"
|
||||
extra={<Button type="text" icon={<CloseOutlined />} onClick={() => setShowAddMods(false)} />}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<SearchModsTable modPack={props.modPack} changes={props.changes} onChange={props.onChange} />
|
||||
</Card>}
|
||||
<Table
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: mod => 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 => <ModDetails mod={mod} actions={actions} />,
|
||||
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 <Form.Item
|
||||
name={[scope, fieldName]}
|
||||
key={`${scope} ${fieldName}`}
|
||||
label={fieldName}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
valuePropName={isBoolean ? "checked" : "value"}
|
||||
rules={[...isNumber ? [{
|
||||
validator(rule, value) {
|
||||
if (Number.isNaN(Number(value))) {
|
||||
return Promise.reject(new Error("Must be a number"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}] : []]}
|
||||
className={
|
||||
hasChange(props.changes, { type: "settings.set", scope, name: fieldName }) && "changed"
|
||||
}
|
||||
>
|
||||
{isBoolean ? <Checkbox /> : <Input />}
|
||||
</Form.Item>;
|
||||
});
|
||||
}
|
||||
|
||||
return <>
|
||||
<SectionHeader
|
||||
title="Mod Settings"
|
||||
/>
|
||||
<Typography.Paragraph>Use clusterioctl to add and remove fields.</Typography.Paragraph>
|
||||
<Typography.Title level={5}>Startup</Typography.Title>
|
||||
{controls("startup", pickValue(props.modPack.settings["startup"]))}
|
||||
|
||||
<Typography.Title level={5}>Map</Typography.Title>
|
||||
{controls("runtime-global", pickValue(props.modPack.settings["runtime-global"]))}
|
||||
|
||||
<Typography.Title level={5}>Per Player</Typography.Title>
|
||||
{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 <Tooltip title={denied ? clipboard.deniedReason : "Copied!"} visible={denied ? undefined : copiedVisible}>
|
||||
<Button disabled={denied} onClick={() => {
|
||||
try {
|
||||
clipboard.writeText(props.content);
|
||||
clearTimeout(copiedTimeout.current);
|
||||
setCopiedVisible(undefined);
|
||||
copiedTimeout.current = setTimeout(() => {
|
||||
setCopiedVisible(false);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
logger.error(`Writing to clipboard failed:\n${err.stack}`);
|
||||
notify("Unable to copy to clipboard", "error", "Use ordinary select and copy instead.");
|
||||
}
|
||||
}}>Copy</Button>
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
function ExportButton(props) {
|
||||
let [visible, setVisible] = useState(false);
|
||||
function close() {
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
let exportString = props.modPack.toModPackString();
|
||||
|
||||
return <>
|
||||
<Button icon={<ExportOutlined />} onClick={() => { setVisible(true); }}>Export to string</Button>
|
||||
<Modal
|
||||
title="Mod Pack String"
|
||||
visible={visible}
|
||||
onOk={close}
|
||||
onCancel={close}
|
||||
destroyOnClose
|
||||
footer={<Space>
|
||||
<CopyButton content={exportString} />
|
||||
<Button onClick={close}>Close</Button>
|
||||
</Space>}
|
||||
>
|
||||
<Text code className="code">
|
||||
{exportString}
|
||||
</Text>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
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 <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={modPackId}
|
||||
/>
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
if (modifiedModPack.missing) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Mod Pack Not Found"
|
||||
/>
|
||||
<p>Mod pack with id {modPackId} was not found on the master server.</p>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={modPack.name}
|
||||
extra={<Space>
|
||||
<ExportButton modPack={modifiedModPack}/>
|
||||
{account.hasPermission("core.mod-pack.delete") && <Popconfirm
|
||||
title="Delete mod pack and all of its settings?"
|
||||
placement="bottomRight"
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
libLink.messages.deleteModPack.send(
|
||||
control, { id: modPack.id }
|
||||
).then(() => {
|
||||
history.push("/mods");
|
||||
}).catch(notifyErrorHandler("Error deleting mod pack"));
|
||||
}}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />}>Delete</Button>
|
||||
</Popconfirm>}
|
||||
</Space>}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
onFieldsChange={(changedFields, allFields) => {
|
||||
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"])),
|
||||
}}
|
||||
>
|
||||
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, lg: 3 }}>
|
||||
<Descriptions.Item span={3} label="Name">
|
||||
<Form.Item noStyle name="name">
|
||||
<Input
|
||||
className={hasChange(changes, { type: "name" }) && "changed"}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item span={3} label="Description">
|
||||
<Form.Item noStyle name="description">
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2 }}
|
||||
className={hasChange(changes, { type: "description" }) && "changed"}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Mods">
|
||||
{modifiedModPack.mods.size}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Factorio Version">
|
||||
<Form.Item
|
||||
name="factorioVersion"
|
||||
style={{ marginBottom: 0 }}
|
||||
rules={[{
|
||||
pattern: /^\d+\.\d+(\.\d+)?$/,
|
||||
message: "Must be an a.b or a.b.c version number..",
|
||||
}]}
|
||||
>
|
||||
<Input
|
||||
className={hasChange(changes, { type: "factorioVersion" }) && "changed"}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<ModsTable modPack={modifiedModPack} changes={changes} onChange={pushChange} onRevert={revertChange} />
|
||||
<SettingsTable modPack={modifiedModPack} changes={changes} onChange={pushChange} onRevert={revertChange} />
|
||||
</Form>
|
||||
|
||||
<div
|
||||
className="ant-notification ant-notification-bottom"
|
||||
style={{
|
||||
visibility: changes.length ? "visible" : "hidden",
|
||||
inset: "auto auto 24px auto",
|
||||
position: "sticky",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<div className="ant-notification-notice" style={{ width: "auto", maxWidth: 384, marginBottom: 0 }}>
|
||||
<Row style={{ alignItems: "center", rowGap: 12 }}>
|
||||
<Col flex="auto">You have unsaved changes</Col>
|
||||
<Col flex="0 0 auto" style={{ marginLeft: "auto" }}>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setChanges([]);
|
||||
}}
|
||||
>Revert</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
libLink.messages.updateModPack.send(
|
||||
control, { mod_pack: modifiedModPack.toJSON() }
|
||||
).then(() => {
|
||||
form.resetFields();
|
||||
setChanges([]);
|
||||
}).catch(notifyErrorHandler("Error deleting mod pack"));
|
||||
}}
|
||||
>Apply</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>;
|
||||
|
||||
}
|
||||
@@ -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 <>
|
||||
<Button icon={<ImportOutlined />} onClick={() => { setVisible(true); }}>Import string</Button>
|
||||
<Modal
|
||||
title="Import Mod Pack String"
|
||||
visible={visible}
|
||||
okText="Import"
|
||||
cancelText="Cancel"
|
||||
onCancel={() => { 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"));
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="string"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ async validator(rule, value) {
|
||||
if (value) {
|
||||
libData.ModPack.fromModPackString(value);
|
||||
}
|
||||
}},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 6, maxRows: 6 }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
function CreateModPackButton() {
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
let [visible, setVisible] = useState(false);
|
||||
let [form] = Form.useForm();
|
||||
function close() {
|
||||
setVisible(false);
|
||||
}
|
||||
return <>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setVisible(true); }}>Create</Button>
|
||||
<Modal
|
||||
title="Create Mod Pack"
|
||||
visible={visible}
|
||||
okText="Create"
|
||||
cancelText="Cancel"
|
||||
onCancel={() => { 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"));
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark="optional">
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="factorioVersion"
|
||||
label="Factorio Version"
|
||||
rules={[{
|
||||
required: true,
|
||||
pattern: /^\d+\.\d+(\.\d+)?$/,
|
||||
message: "Must be an a.b or a.b.c version number.",
|
||||
}]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function ModsPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let history = useHistory();
|
||||
let [modList] = useModList();
|
||||
let [modPackList] = useModPackList();
|
||||
|
||||
function actions(mod) {
|
||||
return <Space>
|
||||
@@ -73,8 +187,43 @@ export default function ModsPage() {
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title="Mods"
|
||||
extra={uploadButton}
|
||||
/>
|
||||
<SectionHeader
|
||||
title="Mod Packs"
|
||||
extra={<Space>
|
||||
{account.hasPermission("core.mod-pack.create") && <ImportModPackButton />}
|
||||
{account.hasPermission("core.mod-pack.create") && <CreateModPackButton />}
|
||||
</Space>}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => 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`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<SectionHeader title="Stored Mods" extra={uploadButton} />
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
|
||||
@@ -49,7 +49,13 @@ export default function SiteLayout(props) {
|
||||
let menuItems = [];
|
||||
let menuGroups = new Map();
|
||||
for (let { sidebarName, sidebarGroup, permission, path } of combinedPages) {
|
||||
if (!sidebarName || permission && !account.hasPermission(permission)) {
|
||||
if (
|
||||
!sidebarName || permission && (
|
||||
typeof permission === "function"
|
||||
? !permission(account)
|
||||
: !account.hasPermission(permission)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (sidebarGroup) {
|
||||
|
||||
@@ -296,6 +296,10 @@ body {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.code > 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;
|
||||
}
|
||||
@@ -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: <ModsPage />,
|
||||
},
|
||||
{
|
||||
path: "/mods/mod-packs/:id/view",
|
||||
sidebarPath: "/mods",
|
||||
content: <ModPackViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/users",
|
||||
sidebarName: "Users",
|
||||
|
||||
Reference in new issue
Block a user