mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
Add a live filter override to useTableQueryState: setFilter updates the filter immediately (so every table's search filters as you type) without touching the URL, and commitFilter writes it to the URL when the filter dropdown closes. useColumnSearch and useUserFilter now filter live via setFilter and commit on close, so the Users table no longer rewrites the URL on every keystroke and the other tables gain live search. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
696 lines
20 KiB
TypeScript
696 lines
20 KiB
TypeScript
import React, { Fragment, useState, useContext, useEffect, useMemo } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, notification,
|
|
} from "antd";
|
|
import {
|
|
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
|
|
} from "@ant-design/icons";
|
|
import * as lib from "@clusterio/lib";
|
|
|
|
import { useAccount } from "../model/account";
|
|
import { useMods } from "../model/mods";
|
|
import { useModPacks } from "../model/mod_pack";
|
|
import { notifyErrorHandler } from "../util/notify";
|
|
import ControlContext from "./ControlContext";
|
|
import PageHeader from "./PageHeader";
|
|
import PageLayout from "./PageLayout";
|
|
import PluginExtra from "./PluginExtra";
|
|
import SectionHeader from "./SectionHeader";
|
|
import useTableQueryState from "../util/useTableQueryState";
|
|
import useColumnSearch from "../util/useColumnSearch";
|
|
import useRowNavigation from "../util/useRowNavigation";
|
|
import Link from "./Link";
|
|
import ModDetails from "./ModDetails";
|
|
import { Dropzone } from "./Dropzone";
|
|
import UploadButton from "./UploadButton";
|
|
import { InputPartialVersion } from "./InputVersion";
|
|
|
|
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
|
|
|
// Type alias for the mod structure derived from the library response
|
|
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
|
|
|
|
function ImportModPackButton() {
|
|
let control = useContext(ControlContext);
|
|
let navigate = useNavigate();
|
|
let [open, setOpen] = useState(false);
|
|
let [form] = Form.useForm();
|
|
function close() {
|
|
setOpen(false);
|
|
}
|
|
return <>
|
|
<Button icon={<ImportOutlined />} onClick={() => { setOpen(true); }}>Import string</Button>
|
|
<Modal
|
|
title="Import Mod Pack String"
|
|
open={open}
|
|
okText="Import"
|
|
cancelText="Cancel"
|
|
onCancel={() => { setOpen(false); }}
|
|
onOk={() => {
|
|
(async () => {
|
|
let values;
|
|
try {
|
|
values = await form.validateFields();
|
|
} catch {
|
|
return; // Validation failed
|
|
}
|
|
const modPack = lib.ModPack.fromModPackString(values.string);
|
|
await control.send(new lib.ModPackCreateRequest(modPack));
|
|
navigate(`/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) {
|
|
lib.ModPack.fromModPackString(value);
|
|
}
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<Input.TextArea autoSize={{ minRows: 6, maxRows: 6 }} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</>;
|
|
}
|
|
|
|
function CreateModPackButton() {
|
|
let control = useContext(ControlContext);
|
|
let navigate = useNavigate();
|
|
let [open, setOpen] = useState(false);
|
|
let [form] = Form.useForm();
|
|
function close() {
|
|
setOpen(false);
|
|
}
|
|
return <>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setOpen(true); }}>Create</Button>
|
|
<Modal
|
|
title="Create Mod Pack"
|
|
open={open}
|
|
okText="Create"
|
|
cancelText="Cancel"
|
|
onCancel={() => { setOpen(false); }}
|
|
onOk={() => {
|
|
(async () => {
|
|
let values;
|
|
try {
|
|
values = await form.validateFields();
|
|
} catch {
|
|
return; // Validation failed
|
|
}
|
|
const modPack = lib.ModPack.fromJSON({
|
|
name: values.name,
|
|
description: values.description,
|
|
factorio_version: values.factorioVersion,
|
|
} as any);
|
|
await control.send(new lib.ModPackCreateRequest(modPack));
|
|
navigate(`/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.",
|
|
}]}
|
|
>
|
|
<InputPartialVersion
|
|
value={form.getFieldValue("factorioVersion")}
|
|
onChange={(value) => form.setFieldValue("factorioVersion", value)}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</>;
|
|
}
|
|
|
|
function SearchModsButton() {
|
|
const control = useContext(ControlContext);
|
|
const account = useAccount();
|
|
const [open, setOpen] = useState(false);
|
|
const [form] = Form.useForm();
|
|
const [searchText, setSearchText] = useState("");
|
|
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion>("2.0");
|
|
|
|
// State for all mods fetched from backend
|
|
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
// State for client-side pagination and sorting
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(10);
|
|
const [sort, setSort] = useState<string>("name");
|
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
|
|
// Function to handle download from Mod Portal to Controller
|
|
const handleControllerDownload = (
|
|
modName: string,
|
|
modTitle: string | undefined,
|
|
modVersion: lib.FullVersion | undefined,
|
|
portalFactorioVersion: lib.ApiVersion,
|
|
) => {
|
|
if (modVersion) {
|
|
control.send(
|
|
new lib.ModPortalDownloadRequest(
|
|
[{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}],
|
|
portalFactorioVersion
|
|
)
|
|
).then(() => {
|
|
notification.success({
|
|
message: "Download complete",
|
|
description: `${modTitle || modName} v${
|
|
modVersion
|
|
} has been downloaded to the controller.`,
|
|
});
|
|
}).catch(
|
|
notifyErrorHandler("Error starting mod download")
|
|
);
|
|
}
|
|
};
|
|
|
|
// Fetch all mods from backend when modal opens or version changes
|
|
useEffect(() => {
|
|
if (!open || !factorioVersion) {
|
|
setAllMods([]); // Clear mods if modal closed or no version
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
let canceled = false;
|
|
|
|
control.send(
|
|
new lib.ModPortalGetAllRequest(factorioVersion)
|
|
).then((response: any) => {
|
|
if (canceled) { return; }
|
|
setAllMods(response.mods || []);
|
|
setLoading(false);
|
|
}).catch(err => {
|
|
if (canceled) { return; }
|
|
notifyErrorHandler("Error fetching mods from portal")(err);
|
|
setError(err);
|
|
setAllMods([]);
|
|
setLoading(false);
|
|
});
|
|
|
|
// eslint-disable-next-line consistent-return
|
|
return () => {
|
|
canceled = true;
|
|
};
|
|
// Re-fetch when modal opens or factorio version changes
|
|
}, [open, factorioVersion, control]);
|
|
|
|
|
|
// Memoize the filtered, sorted, and paginated mods (logic remains the same)
|
|
const displayedMods = useMemo(() => {
|
|
let filtered = allMods;
|
|
|
|
// Apply search filter (case-insensitive)
|
|
if (searchText) {
|
|
const lowerSearchText = searchText.toLowerCase();
|
|
filtered = filtered.filter(mod => mod.name.toLowerCase().includes(lowerSearchText)
|
|
|| mod.title.toLowerCase().includes(lowerSearchText)
|
|
);
|
|
}
|
|
|
|
// Apply sorting
|
|
if (sort && sortOrder) {
|
|
filtered = [...filtered].sort((a, b) => {
|
|
let aValue: any = a[sort as keyof ModPortalModType] ?? "";
|
|
let bValue: any = b[sort as keyof ModPortalModType] ?? "";
|
|
|
|
// Handle specific types if necessary (e.g., numbers, dates)
|
|
if (sort === "downloads_count") {
|
|
aValue = a.downloads_count ?? 0;
|
|
bValue = b.downloads_count ?? 0;
|
|
return sortOrder === "asc" ? aValue - bValue : bValue - aValue;
|
|
}
|
|
|
|
// Default string comparison
|
|
const comparison = strcmp(String(aValue), String(bValue));
|
|
return sortOrder === "asc" ? comparison : -comparison;
|
|
});
|
|
}
|
|
|
|
// Apply pagination
|
|
const startIndex = (page - 1) * pageSize;
|
|
return filtered.slice(startIndex, startIndex + pageSize);
|
|
|
|
}, [allMods, searchText, sort, sortOrder, page, pageSize]);
|
|
|
|
// Calculate total results based on the filtered list *before* pagination (logic remains the same)
|
|
const totalResults = useMemo(() => {
|
|
let filtered = allMods;
|
|
if (searchText) {
|
|
const lowerSearchText = searchText.toLowerCase();
|
|
filtered = filtered.filter(mod => mod.name.toLowerCase().includes(lowerSearchText)
|
|
|| mod.title.toLowerCase().includes(lowerSearchText)
|
|
);
|
|
}
|
|
return filtered.length;
|
|
}, [allMods, searchText]);
|
|
|
|
// Update search text and factorio version from form
|
|
const handleSearch = (changedValues: any, allValues: any) => {
|
|
const nameValue = allValues.name;
|
|
const versionValue = allValues.factorioVersion as lib.ApiVersion | undefined;
|
|
|
|
// Only trigger state updates if values actually changed
|
|
if (nameValue !== searchText) {
|
|
setSearchText(nameValue || "");
|
|
}
|
|
if (versionValue && versionValue !== factorioVersion) {
|
|
setFactorioVersion(versionValue);
|
|
setAllMods([]); // Clear mods when version changes, useEffect will fetch new ones
|
|
setLoading(true); // Show loading indicator immediately
|
|
}
|
|
// Reset page only if search text or version changed
|
|
if (nameValue !== searchText || (versionValue && versionValue !== factorioVersion)) {
|
|
setPage(1);
|
|
}
|
|
};
|
|
|
|
// Update pagination and sorting state when table changes (logic remains the same)
|
|
const handleTableChange = (
|
|
pagination: any,
|
|
filters: any,
|
|
sorter: any
|
|
) => {
|
|
if (pagination.current !== page) {
|
|
setPage(pagination.current);
|
|
}
|
|
if (pagination.pageSize !== pageSize) {
|
|
setPageSize(pagination.pageSize);
|
|
setPage(1); // Reset to first page if page size changes
|
|
}
|
|
|
|
// Update sort state if sorter exists and has changed
|
|
if (sorter.field && (sorter.field !== sort || sorter.order !== (sortOrder === "asc" ? "ascend" : "descend"))) {
|
|
setSort(sorter.field);
|
|
setSortOrder(sorter.order === "ascend" ? "asc" : "desc");
|
|
setPage(1); // Reset to first page on sort change
|
|
} else if (!sorter.field && sort !== "name") { // Reset only if not already default
|
|
// Clear sort if column header clicked without order - default to name ascending
|
|
setSort("name");
|
|
setSortOrder("asc");
|
|
setPage(1);
|
|
}
|
|
};
|
|
|
|
// Helper to get antd sort order format (logic remains the same)
|
|
const getColumnSortOrder = (columnKey: string): "ascend" | "descend" | undefined => {
|
|
if (sort === columnKey) {
|
|
return sortOrder === "asc" ? "ascend" : "descend";
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
return <>
|
|
<Button icon={<SearchOutlined />} onClick={() => { setOpen(true); }}>Search</Button>
|
|
<Modal
|
|
title="Search Mod Portal"
|
|
open={open}
|
|
onCancel={() => { setOpen(false); }}
|
|
width={1000}
|
|
footer={[
|
|
<Button key="close" onClick={() => { setOpen(false); }}>
|
|
Close
|
|
</Button>,
|
|
]}
|
|
>
|
|
<Form
|
|
form={form}
|
|
layout="horizontal"
|
|
labelCol={{ span: 6 }}
|
|
wrapperCol={{ span: 18 }}
|
|
onValuesChange={handleSearch}
|
|
initialValues={{ factorioVersion: factorioVersion, name: searchText }}
|
|
>
|
|
<Form.Item name="name" label="Filter by Name or Title">
|
|
<Input placeholder="Start typing to filter..." />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="factorioVersion"
|
|
label="Factorio Version"
|
|
>
|
|
<Select>
|
|
{lib.ApiVersions.map(version => (
|
|
<Select.Option key={version} value={version}>
|
|
{version}
|
|
</Select.Option>
|
|
)).reverse()}
|
|
</Select>
|
|
</Form.Item>
|
|
</Form>
|
|
|
|
{/* Display error message if fetching failed */}
|
|
{error && <Alert
|
|
message="Error Fetching Mods"
|
|
description={error.message}
|
|
type="error"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>}
|
|
|
|
<Table
|
|
dataSource={displayedMods}
|
|
rowKey={record => record.name}
|
|
loading={loading}
|
|
onChange={handleTableChange}
|
|
pagination={{
|
|
current: page,
|
|
pageSize: pageSize,
|
|
total: totalResults,
|
|
showSizeChanger: true,
|
|
pageSizeOptions: ["10", "20", "50", "100"],
|
|
}}
|
|
expandable={{
|
|
expandedRowRender: record => (
|
|
<div>
|
|
<p><strong>Summary:</strong> {record.summary ?? "N/A"}</p>
|
|
<p><strong>Downloads:</strong> {record.downloads_count?.toLocaleString() ?? "N/A"}</p>
|
|
<p><strong>Latest Release:</strong></p>
|
|
{record.latest_release ? (
|
|
<ul>
|
|
<li>Version: {record.latest_release.version ?? "N/A"}</li>
|
|
<li>
|
|
Factorio Version: {
|
|
record.latest_release.info_json?.factorio_version ?? "N/A"
|
|
}
|
|
</li>
|
|
<li>
|
|
Released: {
|
|
record.latest_release.released_at
|
|
? new Date(record.latest_release.released_at).toLocaleString()
|
|
: "N/A"
|
|
}
|
|
</li>
|
|
</ul>
|
|
) : <p>No release information available.</p>}
|
|
{account.hasPermission("core.mod.download_from_portal") && record.latest_release && (
|
|
<Button
|
|
onClick={() => {
|
|
handleControllerDownload(
|
|
record.name,
|
|
record.title,
|
|
record.latest_release!.version,
|
|
factorioVersion
|
|
);
|
|
}}
|
|
disabled={!record.latest_release?.version}
|
|
>
|
|
Download Latest Version
|
|
</Button>
|
|
)}
|
|
</div>
|
|
),
|
|
}}
|
|
columns={[
|
|
// Columns remain largely the same
|
|
{
|
|
title: "Name",
|
|
dataIndex: "name",
|
|
key: "name",
|
|
sorter: true,
|
|
sortOrder: getColumnSortOrder("name"),
|
|
},
|
|
{
|
|
title: "Title",
|
|
dataIndex: "title",
|
|
key: "title",
|
|
sorter: true,
|
|
sortOrder: getColumnSortOrder("title"),
|
|
},
|
|
{
|
|
title: "Author",
|
|
dataIndex: "owner",
|
|
key: "owner",
|
|
sorter: true,
|
|
sortOrder: getColumnSortOrder("owner"),
|
|
},
|
|
{
|
|
title: "Downloads",
|
|
dataIndex: "downloads_count",
|
|
key: "downloads_count",
|
|
sorter: true,
|
|
sortOrder: getColumnSortOrder("downloads_count"),
|
|
align: "right",
|
|
render: (count) => count?.toLocaleString() ?? "N/A",
|
|
},
|
|
{
|
|
title: "Latest Version",
|
|
key: "version",
|
|
render: (_, record) => record.latest_release?.version ?? "N/A",
|
|
},
|
|
{
|
|
title: "Actions",
|
|
key: "actions",
|
|
align: "center",
|
|
render: (_, record) => (
|
|
<Space>
|
|
<Tooltip title="Open in Factorio Mod Portal">
|
|
<a
|
|
href={`https://mods.factorio.com/mod/${record.name}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<Button type="text">
|
|
<img
|
|
src="https://mods.factorio.com/static/favicon.ico"
|
|
alt="Factorio Mod Portal"
|
|
/>
|
|
</Button>
|
|
</a>
|
|
</Tooltip>
|
|
{account.hasPermission("core.mod.download_from_portal") && record.latest_release && (
|
|
<Tooltip title="Download to Controller">
|
|
<Button
|
|
type="text"
|
|
icon={<DownloadOutlined />}
|
|
disabled={!record.latest_release.version}
|
|
onClick={() => {
|
|
handleControllerDownload(
|
|
record.name,
|
|
record.title,
|
|
record.latest_release!.version,
|
|
factorioVersion
|
|
);
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</Modal>
|
|
</>;
|
|
}
|
|
|
|
export default function ModsPage() {
|
|
let account = useAccount();
|
|
let control = useContext(ControlContext);
|
|
let [mods] = useMods();
|
|
let [modPacks] = useModPacks();
|
|
const modPackTable = useTableQueryState<lib.ModPack>({
|
|
namespace: "modPack", defaultSortKey: "name",
|
|
});
|
|
const modTable = useTableQueryState<lib.ModInfo>({
|
|
namespace: "mod", defaultSortKey: "title",
|
|
});
|
|
const modPackNameSearch = useColumnSearch<lib.ModPack>(
|
|
modPackTable, "name", modPack => modPack.name, "Search mod packs"
|
|
);
|
|
const modNameSearch = useColumnSearch<lib.ModInfo>(modTable, "title", mod => mod.title, "Search mods");
|
|
const rowNav = useRowNavigation();
|
|
|
|
function actions(mod: lib.ModInfo) {
|
|
return <Space>
|
|
{account.hasPermission("core.mod.download")
|
|
&& <Typography.Link
|
|
onClick={() => {
|
|
control.send(
|
|
new lib.ModDownloadRequest(mod.name, mod.version)
|
|
).then((streamId: string) => {
|
|
let url = new URL(webRoot, document.location.origin);
|
|
url.pathname += `api/stream/${streamId}`;
|
|
document.location = url.href;
|
|
}).catch(
|
|
notifyErrorHandler("Error downloading save")
|
|
);
|
|
}}
|
|
>download</Typography.Link>
|
|
}
|
|
{account.hasPermission("core.mod.delete")
|
|
&& <Popconfirm
|
|
title={`Delete ${mod.filename}?`}
|
|
onConfirm={event => {
|
|
control.send(
|
|
new lib.ModDeleteRequest(mod.name, mod.version)
|
|
).catch(notifyErrorHandler("Error deleting mod"));
|
|
}}
|
|
okText="Delete"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
<Typography.Link type="danger">delete</Typography.Link>
|
|
</Popconfirm>
|
|
}
|
|
</Space>;
|
|
}
|
|
|
|
let uploadButton;
|
|
if (account.hasPermission("core.mod.upload")) {
|
|
uploadButton = <Upload
|
|
name="file"
|
|
accept=".zip"
|
|
multiple
|
|
showUploadList={false}
|
|
headers={{
|
|
"X-Access-Token": control.connector.token || "",
|
|
}}
|
|
action={`${webRoot}api/upload-mod`}
|
|
>
|
|
<UploadButton />
|
|
</Upload>;
|
|
}
|
|
|
|
return <PageLayout nav={[{ name: "Mods" }]}>
|
|
<PageHeader title="Mods" />
|
|
<SectionHeader
|
|
title="Mod Packs"
|
|
extra={<Space wrap>
|
|
{account.hasPermission("core.mod_pack.create") && <ImportModPackButton />}
|
|
{account.hasPermission("core.mod_pack.create") && <CreateModPackButton />}
|
|
</Space>}
|
|
/>
|
|
<Table
|
|
columns={[
|
|
{
|
|
title: "Name",
|
|
dataIndex: "name",
|
|
sorter: (a, b) => strcmp(a.name, b.name),
|
|
sortOrder: modPackTable.sortOrder("name"),
|
|
filteredValue: modPackTable.filteredValue("name"),
|
|
className: "table-link-cell",
|
|
render: (_, modPack) => <Link
|
|
to={`/mods/mod-packs/${modPack.id}/view`}
|
|
style={{ color: "inherit" }}
|
|
>
|
|
{modPack.name}
|
|
</Link>,
|
|
...modPackNameSearch,
|
|
},
|
|
{
|
|
title: "Factorio Version",
|
|
dataIndex: "factorioVersion",
|
|
sorter: (a, b) => a.integerFactorioVersion - b.integerFactorioVersion,
|
|
sortOrder: modPackTable.sortOrder("factorioVersion"),
|
|
},
|
|
{
|
|
title: "Mods",
|
|
key: "mods",
|
|
render: (_, modPack) => modPack.mods.size,
|
|
},
|
|
]}
|
|
dataSource={[...modPacks.values()]}
|
|
pagination={modPackTable.pagination}
|
|
onChange={modPackTable.onChange}
|
|
rowKey={modPack => Number(modPack.id)}
|
|
onRow={modPack => rowNav(`/mods/mod-packs/${modPack.id}/view`)}
|
|
/>
|
|
<SectionHeader title="Stored Mods" extra={<Space wrap>
|
|
<SearchModsButton />
|
|
{uploadButton}
|
|
</Space>} />
|
|
|
|
<Upload.Dragger
|
|
className="save-list-dragger"
|
|
openFileDialogOnClick={false}
|
|
name="file"
|
|
accept=".zip"
|
|
multiple
|
|
headers={{
|
|
"X-Access-Token": control.connector.token || "",
|
|
}}
|
|
action={`${webRoot}api/upload-mod`}
|
|
showUploadList={false}
|
|
>
|
|
<Dropzone />
|
|
<Table
|
|
columns={[
|
|
{
|
|
title: "Name",
|
|
dataIndex: "title",
|
|
sorter: (a, b) => (
|
|
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
|
|
),
|
|
sortOrder: modTable.sortOrder("title"),
|
|
filteredValue: modTable.filteredValue("title"),
|
|
...modNameSearch,
|
|
},
|
|
{
|
|
title: "Version",
|
|
key: "version",
|
|
align: "right",
|
|
render: (_, mod) => <>
|
|
{`${mod.version} `}
|
|
<Typography.Text type="secondary">{`/ ${mod.factorioVersion}`}</Typography.Text>
|
|
</>,
|
|
},
|
|
{
|
|
title: "Filename",
|
|
dataIndex: "filename",
|
|
responsive: ["xl"],
|
|
sorter: (a, b) => strcmp(a.filename, b.filename),
|
|
sortOrder: modTable.sortOrder("filename"),
|
|
},
|
|
{
|
|
title: "Size",
|
|
key: "size",
|
|
responsive: ["lg"],
|
|
render: (_, mod) => lib.formatBytes(mod.size),
|
|
align: "right",
|
|
sorter: (a, b) => a.size - b.size,
|
|
sortOrder: modTable.sortOrder("size"),
|
|
},
|
|
{
|
|
title: "Action",
|
|
key: "action",
|
|
responsive: ["lg"],
|
|
render: (_, mod) => actions(mod),
|
|
},
|
|
]}
|
|
expandable={{
|
|
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
|
|
expandedRowClassName: () => "no-expanded-padding",
|
|
}}
|
|
dataSource={[...mods.values()]}
|
|
pagination={modTable.pagination}
|
|
onChange={modTable.onChange}
|
|
rowKey={mod => mod.id}
|
|
/>
|
|
</Upload.Dragger>
|
|
<PluginExtra component="ModsPage" />
|
|
</PageLayout>;
|
|
}
|