Files
factorio-clusterio/packages/web_ui/src/components/ModsPage.tsx
T
bbassieandClaude Opus 4.8 bb4c82ea5a Make all table search live and only write the URL on close
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>
2026-07-02 00:14:32 +00:00

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>;
}