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:
Hornwitser committed 2022-09-24 21:17:18 +02:00
1 parent 3eb63db313
commit bddde30be4
6 files changed
+850 -7

No files matched your search

+1
View File
@@ -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>;
}
+153 -4
View File
@@ -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) {
+31 -1
View File
@@ -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;
}
+7 -1
View File
@@ -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",