From e7519975ec60a63768d586088d0ce856d1517c75 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Wed, 23 Jul 2025 14:21:33 +0200 Subject: [PATCH] Use same component for all user tables --- .../src/components/OnlinePlayersList.tsx | 99 +-------- packages/web_ui/src/components/UsersPage.tsx | 104 +--------- packages/web_ui/src/components/UsersTable.tsx | 194 ++++++++++++++++++ 3 files changed, 203 insertions(+), 194 deletions(-) create mode 100644 packages/web_ui/src/components/UsersTable.tsx diff --git a/packages/web_ui/src/components/OnlinePlayersList.tsx b/packages/web_ui/src/components/OnlinePlayersList.tsx index 844c8033..c35e6984 100644 --- a/packages/web_ui/src/components/OnlinePlayersList.tsx +++ b/packages/web_ui/src/components/OnlinePlayersList.tsx @@ -1,13 +1,7 @@ import React from "react"; -import { Table, Tag, Space } from "antd"; -import { ColumnsType } from "antd/es/table"; -import * as lib from "@clusterio/lib"; - -import { useUsers, formatFirstSeen } from "../model/user"; -import { useRoles } from "../model/roles"; -import { formatDuration } from "../util/time_format"; -import Link from "./Link"; +import { useUsers } from "../model/user"; +import UsersTable from "./UsersTable"; type OnlinePlayersListProps = { instanceId: number; @@ -15,82 +9,10 @@ type OnlinePlayersListProps = { export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps) { const [users] = useUsers(); - const [roles] = useRoles(); // Filter users to only show those online on this specific instance const onlineUsers = [...users.values()].filter(user => user.instances && user.instances.has(instanceId)); - const columns: ColumnsType = [ - { - title: "Player", - key: "name", - render: (_, user) => ( - - - {user.name} - - - {user.isAdmin && Admin} - - - ), - sorter: (a, b) => a.name.localeCompare(b.name), - defaultSortOrder: "ascend", - }, - { - title: "Roles", - key: "roles", - render: (_, user) => ( - [...user.roleIds] - .map(id => ( - e.stopPropagation()}> - {(roles.get(id) || { name: id }).name} - - )) - ), - }, - { - title: "Play Time", - key: "playTime", - render: (_, user) => { - const instanceStats = user.instanceStats.get(instanceId); - return instanceStats?.onlineTimeMs - ? formatDuration(instanceStats.onlineTimeMs) - : "-"; - }, - sorter: (a, b) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); - }, - }, - { - title: "Join Count", - key: "joinCount", - render: (_, user) => { - const instanceStats = user.instanceStats.get(instanceId); - return instanceStats?.joinCount ?? 0; - }, - sorter: (a, b) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); - }, - }, - { - title: "First Seen", - key: "firstSeen", - render: (_, user) => formatFirstSeen(user, instanceId), - sorter: (a, b) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0; - const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0; - return firstSeenA - firstSeenB; - }, - }, - ]; - if (onlineUsers.length === 0) { return null; } @@ -98,22 +20,7 @@ export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps return (

Online Players ({onlineUsers.length})

- user.name} - pagination={false} - size="small" - onRow={(user) => ({ - onClick: (event) => { - // Don't navigate if clicking on a link - if ((event.target as HTMLElement).closest("a")) { - return; - } - window.location.href = `/users/${user.name}/view`; - }, - })} - /> + ); } diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index 40fafc7d..f35869cd 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -1,31 +1,23 @@ -import React, { useEffect, useContext, useState, useRef } from "react"; +import React, { useContext, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Static } from "@sinclair/typebox"; -import { - Button, Form, FormInstance, Input, InputRef, Modal, - Radio, Space, Table, Tag, Upload, UploadProps, +import { Button, Form, FormInstance, Input, Modal, + Radio, Upload, UploadProps, } from "antd"; -import { - InboxOutlined, SearchOutlined, -} from "@ant-design/icons"; +import { InboxOutlined } from "@ant-design/icons"; import * as lib from "@clusterio/lib"; -import { useRoles } from "../model/roles"; import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; -import { formatDuration } from "../util/time_format"; import { saveJson } from "../util/save_file"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, useUsers } from "../model/user"; -import Link from "./Link"; - -const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; +import UsersTable from "./UsersTable"; function CreateUserButton() { let control = useContext(ControlContext); @@ -374,10 +366,6 @@ function BulkUserActionButton() { export default function UsersPage() { let account = useAccount(); - let navigate = useNavigate(); - let [users] = useUsers(); - const [roles] = useRoles(); - const searchInput = useRef(null); return : undefined} } /> -
- {user.name} - - {user.isAdmin && Admin} - {user.isWhitelisted && Whitelisted} - {user.isBanned && Banned} - - , - defaultSortOrder: "ascend", - sorter: (a, b) => strcmp(a.name, b.name), - filterIcon: (filtered) => , - onFilter: (value, record) => record.name.toLowerCase().includes((value as string).toLowerCase()), - filterDropdownProps: { - onOpenChange: open => open && setTimeout(() => searchInput.current?.select(), 100), - }, - filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => ( -
e.stopPropagation()}> - setSelectedKeys([e.target.value])} - onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })} - onSearch={() => confirm({ closeDropdown: true })} - /> -
- ), - }, - { - title: "Roles", - key: "roles", - render: (_, user) => ( - [...user.roleIds] - .map(id => e.stopPropagation()}> - {(roles.get(id) || { name: id }).name} - ) - ), - }, - { - title: "Online time", - key: "onlineTime", - render: (_, user) => (user.playerStats?.onlineTimeMs - ? formatDuration(user.playerStats.onlineTimeMs) : null), - sorter: (a, b) => (a.playerStats?.onlineTimeMs ?? 0) - - (b.playerStats?.onlineTimeMs ?? 0), - responsive: ["lg"], - }, - { - title: "First seen", - key: "firstSeen", - render: (_, user) => formatFirstSeen(user), - sorter: (a, b) => sortFirstSeen(a, b), - }, - { - title: "Last seen", - key: "lastSeen", - render: (_, user) => formatLastSeen(user), - sorter: (a, b) => sortLastSeen(a, b), - responsive: ["lg"], - }, - ]} - dataSource={[...users.values()]} - pagination={{ - defaultPageSize: 50, - showSizeChanger: true, - pageSizeOptions: ["10", "20", "50", "100", "200"], - showTotal: total => `${total} Users`, - }} - rowKey={user => user.name} - onRow={(user, rowIndex) => ({ - onClick: event => { - navigate(`/users/${user.name}/view`); - }, - })} - /> + ; } diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx new file mode 100644 index 00000000..f0e0d19a --- /dev/null +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -0,0 +1,194 @@ +import React, { useRef } from "react"; +import { Table, Tag, Space, Input, InputRef } from "antd"; +import { SearchOutlined } from "@ant-design/icons"; +import { useNavigate } from "react-router-dom"; + +import * as lib from "@clusterio/lib"; + +import { useRoles } from "../model/roles"; +import { useUsers } from "../model/user"; +import { formatDuration } from "../util/time_format"; +import { + formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, +} from "../model/user"; +import Link from "./Link"; + +export interface UsersTableProps { + /** + * Optional instance id. + * – If provided, instance-specific columns (Play Time, Join Count, First Seen) are shown. + * – If omitted, global player statistics columns (Online Time, First/Last Seen) are shown. + */ + instanceId?: number; + /** Show only players that are currently online. When instanceId is provided players must be online on that instance. */ + onlyOnline?: boolean; + /** Ant Design pagination prop. Pass `false` to disable pagination. */ + pagination?: false | object; + /** Ant Design size prop */ + size?: "small" | "middle" | "large"; +} + +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; + +export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) { + const [roles] = useRoles(); + const navigate = useNavigate(); + const searchInput = useRef(null); + + const [users] = useUsers(); + + let data = [...users.values()]; + + if (onlyOnline) { + if (instanceId !== undefined) { + data = data.filter(u => u.instances && u.instances.has(instanceId)); + } else { + data = data.filter(u => u.instances && u.instances.size > 0); + } + } + + const columns: any[] = [ + { + title: "Name", + key: "name", + render: (_: any, user: lib.User) => ( + + {user.name} + + {user.isAdmin && Admin} + {user.isWhitelisted && Whitelisted} + {user.isBanned && Banned} + + + ), + defaultSortOrder: "ascend", + sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name), + filterIcon: (filtered: boolean) => ( + + ), + onFilter: (value: string | number | boolean, record: lib.User) => + record.name.toLowerCase().includes((value as string).toLowerCase()), + filterDropdownProps: { + onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100), + }, + filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: any) => ( +
e.stopPropagation()}> + setSelectedKeys([e.target.value])} + onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })} + onSearch={() => confirm({ closeDropdown: true })} + /> +
+ ), + }, + { + title: "Roles", + key: "roles", + render: (_: any, user: lib.User) => ( + [...user.roleIds].map((id) => ( + e.stopPropagation()}> + {(roles.get(id) || { name: id }).name} + + )) + ), + }, + ]; + + if (instanceId !== undefined) { + columns.push( + { + title: "Play Time", + key: "playTime", + render: (_: any, user: lib.User) => { + const instanceStats = user.instanceStats.get(instanceId); + return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-"; + }, + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); + }, + }, + { + title: "Join Count", + key: "joinCount", + render: (_: any, user: lib.User) => { + const instanceStats = user.instanceStats.get(instanceId); + return instanceStats?.joinCount ?? 0; + }, + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); + }, + }, + { + title: "First Seen", + key: "firstSeen", + render: (_: any, user: lib.User) => formatFirstSeen(user, instanceId), + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0; + const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0; + return firstSeenA - firstSeenB; + }, + }, + ); + } else { + columns.push( + { + title: "Online time", + key: "onlineTime", + render: (_: any, user: lib.User) => + user.playerStats?.onlineTimeMs ? formatDuration(user.playerStats.onlineTimeMs) : null, + sorter: (a: lib.User, b: lib.User) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0), + responsive: ["lg"], + }, + { + title: "First seen", + key: "firstSeen", + render: (_: any, user: lib.User) => formatFirstSeen(user), + sorter: (a: lib.User, b: lib.User) => sortFirstSeen(a, b), + }, + { + title: "Last seen", + key: "lastSeen", + render: (_: any, user: lib.User) => formatLastSeen(user), + sorter: (a: lib.User, b: lib.User) => sortLastSeen(a, b), + responsive: ["lg"], + }, + ); + } + + const defaultPagination = pagination === undefined + ? { + defaultPageSize: 50, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50", "100", "200"], + showTotal: (total: number) => `${total} Users`, + } + : pagination; + + return ( +
user.name} + pagination={defaultPagination} + size={size} + onRow={(user) => ({ + onClick: (event) => { + if ((event.target as HTMLElement).closest("a")) { + return; + } + navigate(`/users/${user.name}/view`); + }, + })} + /> + ); +}