From 0d28a3ac3c1784700f3980db7e10974d7218e1fe Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 13 Mar 2026 18:51:59 +0100 Subject: [PATCH] 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. --- packages/controller/src/routes.ts | 2 +- .../web_ui/src/components/FactorioIcon.tsx | 11 ++ .../src/components/IconReferencePage.tsx | 185 ++++++------------ .../web_ui/src/components/ModPackViewPage.tsx | 7 + packages/web_ui/src/index.ts | 9 +- packages/web_ui/src/model/export_locale.ts | 13 ++ packages/web_ui/src/model/export_manifest.ts | 27 --- .../src/model/export_prototype_metadata.ts | 17 ++ packages/web_ui/src/model/item_metadata.ts | 85 -------- packages/web_ui/src/model/locale.ts | 29 --- packages/web_ui/src/model/mod_pack.ts | 10 + packages/web_ui/src/pages.tsx | 11 +- .../src/store/default_mod_pack_store.ts | 51 +++++ .../web_ui/src/store/export_locale_stores.ts | 61 ++++++ .../store/export_prototype_metadata_store.ts | 126 ++++++++++++ packages/web_ui/src/style.css | 9 +- plugins/subspace_storage/web/index.tsx | 34 ++-- plugins/subspace_storage/web/style.css | 4 +- 18 files changed, 391 insertions(+), 300 deletions(-) create mode 100644 packages/web_ui/src/components/FactorioIcon.tsx create mode 100644 packages/web_ui/src/model/export_locale.ts delete mode 100644 packages/web_ui/src/model/export_manifest.ts create mode 100644 packages/web_ui/src/model/export_prototype_metadata.ts delete mode 100644 packages/web_ui/src/model/item_metadata.ts delete mode 100644 packages/web_ui/src/model/locale.ts create mode 100644 packages/web_ui/src/store/default_mod_pack_store.ts create mode 100644 packages/web_ui/src/store/export_locale_stores.ts create mode 100644 packages/web_ui/src/store/export_prototype_metadata_store.ts diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 8c461c9d..b9fba89b 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -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", ]; diff --git a/packages/web_ui/src/components/FactorioIcon.tsx b/packages/web_ui/src/components/FactorioIcon.tsx new file mode 100644 index 00000000..a7cd1487 --- /dev/null +++ b/packages/web_ui/src/components/FactorioIcon.tsx @@ -0,0 +1,11 @@ +import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store"; + +export default function FactorioIcon(props: { modPackId: number | undefined, prototype?: PrototypeMetadataEntry }) { + return ; +} diff --git a/packages/web_ui/src/components/IconReferencePage.tsx b/packages/web_ui/src/components/IconReferencePage.tsx index 800bfb0c..de5263b0 100644 --- a/packages/web_ui/src/components/IconReferencePage.tsx +++ b/packages/web_ui/src/components/IconReferencePage.tsx @@ -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> | 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 = { - 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> = { - 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> | 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>(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(); - 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) => ( -
+ render: (prototype: PrototypeMetadataEntry) => ( + ), }, { @@ -146,19 +84,9 @@ export default function IconReferencePage() { {name} ), }, - { - title: "Usage", - dataIndex: "cssClass", - key: "usage", - render: (cssClass: string) => ( - - {`
`} - - ), - }, { title: "Path", - dataIndex: "path", + dataIndex: ["icon", "path"], key: "path", render: (p: string | undefined) => (p ? {p} @@ -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 ( - +
- {!manifest && ( + {!modPack?.exportManifest && ( No export data available. Run export-data on an instance to generate icons. @@ -188,7 +120,7 @@ export default function IconReferencePage() {
)}
- Icons: + Prototypes: {totalCount}
@@ -196,7 +128,7 @@ export default function IconReferencePage() { setFilter(e.target.value)} allowClear @@ -208,13 +140,12 @@ export default function IconReferencePage() { : `${visibleCount} / ${totalCount} icons`}
- {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 ( -
+
toggleGroup(category)} + onClick={() => toggleGroup(baseType)} style={{ display: "flex", alignItems: "center", @@ -230,13 +161,13 @@ export default function IconReferencePage() { {expanded ? "▾" : "►"} - {label} + {baseType} {entries.length}
{expanded && ( - + columns={columns} dataSource={entries} rowKey="key" diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 8cccaf24..caea5202 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -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} /> + + Inspect exported data + + + Icon reference +
void) => exportLocaleStore.subscribe(modPack?.id, localePath, callback), + [modPack?.id, localePath], + ); + return useSyncExternalStore(subscribe, () => exportLocaleStore.getSnapshot(modPack?.id)); +} diff --git a/packages/web_ui/src/model/export_manifest.ts b/packages/web_ui/src/model/export_manifest.ts deleted file mode 100644 index 5ec39b28..00000000 --- a/packages/web_ui/src/model/export_manifest.ts +++ /dev/null @@ -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(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; -} diff --git a/packages/web_ui/src/model/export_prototype_metadata.ts b/packages/web_ui/src/model/export_prototype_metadata.ts new file mode 100644 index 00000000..3b1e1fe1 --- /dev/null +++ b/packages/web_ui/src/model/export_prototype_metadata.ts @@ -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)); +} diff --git a/packages/web_ui/src/model/item_metadata.ts b/packages/web_ui/src/model/item_metadata.ts deleted file mode 100644 index e8e1bc05..00000000 --- a/packages/web_ui/src/model/item_metadata.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, useState } from "react"; - -import { useExportManifest } from "./export_manifest"; -import { ExportMetadata, ExportMetadataEntry } from "@clusterio/lib"; - - -const metadataCaches = new Map>(); -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 { - const exportManifest = useExportManifest(); - const [metadata, setMetadata] = useState>( - 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(); - 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"); } diff --git a/packages/web_ui/src/model/locale.ts b/packages/web_ui/src/model/locale.ts deleted file mode 100644 index 95b136ef..00000000 --- a/packages/web_ui/src/model/locale.ts +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useEffect, useState } from "react"; - -import { useExportManifest } from "./export_manifest"; - - -let localeCache: Map|null = null; -export function useLocale(): Map { - let exportManifest = useExportManifest(); - let [locale, setLocale] = useState>(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; -} diff --git a/packages/web_ui/src/model/mod_pack.ts b/packages/web_ui/src/model/mod_pack.ts index 927a9a75..a3b63bb2 100644 --- a/packages/web_ui/src/model/mod_pack.ts +++ b/packages/web_ui/src/model/mod_pack.ts @@ -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()); +} diff --git a/packages/web_ui/src/pages.tsx b/packages/web_ui/src/pages.tsx index f3f36a35..f2187c45 100644 --- a/packages/web_ui/src/pages.tsx +++ b/packages/web_ui/src/pages.tsx @@ -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: , }, + { + path: "/mods/mod-packs/:id/icon-reference", + sidebarPath: "/mods", + content: , + }, { path: "/users", sidebarName: "Users", @@ -89,10 +96,6 @@ export const pages: PluginPage[] = [ sidebarPath: "/plugins", content: , }, - { - path: "/icon-reference", - content: , - }, { path: "/error", content: , diff --git a/packages/web_ui/src/store/default_mod_pack_store.ts b/packages/web_ui/src/store/default_mod_pack_store.ts new file mode 100644 index 00000000..4b92f30a --- /dev/null +++ b/packages/web_ui/src/store/default_mod_pack_store.ts @@ -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; + 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(); diff --git a/packages/web_ui/src/store/export_locale_stores.ts b/packages/web_ui/src/store/export_locale_stores.ts new file mode 100644 index 00000000..ef07b4b7 --- /dev/null +++ b/packages/web_ui/src/store/export_locale_stores.ts @@ -0,0 +1,61 @@ +import { ExportLocale, logger } from "@clusterio/lib"; +import notify from "../util/notify"; + + +interface ExportLocaleCacheEntry { + callbacks: (() => void)[]; + localePath: string | undefined; + data: Map; +} + +class ExportLocaleStore { + cache = new Map(); + _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 { + if (modPackId === undefined) { + return this._emptyMap; + } + return this.cache.get(modPackId)?.data ?? this._emptyMap; + } +} +export const exportLocaleStore = new ExportLocaleStore(); diff --git a/packages/web_ui/src/store/export_prototype_metadata_store.ts b/packages/web_ui/src/store/export_prototype_metadata_store.ts new file mode 100644 index 00000000..17030032 --- /dev/null +++ b/packages/web_ui/src/store/export_prototype_metadata_store.ts @@ -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>, + styleSheet: CSSStyleSheet, +} + +class ExportPrototypeMetadataStore { + cache = new Map(); + 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(); diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 937c4cd9..59be1d2b 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -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; diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 120d29da..63f669d7 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -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); 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() { /> { 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 <> - + {localeName} ; }, diff --git a/plugins/subspace_storage/web/style.css b/plugins/subspace_storage/web/style.css index d96377fc..09460697 100644 --- a/plugins/subspace_storage/web/style.css +++ b/plugins/subspace_storage/web/style.css @@ -1,5 +1,3 @@ -.factorio-icon { - display: inline-block; - vertical-align: middle; +.subspace-storage-storage .factorio-icon { margin-right: 0.5em; }