From ec7b5f2db8ceace01a25b7a9263a9fa9ae57cd7a Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:24:02 +0000 Subject: [PATCH] Rework the player sync web page after UX review The drawer actions move to its header as Export, Import and a trash icon for Delete. Import and Delete are disabled while an instance holds the player, since the stored data is out of date until it uploads. The drawer also shows color and cheat mode. Inventory tables get title case names, pagination at 10 rows, item search, sorting and quality icons. The player list shows force with a filter instead of generation, and the database stats use Descriptions without the largest entry. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/controller.ts | 3 +- plugins/player_sync/messages.ts | 4 +- plugins/player_sync/package.json | 1 + plugins/player_sync/test/controller.js | 2 +- plugins/player_sync/test/plugin.js | 1 + plugins/player_sync/web/index.tsx | 308 ++++++++++++++++--------- plugins/player_sync/web/style.css | 8 + 7 files changed, 219 insertions(+), 108 deletions(-) diff --git a/plugins/player_sync/controller.ts b/plugins/player_sync/controller.ts index 93ba3aa5..16589fdd 100644 --- a/plugins/player_sync/controller.ts +++ b/plugins/player_sync/controller.ts @@ -266,7 +266,8 @@ export default async function(context: ControllerPluginContext) { controller.handle(msg.ListPlayersRequest, async () => { let entries = new Map(); for (let [name, playerData] of playerDatastore) { - entries.set(name, new msg.PlayerEntry(name, playerData.generation, JSON.stringify(playerData).length)); + let size = JSON.stringify(playerData).length; + entries.set(name, new msg.PlayerEntry(name, playerData.generation, size, undefined, playerData.force)); } for (let name of acquiredPlayers.keys()) { let acquisitionRecord = getAcquisition(name); diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 7663a794..146d2727 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -359,6 +359,7 @@ export class PlayerEntry { public generation?: number, public size?: number, public instanceId?: number, + public force?: string, ) { } @@ -367,10 +368,11 @@ export class PlayerEntry { "generation": Type.Optional(Type.Integer()), "size": Type.Optional(Type.Integer()), "instanceId": Type.Optional(Type.Integer()), + "force": Type.Optional(Type.String()), }); static fromJSON(json: Static): PlayerEntry { - return new this(json.name, json.generation, json.size, json.instanceId); + return new this(json.name, json.generation, json.size, json.instanceId, json.force); } } diff --git a/plugins/player_sync/package.json b/plugins/player_sync/package.json index ff06cf62..c95e068f 100644 --- a/plugins/player_sync/package.json +++ b/plugins/player_sync/package.json @@ -51,6 +51,7 @@ "@sinclair/typebox": "catalog:" }, "devDependencies": { + "@ant-design/icons": "^5.6.1", "@clusterio/controller": "workspace:^", "@clusterio/ctl": "workspace:^", "@clusterio/host": "workspace:^", diff --git a/plugins/player_sync/test/controller.js b/plugins/player_sync/test/controller.js index a7177d78..2a5328df 100644 --- a/plugins/player_sync/test/controller.js +++ b/plugins/player_sync/test/controller.js @@ -147,7 +147,7 @@ describe("player_sync controller", function() { entries.sort((a, b) => a.name.localeCompare(b.name)); assert.deepEqual(entries, [ new msg.PlayerEntry("online", undefined, undefined, 1), - new msg.PlayerEntry("stored", 3, JSON.stringify(playerData("stored", 3)).length, 1), + new msg.PlayerEntry("stored", 3, JSON.stringify(playerData("stored", 3)).length, 1, "player"), ]); }); }); diff --git a/plugins/player_sync/test/plugin.js b/plugins/player_sync/test/plugin.js index ed521923..77516d93 100644 --- a/plugins/player_sync/test/plugin.js +++ b/plugins/player_sync/test/plugin.js @@ -26,6 +26,7 @@ describe("player_sync plugin", function() { [undefined, 0, 4], // generation [undefined, 0, 1234], // size [undefined, 1], // instanceId + [undefined, "player"], // force )); }); it("GetPlayerDataResponse should be round trip json serialisable", function() { diff --git a/plugins/player_sync/web/index.tsx b/plugins/player_sync/web/index.tsx index 38f101fc..dcfd2c9d 100644 --- a/plugins/player_sync/web/index.tsx +++ b/plugins/player_sync/web/index.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; -import { Alert, Button, Descriptions, Drawer, Popconfirm, Space, Table, Typography, Upload } from "antd"; +import { Alert, Button, Descriptions, Drawer, Input, Popconfirm, Space, Table, Typography, Upload } from "antd"; +import type { ColumnType } from "antd/es/table"; +import { DeleteOutlined, SearchOutlined } from "@ant-design/icons"; import * as lib from "@clusterio/lib"; import { @@ -28,89 +30,134 @@ function useInstanceName() { }; } +function titleCase(text: string) { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +// Factorio reads colors back as 0-1 floats but accepts 0-255 as well +function formatColor(color: any) { + let [r, g, b] = Array.isArray(color) ? color : [color.r, color.g, color.b]; + let scale = Math.max(r, g, b) > 1 ? 1 : 255; + let hex = (c: number) => Math.min(255, Math.max(0, Math.round(c * scale))).toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} + +// Filters the table locally, the inventory tables are not kept in the URL +function itemSearch(getText: (record: T) => string): ColumnType { + return { + filterIcon: (filtered: boolean) => , + filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => ( +
e.stopPropagation()}> + { + setSelectedKeys(e.target.value ? [e.target.value] : []); + confirm({ closeDropdown: false }); + }} + onPressEnter={() => confirm()} + /> +
+ ), + onFilter: (value, record) => getText(record).toLowerCase().includes(String(value).toLowerCase()), + }; +} + function InventoryTable(props: { name: string, items: ItemSummary[] }) { const modPack = useDefaultModPack(); const locale = useExportLocale(modPack); const prototypes = useExportPrototypeMetadata(modPack); const itemMetadata = prototypes?.get("item"); + const qualityMetadata = prototypes?.get("quality"); + // The export lists qualities in the order mods define them, normal is left out of the stored data + const qualityOrder = [...qualityMetadata?.keys() ?? []]; + const qualityRank = (quality?: string) => (quality === undefined ? -1 : qualityOrder.indexOf(quality)); - function getLocaleName(itemName: string) { - let meta = itemMetadata?.get(itemName); + function getLocaleName(type: "item" | "quality", name: string) { + let meta = (type === "item" ? itemMetadata : qualityMetadata)?.get(name); if (typeof meta?.localised_name === "string") { return meta.localised_name; } if (meta?.localised_name) { - return locale.get(meta.localised_name[0]) ?? itemName; + return locale.get(meta.localised_name[0]) ?? name; } - return locale.get(`item-name.${itemName}`) ?? locale.get(`entity-name.${itemName}`) ?? itemName; + if (type === "quality") { + return locale.get(`quality-name.${name}`) ?? name; + } + return locale.get(`item-name.${name}`) ?? locale.get(`entity-name.${name}`) ?? name; } + const itemText = (item: ItemSummary) => ( + item.exportSize !== undefined ? item.name : `${getLocaleName("item", item.name)} ${item.name}` + ); - return size="small" - title={() => props.name} + title={() => titleCase(props.name)} columns={[ { title: "Item", key: "item", + ...itemSearch(itemText), + sorter: (a, b) => strcmp(itemText(a), itemText(b)), render: (_, item) => (item.exportSize !== undefined ? {item.name} - : <> + : - {getLocaleName(item.name)} - + {getLocaleName("item", item.name)} + ), }, { title: "Quality", key: "quality", - render: (_, item) => item.quality, + sorter: (a, b) => qualityRank(a.quality) - qualityRank(b.quality), + render: (_, item) => item.quality !== undefined && + + {getLocaleName("quality", item.quality)} + , }, { title: "Count", key: "count", align: "right", + sorter: (a, b) => a.count - b.count, render: (_, item) => item.count, }, { title: "Size", key: "size", align: "right", + sorter: (a, b) => (a.exportSize ?? -1) - (b.exportSize ?? -1), render: (_, item) => (item.exportSize === undefined ? "" : lib.formatBytes(item.exportSize)), }, ]} dataSource={props.items.map((item, key) => ({ ...item, key }))} - pagination={false} + pagination={{ pageSize: 10, hideOnSinglePage: true, showSizeChanger: false }} />; } -function PlayerDrawer(props: { playerName?: string, onClose: () => void, onChange: () => void }) { - return - {/* Keyed so a request that finishes after switching players can only update the old player's view */} - {props.playerName !== undefined - && } - ; -} - -function PlayerDetails(props: { playerName: string, onChange: () => void }) { +function usePlayerData(playerName?: string) { const control = useContext(ControlContext); - const account = useAccount(); - const instanceName = useInstanceName(); - const [response, setResponse] = useState(); - const { playerName } = props; + const [loaded, setLoaded] = useState<{ playerName: string, response: GetPlayerDataResponse }>(); // Only the latest request may set the response, an import or delete can finish after a reload const requestId = useRef(0); + const currentPlayer = useRef(playerName); + currentPlayer.current = playerName; const reload = useCallback(() => { + // Ignore reloads from actions that finish after switching to another player + if (playerName !== currentPlayer.current) { + return; + } requestId.current += 1; let id = requestId.current; - control.send(new GetPlayerDataRequest(playerName)).then(result => { + if (playerName === undefined) { + return; + } + control.send(new GetPlayerDataRequest(playerName)).then(response => { if (id === requestId.current) { - setResponse(result); + setLoaded({ playerName, response }); } }).catch( notifyErrorHandler("Error loading player data") @@ -118,11 +165,37 @@ function PlayerDetails(props: { playerName: string, onChange: () => void }) { }, [control, playerName]); useEffect(reload, [reload]); + return [loaded?.playerName === playerName ? loaded?.response : undefined, reload] as const; +} + +type PlayerProps = { playerName: string, response: GetPlayerDataResponse, onChange: () => void }; + +function PlayerDrawer(props: { playerName?: string, onClose: () => void, onChange: () => void }) { + const { playerName } = props; + const [response, reload] = usePlayerData(playerName); + function changed() { reload(); props.onChange(); } + const loaded = response && playerName !== undefined; + return } + > + {loaded && } + ; +} + +function PlayerActions(props: PlayerProps) { + const control = useContext(ControlContext); + const account = useAccount(); + const { playerName, response } = props; + async function upload(file: File) { let playerData: IpcPlayerData; try { @@ -135,11 +208,11 @@ function PlayerDetails(props: { playerName: string, onChange: () => void }) { playerData.generation ??= 0; let result = await control.send(new SetPlayerDataRequest(playerName, playerData)); notify(`Stored player data for ${playerName} as generation ${result.generation}`); - changed(); + props.onChange(); } function download() { - let json = JSON.stringify(response!.playerData, null, "\t"); + let json = JSON.stringify(response.playerData, null, "\t"); let url = URL.createObjectURL(new Blob([json], { type: "application/json" })); let link = document.createElement("a"); link.href = url; @@ -149,67 +222,89 @@ function PlayerDetails(props: { playerName: string, onChange: () => void }) { } const canModify = account.hasPermission("player_sync.inventory.modify"); - const playerData = response?.playerData; - const acquiredBy = response?.instanceId !== undefined ? instanceName(response.instanceId) : undefined; - return <> - {response && - {acquiredBy !== undefined && control.send(new ForceReleaseRequest(playerName)).then(changed).catch( - notifyErrorHandler("Error releasing lock") - )} - > - - } - />} - - {playerData?.generation ?? "-"} - - {playerData ? lib.formatBytes(JSON.stringify(playerData).length) : "-"} - - {playerData?.controller ?? "-"} - {playerData?.force ?? "-"} - - - - {canModify && { - upload(file).catch(notifyErrorHandler("Error storing player data")); - return false; - }} - > - - } - {canModify && control.send(new DeletePlayerDataRequest(playerName)).then(changed).catch( - notifyErrorHandler("Error deleting player data") - )} - > - - } - - {!playerData && No player data stored} - {playerData && summarizePlayerInventories(playerData).map( - inventory => + const acquired = response.instanceId !== undefined; + return + + {canModify && { + upload(file).catch(notifyErrorHandler("Error storing player data")); + return false; + }} + > + + } + {canModify && control.send(new DeletePlayerDataRequest(playerName)).then(props.onChange).catch( + notifyErrorHandler("Error deleting player data") )} - } - ; + > + + } + />} + + {playerData?.generation ?? "-"} + + {playerData ? lib.formatBytes(JSON.stringify(playerData).length) : "-"} + + {playerData?.controller ?? "-"} + {playerData?.force ?? "-"} + + {color ? + + {color} + : "-"} + + {cheatMode} + + {!playerData && No player data stored} + {playerData && summarizePlayerInventories(playerData).map( + inventory => + )} + ; } function InventoryPage() { @@ -223,6 +318,7 @@ function InventoryPage() { namespace: "inventory", defaultSortKey: "name", pagination: { defaultPageSize: 50 }, }); const nameSearch = useColumnSearch(tableState, "name", player => player.name, "Search"); + const forces = [...new Set(players.flatMap(player => (player.force === undefined ? [] : [player.force])))]; const reload = useCallback(() => { control.send(new DatabaseStatsRequest()).then(setStatsData).catch( @@ -236,11 +332,10 @@ function InventoryPage() { return - {statsData && <> -

Database size: {lib.formatBytes(statsData.databaseSize)}

-

Database entries: {statsData.databaseEntries}

-

Largest entry is {statsData.largestEntry.name} with {lib.formatBytes(statsData.largestEntry.size)}

- } + {statsData && + {lib.formatBytes(statsData.databaseSize)} + {statsData.databaseEntries} + }
(a.generation ?? -1) - (b.generation ?? -1), - sortOrder: tableState.sortOrder("generation"), - render: (_, player) => player.generation ?? "-", + title: "Force", + key: "force", + filters: forces.sort(strcmp).map(force => ({ text: force, value: force })), + filterMultiple: true, + filteredValue: tableState.filteredValue("force"), + onFilter: (value, player) => player.force === value, + sorter: (a, b) => strcmp(a.force ?? "", b.force ?? ""), + sortOrder: tableState.sortOrder("force"), + render: (_, player) => player.force ?? "-", }, { title: "Size", diff --git a/plugins/player_sync/web/style.css b/plugins/player_sync/web/style.css index e69de29b..48549508 100644 --- a/plugins/player_sync/web/style.css +++ b/plugins/player_sync/web/style.css @@ -0,0 +1,8 @@ +.player-sync-color { + display: inline-block; + width: 16px; + height: 16px; + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 2px; + vertical-align: middle; +}