Refactor web_ui handling of exports

The handling of exported data such as locales and icons in web_ui was
based on a model where there is only one set of data exported per
cluster.  This model changed to have one set of exports per mod pack
when mod packs were introduced, but the existing web_ui code was
bodged to use exports from a "default mod pack" on the controller.

Refactor the code loading exported data to take the mod pack as an
argument for selecting which exported data to load.  The different kinds
of prototype metadata that can be loaded is consolidated into one
`useExportPrototypeMetadata` hook.  The logic for displaying icons is
put into a `FactorioIcon` component.  And the obsolete
`useExportManifest` hook is removed.

These changes allow the `/icon-reference` page to display exports on a
per mod pack basis.  Which has now been linked on the bottom of the mod
pack view page.
This commit is contained in:
Hornwitser committed 2026-03-16 17:11:47 +01:00
1 parent 4b85367d4e
commit 0d28a3ac3c
18 files changed
+391 -300

No files matched your search

+1 -1
View File
@@ -696,12 +696,12 @@ export const webRoutes = [
"/instances/:id/view",
"/mods",
"/mods/mod-packs/:id/view",
"/mods/mod-packs/:id/icon-reference",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/icon-reference",
"/error",
];
@@ -0,0 +1,11 @@
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
export default function FactorioIcon(props: { modPackId: number | undefined, prototype?: PrototypeMetadataEntry }) {
return <span
className="factorio-icon"
data-mod-pack={props.modPackId}
data-type={props.prototype?.base_type}
data-name={props.prototype?.name}
data-unknown={props.prototype ? undefined : "true"}
/>;
}
@@ -1,117 +1,56 @@
import React, { useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Card, Input, Space, Table, Tag, Typography } from "antd";
import {
useItemMetadata,
useRecipeMetadata,
useVirtualSignalMetadata,
useTechnologyMetadata,
useSpaceLocationMetadata,
useQualityMetadata,
useEntityMetadata,
useFluidMetadata,
} from "../model/item_metadata";
import { useExportManifest } from "../model/export_manifest";
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";
const { Text } = Typography;
type IconEntry = {
key: string;
category: string;
name: string;
cssClass: string;
path?: string;
};
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)
))),
]),
);
}
const CATEGORIES = [
"item", "fluid", "recipe", "virtual-signal", "technology", "space-location", "quality", "entity",
] as const;
type Category = typeof CATEGORIES[number];
const CATEGORY_LABELS: Record<Category, string> = {
item: "Item",
fluid: "Fluid",
recipe: "Recipe",
"virtual-signal": "Virtual Signal",
technology: "Technology",
"space-location": "Space Location",
quality: "Quality",
entity: "Entity",
};
function useAllIconEntries() {
const item = useItemMetadata();
const fluid = useFluidMetadata();
const recipe = useRecipeMetadata();
const virtualSignal = useVirtualSignalMetadata();
const technology = useTechnologyMetadata();
const spaceLocation = useSpaceLocationMetadata();
const quality = useQualityMetadata();
const entity = useEntityMetadata();
const byCategory: Record<Category, Map<string, { icon?: { path?: string }}>> = {
item,
fluid,
recipe,
"virtual-signal": virtualSignal,
technology,
"space-location": spaceLocation,
quality,
entity,
};
return useMemo(() => {
const entries: IconEntry[] = [];
for (const category of CATEGORIES) {
const names = [...byCategory[category].keys()].sort();
for (const name of names) {
const meta = byCategory[category].get(name)!;
entries.push({
key: `${category}:${name}`,
category,
name,
cssClass: `${category}-${CSS.escape(name)}`,
path: meta.icon?.path,
});
}
}
return entries;
}, [item, fluid, recipe, virtualSignal, technology, spaceLocation, quality, entity]);
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);
const [filter, setFilter] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const allEntries = useAllIconEntries();
const [modPack] = useModPack(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 filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) {
return allEntries;
}
return allEntries.filter(e => e.name.toLowerCase().includes(q) || e.cssClass.toLowerCase().includes(q)
);
}, [allEntries, filter]);
const groups = useMemo(() => {
const map = new Map<string, IconEntry[]>();
for (const cat of CATEGORIES) {
map.set(cat, []);
}
for (const entry of filtered) {
map.get(entry.category)!.push(entry);
}
return CATEGORIES.map(cat => ({ category: cat, entries: map.get(cat)! }))
.filter(g => g.entries.length > 0);
}, [filtered]);
const visibleCount = filtered.length;
const totalCount = allEntries.length;
const visibleCount = countPrototypes(filtered);
const totalCount = countPrototypes(prototypes);
const autoExpand = visibleCount <= 20;
const isExpanded = (category: string) => autoExpand || expandedGroups.has(category);
@@ -131,11 +70,10 @@ export default function IconReferencePage() {
const columns = [
{
title: "Icon",
dataIndex: "cssClass",
key: "icon",
width: 48,
render: (_: string, record: IconEntry) => (
<div className={record.cssClass} style={{ imageRendering: "pixelated" }} />
render: (prototype: PrototypeMetadataEntry) => (
<FactorioIcon modPackId={modPackId} prototype={prototype}/>
),
},
{
@@ -146,19 +84,9 @@ export default function IconReferencePage() {
<Text style={{ fontSize: 12 }}>{name}</Text>
),
},
{
title: "Usage",
dataIndex: "cssClass",
key: "usage",
render: (cssClass: string) => (
<Text code style={{ fontSize: 11, color: "#90ee90" }}>
{`<div class="${cssClass}" />`}
</Text>
),
},
{
title: "Path",
dataIndex: "path",
dataIndex: ["icon", "path"],
key: "path",
render: (p: string | undefined) => (p
? <Text type="secondary" style={{ fontSize: 11 }}>{p}</Text>
@@ -166,13 +94,17 @@ export default function IconReferencePage() {
},
];
const manifest = useExportManifest();
let nav = [
{ name: "Mods", path: "/mods" },
{ name: "Mod Packs" },
{ name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` },
{ name: "Icon Reference" },
];
return (
<PageLayout nav={[{ name: "Icon Reference" }]}>
<PageLayout nav={nav}>
<PageHeader title="Icon Reference" />
<div style={{ width: "80%", margin: "0 auto" }}>
{!manifest && (
{!modPack?.exportManifest && (
<Card>
<Text type="secondary">
No export data available. Run export-data on an instance to generate icons.
@@ -188,7 +120,7 @@ export default function IconReferencePage() {
</div>
)}
<div>
<Text type="secondary">Icons: </Text>
<Text type="secondary">Prototypes: </Text>
<Text>{totalCount}</Text>
</div>
</div>
@@ -196,7 +128,7 @@ export default function IconReferencePage() {
<Card>
<Space style={{ marginBottom: 16, display: "flex", alignItems: "center" }}>
<Input
placeholder="Filter by name or class…"
placeholder="Filter by type or name…"
value={filter}
onChange={e => setFilter(e.target.value)}
allowClear
@@ -208,13 +140,12 @@ export default function IconReferencePage() {
: `${visibleCount} / ${totalCount} icons`}
</Text>
</Space>
{groups.map(({ category, entries }) => {
const label = CATEGORY_LABELS[category as Category] ?? category;
const expanded = isExpanded(category);
{groups.map(({ baseType, entries }) => {
const expanded = isExpanded(baseType);
return (
<div key={category} style={{ marginBottom: 8 }}>
<div key={baseType} style={{ marginBottom: 8 }}>
<div
onClick={() => toggleGroup(category)}
onClick={() => toggleGroup(baseType)}
style={{
display: "flex",
alignItems: "center",
@@ -230,13 +161,13 @@ export default function IconReferencePage() {
<span style={{ fontSize: 10, color: "#888", width: 12 }}>
{expanded ? "▾" : "►"}
</span>
<Tag style={{ margin: 0 }}>{label}</Tag>
<Tag style={{ margin: 0 }}>{baseType}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{entries.length}
</Text>
</div>
{expanded && (
<Table<IconEntry>
<Table<PrototypeMetadataEntry>
columns={columns}
dataSource={entries}
rowKey="key"
@@ -27,6 +27,7 @@ import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import InputPartialVersion from "./InputPartialVersion";
import Link from "./Link";
const { logger } = lib;
const { Text } = Typography;
@@ -1334,6 +1335,12 @@ export default function ModPackViewPage() {
prototypes={prototypes}
locale={locale}
/>
<Typography.Title level={4}>
Inspect exported data
</Typography.Title>
<Link to={`/mods/mod-packs/${modPackId}/icon-reference`}>
Icon reference
</Link>
</Form>
<div
+7 -2
View File
@@ -5,11 +5,11 @@ export { default as BaseWebPlugin } from "./BaseWebPlugin";
export * from "./util/websocket";
export * from "./model/account";
export * from "./model/export_locale";
export * from "./model/export_prototype_metadata";
export * from "./model/host";
export * from "./model/instance";
export * from "./model/is_dragging";
export * from "./model/locale";
export * from "./model/item_metadata";
export * from "./model/mod_pack";
export * from "./model/mods";
export * from "./model/roles";
@@ -17,6 +17,9 @@ export * from "./model/saves";
export * from "./model/system";
export * from "./model/user";
export * from "./store/export_locale_stores";
export * from "./store/export_prototype_metadata_store";
export { default as ControlContext } from "./components/ControlContext";
export { default as BaseConfigTree } from "./components/BaseConfigTree";
@@ -37,3 +40,5 @@ export { default as SavesList } from "./components/SavesList";
export { default as SectionHeader } from "./components/SectionHeader";
export { default as Link } from "./components/Link";
export { default as FactorioIcon } from "./components/FactorioIcon";
@@ -0,0 +1,13 @@
import type { ModPack } from "@clusterio/lib";
import { useCallback, useSyncExternalStore } from "react";
import { exportLocaleStore } from "../store/export_locale_stores";
export function useExportLocale(modPack: ModPack | undefined) {
const localePath = modPack?.exportManifest?.assets["locale"];
const subscribe = useCallback(
(callback: () => void) => exportLocaleStore.subscribe(modPack?.id, localePath, callback),
[modPack?.id, localePath],
);
return useSyncExternalStore(subscribe, () => exportLocaleStore.getSnapshot(modPack?.id));
}
@@ -1,27 +0,0 @@
import { useEffect, useContext, useState } from "react";
import * as lib from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
let exportManifestCache: lib.ExportManifest|null = null;
let loaded = false;
export function useExportManifest(): lib.ExportManifest|null {
const control = useContext(ControlContext);
let [exportManifest, setExportManifest] = useState<lib.ExportManifest|null>(exportManifestCache);
useEffect(() => {
async function load() {
let modPack = await control.send(new lib.ModPackGetDefaultRequest());
if (modPack.exportManifest) {
exportManifestCache = modPack.exportManifest;
setExportManifest(exportManifestCache);
}
loaded = true;
}
if (!loaded) {
load();
}
}, []);
return exportManifest;
}
@@ -0,0 +1,17 @@
import { useCallback, useSyncExternalStore } from "react";
import { ModPack } from "@clusterio/lib";
import { exportPrototypeMetadataStore } from "../store/export_prototype_metadata_store";
/**
* Load the unified spritesheet metadata and inject CSS rules for every icon.
* Required for {@link FactorioIcon}.
*/
export function useExportPrototypeMetadata(modPack: ModPack | undefined) {
const subscribe = useCallback(
(callback: () => void) => exportPrototypeMetadataStore.subscribe(modPack, callback),
[modPack]
);
return useSyncExternalStore(subscribe, () => exportPrototypeMetadataStore.getSnapshot(modPack?.id));
}
@@ -1,85 +0,0 @@
import { useEffect, useState } from "react";
import { useExportManifest } from "./export_manifest";
import { ExportMetadata, ExportMetadataEntry } from "@clusterio/lib";
const metadataCaches = new Map<string, Map<string, ExportMetadataEntry>>();
let cssInjected = false;
/**
* Load the unified spritesheet metadata and inject CSS rules for every icon.
* CSS class name: `.{category}-{CSS.escape(name)}`
* e.g. `.item-iron-plate`, `.recipe-iron-plate`, `.signal-signal-red`, `.planet-nauvis`
*/
function useSpriteMetadata(category: string): Map<string, ExportMetadataEntry> {
const exportManifest = useExportManifest();
const [metadata, setMetadata] = useState<Map<string, ExportMetadataEntry>>(
metadataCaches.get(category) ?? new Map()
);
useEffect(() => {
async function load() {
if (
!exportManifest
|| !exportManifest.assets["metadata"]
|| !exportManifest.assets["spritesheet"]
) {
return;
}
const response = await fetch(`${staticRoot}static/${exportManifest.assets["metadata"]}`);
if (!response.ok) {
return;
}
const data: ExportMetadata = await response.json();
if (!cssInjected) {
const sheetUrl = `${staticRoot}static/${exportManifest.assets["spritesheet"]}`;
const style = document.createElement("style");
document.head.appendChild(style);
for (const [baseName, prototypes] of Object.entries(data)) {
for (const prototype of prototypes) {
const icon = prototype.icon;
if (!icon) {
continue;
}
style.sheet!.insertRule(`\
.${baseName}-${CSS.escape(prototype.name)} {
background-image: url("${sheetUrl}");
background-repeat: no-repeat;
background-position: -${icon.x}px -${icon.y}px;
height: ${icon.size}px;
width: ${icon.size}px;
}`
);
}
}
cssInjected = true;
}
const cache = new Map<string, ExportMetadataEntry>();
for (const prototype of data[category] ?? []) {
cache.set(prototype.name, prototype);
}
metadataCaches.set(category, cache);
setMetadata(cache);
}
if (!metadataCaches.has(category)) {
load();
}
}, [exportManifest]);
return metadata;
}
export function useItemMetadata() { return useSpriteMetadata("item"); }
export function useFluidMetadata() { return useSpriteMetadata("fluid"); }
export function useRecipeMetadata() { return useSpriteMetadata("recipe"); }
export function useVirtualSignalMetadata() { return useSpriteMetadata("virtual-signal"); }
export function useTechnologyMetadata() { return useSpriteMetadata("technology"); }
export function useSpaceLocationMetadata() { return useSpriteMetadata("space-location"); }
export function useQualityMetadata() { return useSpriteMetadata("quality"); }
export function useEntityMetadata() { return useSpriteMetadata("entity"); }
-29
View File
@@ -1,29 +0,0 @@
import React, { useEffect, useState } from "react";
import { useExportManifest } from "./export_manifest";
let localeCache: Map<string, string>|null = null;
export function useLocale(): Map<string, string> {
let exportManifest = useExportManifest();
let [locale, setLocale] = useState<Map<string, string>>(localeCache || new Map());
useEffect(() => {
async function load() {
if (!exportManifest?.assets["locale"]) {
return;
}
let response = await fetch(`${staticRoot}static/${exportManifest.assets["locale"]}`);
if (response.ok) {
let data = await response.json();
localeCache = new Map(data);
setLocale(localeCache);
}
}
if (!localeCache) {
load();
}
}, [exportManifest]);
return locale;
}
+10
View File
@@ -1,5 +1,7 @@
import { useCallback, useContext, useSyncExternalStore } from "react";
import ControlContext from "../components/ControlContext";
import { defaultModPackStore } from "../store/default_mod_pack_store";
export function useModPack(id?: number) {
const [modPacks, synced] = useModPacks();
@@ -11,3 +13,11 @@ export function useModPacks() {
const subscribe = useCallback((callback: () => void) => control.modPacks.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => control.modPacks.getSnapshot());
}
export function useDefaultModPack() {
const control = useContext(ControlContext);
const subscribe = useCallback(
(callback: () => void) => defaultModPackStore.subscribe(control, callback), [control],
);
return useSyncExternalStore(subscribe, () => defaultModPackStore.getSnapshot());
}
+7 -4
View File
@@ -18,6 +18,8 @@ import PluginViewPage from "./components/PluginViewPage";
import ErrorPage from "./components/ErrorPage";
import IconReferencePage from "./components/IconReferencePage";
// When adding or editing page paths here the corresponding webRoutes entry
// in packages/controller/src/routes.ts also needs to be updated.
export const pages: PluginPage[] = [
{
path: "/controller",
@@ -57,6 +59,11 @@ export const pages: PluginPage[] = [
sidebarPath: "/mods",
content: <ModPackViewPage />,
},
{
path: "/mods/mod-packs/:id/icon-reference",
sidebarPath: "/mods",
content: <IconReferencePage />,
},
{
path: "/users",
sidebarName: "Users",
@@ -89,10 +96,6 @@ export const pages: PluginPage[] = [
sidebarPath: "/plugins",
content: <PluginViewPage />,
},
{
path: "/icon-reference",
content: <IconReferencePage />,
},
{
path: "/error",
content: <ErrorPage throw error={new Error("Example Error Message")} />,
@@ -0,0 +1,51 @@
import { ModPackGetDefaultRequest, type ModPack, isDeepStrictEqual } from "@clusterio/lib";
import type { Control } from "../util/websocket";
// TODO: replace this super duper expensive method with listening to a default mod pack id event.
class DefaultModPackStore {
refCount = 0;
interval: undefined | ReturnType<typeof setInterval>;
modPack: undefined | ModPack;
callbacks: (() => void)[] = [];
fetchDefaultModPack(control: Control) {
control.send(new ModPackGetDefaultRequest()).then(modPack => {
if (!isDeepStrictEqual(this.modPack, modPack)) {
this.modPack = modPack;
for (const callback of this.callbacks) {
callback();
}
}
}).catch(() => {
if (this.modPack !== undefined) {
this.modPack = undefined;
for (const callback of this.callbacks) {
callback();
}
}
});
}
subscribe(control: Control, callback: () => void) {
if (!this.refCount) {
const update = () => { this.fetchDefaultModPack(control); };
update();
this.interval = setInterval(update, 60e3);
}
this.callbacks.push(callback);
this.refCount += 1;
return () => {
this.callbacks.splice(this.callbacks.indexOf(callback), 1);
this.refCount -= 1;
if (!this.refCount) {
clearInterval(this.interval);
this.interval = undefined;
}
};
}
getSnapshot() {
return this.modPack;
}
};
export const defaultModPackStore = new DefaultModPackStore();
@@ -0,0 +1,61 @@
import { ExportLocale, logger } from "@clusterio/lib";
import notify from "../util/notify";
interface ExportLocaleCacheEntry {
callbacks: (() => void)[];
localePath: string | undefined;
data: Map<string, string>;
}
class ExportLocaleStore {
cache = new Map<number, ExportLocaleCacheEntry>();
_emptyMap = new Map();
async loadLocale(modPackId: number, cacheEntry: ExportLocaleCacheEntry) {
let response = await fetch(`${staticRoot}static/${cacheEntry.localePath}`);
if (response.ok) {
cacheEntry.data = new Map(await response.json() as ExportLocale);
for (const callback of cacheEntry.callbacks) {
callback();
}
} else {
notify(
`Failed to locale for mod pack ${modPackId}, server returned: ` +
`${response.status} ${response.statusText}`
);
}
}
subscribe(modPackId: number | undefined, localePath: string | undefined, callback: () => void) {
if (modPackId === undefined || !localePath) {
return () => {};
}
let cacheEntry = this.cache.get(modPackId);
if (!cacheEntry) {
cacheEntry = {
callbacks: [],
localePath: undefined,
data: new Map(),
};
this.cache.set(modPackId, cacheEntry);
}
if (cacheEntry.localePath !== localePath) {
cacheEntry.localePath = localePath;
this.loadLocale(modPackId, cacheEntry).catch(logger.error);
}
cacheEntry.callbacks.push(callback);
return () => {
cacheEntry.callbacks.splice(cacheEntry.callbacks.indexOf(callback), 1);
};
}
getSnapshot(modPackId: number | undefined): ReadonlyMap<string, string> {
if (modPackId === undefined) {
return this._emptyMap;
}
return this.cache.get(modPackId)?.data ?? this._emptyMap;
}
}
export const exportLocaleStore = new ExportLocaleStore();
@@ -0,0 +1,126 @@
import { ExportMetadata, ExportMetadataEntry, logger, ModPack } from "@clusterio/lib";
import unknownIconUrl from "../images/unknown-item.png";
import notify from "../util/notify";
export type PrototypeMetadataEntry = ExportMetadataEntry & { base_type: string };
interface MetadataCacheEntry {
callbacks: (() => void)[],
metadataPath: string | undefined,
spritesheetPath: string | undefined,
metadata: Map<string, Map<string, PrototypeMetadataEntry>>,
styleSheet: CSSStyleSheet,
}
class ExportPrototypeMetadataStore {
cache = new Map<number, MetadataCacheEntry>();
async loadMetadata(modPackId: number, cacheEntry: MetadataCacheEntry) {
const response = await fetch(`${staticRoot}static/${cacheEntry.metadataPath}`);
if (!response.ok) {
notify(
`Failed to fetch prototype metadata for mod pack ${modPackId}, server returned: ` +
`${response.status} ${response.statusText}`
);
// eslint-disable-next-line node/no-sync
cacheEntry.styleSheet.replaceSync("");
cacheEntry.metadata = new Map();
for (const callback of cacheEntry.callbacks) {
callback();
}
return;
}
const metadata = await response.json() as ExportMetadata;
const spriteUrl = `${staticRoot}static/${cacheEntry.spritesheetPath}`;
const styleContent: string[] = [];
styleContent.push(`\
.factorio-icon\
[data-mod-pack="${modPackId}"] {
background-image: url("${spriteUrl}");
}`);
for (const [baseName, prototypes] of Object.entries(metadata)) {
for (const prototype of prototypes) {
const icon = prototype.icon;
if (!icon) {
styleContent.push(`\
.factorio-icon\
[data-mod-pack="${modPackId}"]\
[data-type="${baseName}"]\
[data-name="${CSS.escape(prototype.name)}"] {
background-image: url("${unknownIconUrl}");
height: 32px;
width: 32px;
}`);
continue;
}
styleContent.push(`\
.factorio-icon\
[data-mod-pack="${modPackId}"]\
[data-type="${baseName}"]\
[data-name="${CSS.escape(prototype.name)}"] {
background-position: -${icon.x}px -${icon.y}px;
height: ${icon.size}px;
width: ${icon.size}px;
}`);
}
}
// eslint-disable-next-line node/no-sync
cacheEntry.styleSheet.replaceSync(styleContent.join("\n"));
cacheEntry.metadata = new Map(
Object.entries(metadata)
.map(([baseName, entries]) => [
baseName,
new Map(
entries.map(entry => [entry.name, { base_type: baseName, ...entry }])
),
])
);
for (const callback of cacheEntry.callbacks) {
callback();
}
}
subscribe(modPack: ModPack | undefined, callback: () => void) {
if (!modPack) {
return () => {};
}
const assets = modPack.exportManifest?.assets;
let cacheEntry = this.cache.get(modPack.id);
if (!cacheEntry) {
const styleSheet = new CSSStyleSheet();
document.adoptedStyleSheets.push(styleSheet);
cacheEntry = {
callbacks: [],
spritesheetPath: undefined,
metadataPath: undefined,
metadata: new Map(),
styleSheet,
};
this.cache.set(modPack.id, cacheEntry);
}
if (
cacheEntry.metadataPath !== assets?.metadata
|| cacheEntry.spritesheetPath !== assets?.spritesheet
) {
cacheEntry.metadataPath = assets?.metadata;
cacheEntry.spritesheetPath = assets?.spritesheet;
this.loadMetadata(modPack.id, cacheEntry).catch(logger.error);
}
cacheEntry.callbacks.push(callback);
return () => {
cacheEntry.callbacks.splice(cacheEntry.callbacks.indexOf(callback), 1);
};
}
getSnapshot(modPackId: number | undefined) {
if (modPackId === undefined) {
return undefined;
}
return this.cache.get(modPackId)?.metadata;
}
}
export const exportPrototypeMetadataStore = new ExportPrototypeMetadataStore();
+8 -1
View File
@@ -320,7 +320,14 @@ body {
font-family: monospace;
}
.factorio-icon.item-unknown-item {
.factorio-icon {
display: inline-block;
vertical-align: middle;
background-repeat: no-repeat;
}
/* data-mod-pack might not be present, but we need the specificity to win over it if it is */
.factorio-icon:is([data-mod-pack],*)[data-unknown="true"] {
background: url("./images/unknown-item.png");
height: 32px;
width: 32px;
+13 -21
View File
@@ -1,10 +1,9 @@
import React, { useContext, useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { Input, Table, Typography } from "antd";
import * as lib from "@clusterio/lib";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext,
notifyErrorHandler, useItemMetadata, useFluidMetadata, useLocale,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon,
} from "@clusterio/web_ui";
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages";
@@ -32,16 +31,18 @@ function useStorage(control: Control) {
function StoragePage() {
let control = useContext(ControlContext);
let locale = useLocale();
let itemMetadata = useItemMetadata();
let fluidMetadata = useFluidMetadata();
const modPack = useDefaultModPack();
let locale = useExportLocale(modPack);
const prototypes = useExportPrototypeMetadata(modPack);
let itemMetadata = prototypes?.get("item");
let fluidMetadata = prototypes?.get("fluid");
let storage = useStorage(control);
type ItemFilter = ([name, item]: [string, Item]) => boolean;
let [filter, setFilter] = useState<null | ItemFilter>(null);
function getLocaleName(itemName: string) {
let localeName = itemName;
let meta = itemMetadata.get(itemName) ?? fluidMetadata.get(itemName);
let meta = itemMetadata?.get(itemName) ?? fluidMetadata?.get(itemName);
if (meta && meta.localised_name) {
// TODO: implement the locale to name conversion.
if (typeof meta.localised_name === "string") {
@@ -86,6 +87,7 @@ function StoragePage() {
/>
</Paragraph>
<Table
className="subspace-storage-storage"
columns={[
{
title: "Resource",
@@ -99,21 +101,11 @@ function StoragePage() {
},
render: (_, item) => {
let localeName = getLocaleName(item[1].name);
let type;
let name;
if (itemMetadata.has(item[1].name)) {
type = "item";
name = item[1].name;
} else if (fluidMetadata.has(item[1].name)) {
type = "fluid";
name = item[1].name;
} else {
type = "item";
name = "unknown-item";
}
let name = item[1].name;
const prototype = itemMetadata?.get(name) ?? fluidMetadata?.get(name);
return <>
<span className={`factorio-icon ${type}-${name}`}/>
<FactorioIcon modPackId={modPack?.id} prototype={prototype} />
{localeName}
</>;
},
+1 -3
View File
@@ -1,5 +1,3 @@
.factorio-icon {
display: inline-block;
vertical-align: middle;
.subspace-storage-storage .factorio-icon {
margin-right: 0.5em;
}