Files
factorio-clusterio/packages/web_ui/src/components/ModPackViewPage.jsx
T
Hornwitser 9b4ca86650 Rename mod hash field to sha1
The Factorio Mod Portal uses and provides SHA1 hashes of mods, so it
makes sense to align the ModPack and ModInfo structures to sha1 by
renaming the genericly named hash property to sha1.
2022-09-24 21:43:07 +02:00

652 lines
19 KiB
React

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, sha1: mod.sha1 },
});
}}
>{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, sha1: mod.sha1 },
});
}}
>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>;
}