Files
factorio-clusterio/packages/web_ui/src/components/ModsPage.tsx
T
2026-01-22 00:37:34 +00:00

671 lines
19 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 ModDetails from "./ModDetails";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
import InputPartialVersion from "./InputPartialVersion";
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 navigate = useNavigate();
let [mods] = useMods();
let [modPacks] = useModPacks();
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",
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: "mods",
render: (_, modPack) => modPack.mods.size,
},
]}
dataSource={[...modPacks.values()]}
pagination={false}
rowKey={modPack => Number(modPack.id)}
onRow={(modPack, rowIndex) => ({
onClick: event => {
navigate(`/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",
defaultSortOrder: "ascend",
sorter: (a, b) => (
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
),
},
{
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),
},
{
title: "Size",
key: "size",
responsive: ["lg"],
render: (_, mod) => lib.formatBytes(mod.size),
align: "right",
sorter: (a, b) => a.size - b.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={false}
rowKey={mod => mod.id}
/>
</Upload.Dragger>
<PluginExtra component="ModsPage" />
</PageLayout>;
}