import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; 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 { PageLayout, PageHeader, ControlContext, FactorioIcon, notify, notifyErrorHandler, useAccount, useInstances, useDefaultModPack, useExportLocale, useExportPrototypeMetadata, useTableQueryState, useColumnSearch, type WebPluginContext, } from "@clusterio/web_ui"; import { DatabaseStatsRequest, DatabaseStatsResponse, DeletePlayerDataRequest, ForceReleaseRequest, GetPlayerDataRequest, GetPlayerDataResponse, IpcPlayerData, ListPlayersRequest, PlayerEntry, SetPlayerDataRequest, } from "../messages.js"; import { type ItemSummary, summarizePlayerInventories } from "../player_data.js"; import "./style.css"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; function useInstanceName() { const [instances] = useInstances(); return (instanceId?: number) => { if (instanceId === undefined) { return ""; } return instances.get(instanceId)?.name ?? `Instance ${instanceId}`; }; } 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(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]) ?? name; } 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={() => 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", item.name)} ), }, { title: "Quality", key: "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={{ pageSize: 10, hideOnSinglePage: true, showSizeChanger: false }} />; } function usePlayerData(playerName?: string) { const control = useContext(ControlContext); 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; if (playerName === undefined) { return; } control.send(new GetPlayerDataRequest(playerName)).then(response => { if (id === requestId.current) { setLoaded({ playerName, response }); } }).catch( notifyErrorHandler("Error loading player data") ); }, [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 { playerData = JSON.parse(await file.text()); } catch (err: any) { notify("Invalid JSON", "error", err.message); return; } playerData.name = playerName; playerData.generation ??= 0; let result = await control.send(new SetPlayerDataRequest(playerName, playerData)); notify(`Stored player data for ${playerName} as generation ${result.generation}`); props.onChange(); } function download() { 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; link.download = `${playerName}.json`; link.click(); URL.revokeObjectURL(url); } const canModify = account.hasPermission("player_sync.inventory.modify"); 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() { const control = useContext(ControlContext); const instanceName = useInstanceName(); const [statsData, setStatsData] = useState(); const [players, setPlayers] = useState([]); const [selected, setSelected] = useState(); const tableState = useTableQueryState({ 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( notifyErrorHandler("Error loading player sync statistics") ); control.send(new ListPlayersRequest()).then(setPlayers).catch( notifyErrorHandler("Error loading player list") ); }, [control]); useEffect(reload, [reload]); return {statsData && {lib.formatBytes(statsData.databaseSize)} {statsData.databaseEntries} } strcmp(a.name, b.name), sortOrder: tableState.sortOrder("name"), }, { 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", key: "size", align: "right", sorter: (a, b) => (a.size ?? -1) - (b.size ?? -1), sortOrder: tableState.sortOrder("size"), render: (_, player) => (player.size === undefined ? "-" : lib.formatBytes(player.size)), }, { title: "Acquired by", key: "instance", sorter: (a, b) => strcmp(instanceName(a.instanceId), instanceName(b.instanceId)), sortOrder: tableState.sortOrder("instance"), render: (_, player) => instanceName(player.instanceId), }, ]} dataSource={players} rowKey="name" pagination={tableState.pagination} onChange={tableState.onChange} onRow={player => ({ style: { cursor: "pointer" }, onClick: () => setSelected(player.name), })} /> setSelected(undefined)} onChange={reload} /> ; } export default async function(context: WebPluginContext) { const { control, plugin } = context; control.hooks.pages.attach(plugin.name, () => [ { path: "/inventory", sidebarName: "Player sync", permission: "player_sync.inventory.view", content: , }, ]); }