mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 07:24:52 +09:00
The instance picker on the users page and the search box on the icon reference page held their value in component state, so leaving the page and coming back with the browser history reset them. Both now go through useTableQueryState like the table filters do, as user.instance and icon.search. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
6.1 KiB
TypeScript
206 lines
6.1 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Card, Input, Space, Table, Tag, Typography } from "antd";
|
|
|
|
import { useExportPrototypeMetadata } from "../model/export_prototype_metadata";
|
|
import PageHeader from "./PageHeader";
|
|
import PageLayout from "./PageLayout";
|
|
import FactorioIcon from "./FactorioIcon";
|
|
import { useParams } from "react-router-dom";
|
|
import { useModPack } from "../model/mod_pack";
|
|
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
|
|
import useResourceState from "../util/useResourceState";
|
|
import useTableQueryState from "../util/useTableQueryState";
|
|
import ResourceState from "./ResourceState";
|
|
|
|
const { Text } = Typography;
|
|
|
|
function filterPrototypes(
|
|
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
|
|
filter: string
|
|
) {
|
|
const query = filter.trim().toLowerCase();
|
|
return new Map(
|
|
[...prototypes ?? []].map(([baseType, entries]) => [
|
|
baseType,
|
|
new Map([...entries.entries()].filter(([name, entry]) => (
|
|
`${entry.base_type}.${name}`.includes(query)
|
|
|| `${entry.type}.${name}`.includes(query)
|
|
))),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function countPrototypes(
|
|
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
|
|
) {
|
|
return prototypes ? [...prototypes.values()].reduce((a, e) => a + e.size, 0) : 0;
|
|
}
|
|
|
|
export default function IconReferencePage() {
|
|
let params = useParams();
|
|
let modPackId = Number(params.id);
|
|
|
|
// Filters live as you type, written to the URL on blur, enter or clear
|
|
const queryState = useTableQueryState({ namespace: "icon" });
|
|
const filter = queryState.filteredValue("search")?.[0] ?? "";
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
|
const [modPack, synced, error] = useModPack(modPackId);
|
|
const state = useResourceState(modPack, synced, error, modPackId);
|
|
const manifest = modPack?.exportManifest;
|
|
const prototypes = useExportPrototypeMetadata(modPack);
|
|
const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]);
|
|
const groups = useMemo(() => (
|
|
[...filtered]
|
|
.map(([baseType, entries]) => ({ baseType, entries: [...entries.values()] }))
|
|
.filter(group => group.entries.length)
|
|
), [filtered]);
|
|
|
|
const visibleCount = countPrototypes(filtered);
|
|
const totalCount = countPrototypes(prototypes);
|
|
const autoExpand = visibleCount <= 20;
|
|
|
|
const isExpanded = (category: string) => autoExpand || expandedGroups.has(category);
|
|
|
|
const toggleGroup = (category: string) => {
|
|
setExpandedGroups(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(category)) {
|
|
next.delete(category);
|
|
} else {
|
|
next.add(category);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: "Icon",
|
|
key: "icon",
|
|
width: 48,
|
|
render: (prototype: PrototypeMetadataEntry) => (
|
|
<FactorioIcon modPackId={modPackId} prototype={prototype}/>
|
|
),
|
|
},
|
|
{
|
|
title: "Name",
|
|
dataIndex: "name",
|
|
key: "name",
|
|
render: (name: string) => (
|
|
<Text style={{ fontSize: 12 }}>{name}</Text>
|
|
),
|
|
},
|
|
{
|
|
title: "Path",
|
|
dataIndex: ["icon", "path"],
|
|
key: "path",
|
|
render: (p: string | undefined) => (p
|
|
? <Text type="secondary" style={{ fontSize: 11 }}>{p}</Text>
|
|
: null),
|
|
},
|
|
];
|
|
|
|
let nav = [
|
|
{ name: "Mods", path: "/mods" },
|
|
{ name: "Mod Packs" },
|
|
{ name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` },
|
|
{ name: "Icon Reference" },
|
|
];
|
|
if (!modPack) {
|
|
return <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
|
|
}
|
|
|
|
return (
|
|
<PageLayout nav={nav}>
|
|
<PageHeader title="Icon Reference" />
|
|
<div style={{ width: "80%", margin: "0 auto" }}>
|
|
{!modPack?.exportManifest && (
|
|
<Card>
|
|
<Text type="secondary">
|
|
No export data available. Run export-data on an instance to generate icons.
|
|
</Text>
|
|
</Card>
|
|
)}
|
|
{manifest && manifest.exportedAt && (
|
|
<div style={{ marginBottom: 12, fontSize: 12, lineHeight: 1.8 }}>
|
|
{manifest.exportedAt && (
|
|
<div>
|
|
<Text type="secondary">Exported: </Text>
|
|
<Text>{new Date(manifest.exportedAt).toLocaleString()}</Text>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<Text type="secondary">Prototypes: </Text>
|
|
<Text>{totalCount}</Text>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Card>
|
|
<Space style={{ marginBottom: 16, display: "flex", alignItems: "center" }}>
|
|
<Input
|
|
placeholder="Filter by type or name…"
|
|
value={filter}
|
|
onChange={e => {
|
|
const values = e.target.value ? [e.target.value] : null;
|
|
queryState.setFilter("search", values);
|
|
if (!values) {
|
|
queryState.commitFilter("search", null);
|
|
}
|
|
}}
|
|
onBlur={() => queryState.commitFilter("search")}
|
|
onPressEnter={() => queryState.commitFilter("search")}
|
|
allowClear
|
|
style={{ width: 320 }}
|
|
/>
|
|
<Text type="secondary" style={{ fontSize: 12, whiteSpace: "nowrap" }}>
|
|
{visibleCount === totalCount
|
|
? `${totalCount} icons`
|
|
: `${visibleCount} / ${totalCount} icons`}
|
|
</Text>
|
|
</Space>
|
|
{groups.map(({ baseType, entries }) => {
|
|
const expanded = isExpanded(baseType);
|
|
return (
|
|
<div key={baseType} style={{ marginBottom: 8 }}>
|
|
<div
|
|
onClick={() => toggleGroup(baseType)}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "6px 12px",
|
|
background: "#1d1d1d",
|
|
border: "1px solid #333",
|
|
borderRadius: expanded ? "4px 4px 0 0" : 4,
|
|
cursor: "pointer",
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 10, color: "#888", width: 12 }}>
|
|
{expanded ? "▾" : "►"}
|
|
</span>
|
|
<Tag style={{ margin: 0 }}>{baseType}</Tag>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{entries.length}
|
|
</Text>
|
|
</div>
|
|
{expanded && (
|
|
<Table<PrototypeMetadataEntry>
|
|
columns={columns}
|
|
dataSource={entries}
|
|
rowKey="key"
|
|
size="small"
|
|
showHeader={false}
|
|
pagination={false}
|
|
style={{ borderTop: "none" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</Card>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|