From 87d5aa259c78817f29a615bafb418764fb7910f1 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 14 Feb 2026 13:15:37 +0100 Subject: [PATCH] feat: add users management page with search, editing, and inventory control Implements comprehensive user management interface for admin panel: - Search and filter users by username, class, and active status - Sort by username, level, balance, or XP with pagination - View and edit user details (balance, XP, level, class, daily streak, active status) - Manage user inventories (add/remove items with quantities) - Debounced search input (300ms delay) - Responsive design (mobile full-screen, desktop slide-in panel) - Draft state management with unsaved changes tracking - Keyboard shortcuts (Escape to close detail panel) Co-Authored-By: Claude Sonnet 4.5 --- panel/src/App.tsx | 3 + panel/src/lib/useUsers.ts | 343 ++++++++++++ panel/src/pages/Users.tsx | 1062 +++++++++++++++++++++++++++++++++++++ 3 files changed, 1408 insertions(+) create mode 100644 panel/src/lib/useUsers.ts create mode 100644 panel/src/pages/Users.tsx diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 09b301c..9f44198 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react"; import Layout, { type Page } from "./components/Layout"; import Dashboard from "./pages/Dashboard"; import Settings from "./pages/Settings"; +import Users from "./pages/Users"; import PlaceholderPage from "./pages/PlaceholderPage"; const placeholders: Record = { @@ -75,6 +76,8 @@ export default function App() { {page === "dashboard" ? ( + ) : page === "users" ? ( + ) : page === "settings" ? ( ) : ( diff --git a/panel/src/lib/useUsers.ts b/panel/src/lib/useUsers.ts new file mode 100644 index 0000000..16544f1 --- /dev/null +++ b/panel/src/lib/useUsers.ts @@ -0,0 +1,343 @@ +import { useCallback, useEffect, useState } from "react"; +import { get, put, post, del } from "./api"; + +export interface User { + id: string; + username: string; + classId: string | null; + isActive: boolean; + balance: string; + xp: string; + level: number; + dailyStreak: number; + settings: Record; + createdAt: string; + updatedAt: string; + class?: Class; +} + +export interface Class { + id: string; + name: string; + description: string; + icon: string; +} + +export interface Item { + id: number; + name: string; + description: string; + rarity: string; + sellPrice: string; + buyPrice: string; +} + +export interface InventoryEntry { + userId: string; + itemId: number; + quantity: string; + item?: Item; +} + +export interface UserFilters { + search: string; + classId: string | null; + isActive: boolean | null; + sortBy: "username" | "level" | "balance" | "xp"; + sortOrder: "asc" | "desc"; +} + +export function useUsers() { + // User list state + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(50); + + // Filters + const [filters, setFiltersState] = useState({ + search: "", + classId: null, + isActive: null, + sortBy: "balance", + sortOrder: "desc", + }); + + // Detail panel state + const [selectedUser, setSelectedUser] = useState(null); + const [userDraft, setUserDraft] = useState | null>(null); + const [inventoryDraft, setInventoryDraft] = useState([]); + + // Reference data + const [classes, setClasses] = useState([]); + const [items, setItems] = useState([]); + + // UI state + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Fetch users with filters and pagination + const fetchUsers = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const params = new URLSearchParams(); + if (filters.search) params.set("search", filters.search); + if (filters.classId) params.set("classId", filters.classId); + if (filters.isActive !== null) params.set("isActive", String(filters.isActive)); + params.set("sortBy", filters.sortBy); + params.set("sortOrder", filters.sortOrder); + params.set("limit", String(limit)); + params.set("offset", String((currentPage - 1) * limit)); + + const data = await get<{ users: User[]; total: number }>( + `/api/users?${params.toString()}` + ); + + setUsers(data.users); + setTotal(data.total); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load users"); + } finally { + setLoading(false); + } + }, [filters, currentPage, limit]); + + // Fetch single user by ID + const fetchUserById = useCallback(async (id: string) => { + try { + const user = await get(`/api/users/${id}`); + return user; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load user"); + return null; + } + }, []); + + // Fetch classes for filter dropdown + const fetchClasses = useCallback(async () => { + try { + const data = await get<{ classes: Class[] }>("/api/classes"); + setClasses(data.classes || []); + } catch (e) { + console.error("Failed to load classes:", e); + setClasses([]); + } + }, []); + + // Fetch items for inventory management + const fetchItems = useCallback(async () => { + try { + const data = await get<{ items: Item[]; total: number }>("/api/items"); + setItems(data.items || []); + } catch (e) { + console.error("Failed to load items:", e); + setItems([]); + } + }, []); + + // Fetch user inventory + const fetchInventory = useCallback(async (userId: string) => { + try { + const data = await get<{ inventory: InventoryEntry[] }>( + `/api/users/${userId}/inventory` + ); + setInventoryDraft(data.inventory); + return data.inventory; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load inventory"); + return []; + } + }, []); + + // Update user + const updateUser = useCallback(async (id: string, data: Partial) => { + try { + setSaving(true); + setError(null); + + const result = await put<{ success: boolean; user: User }>( + `/api/users/${id}`, + data + ); + + return result.user; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update user"); + return null; + } finally { + setSaving(false); + } + }, []); + + // Add item to inventory + const addInventoryItem = useCallback( + async (userId: string, itemId: number, quantity: string) => { + try { + await post(`/api/users/${userId}/inventory`, { itemId, quantity }); + await fetchInventory(userId); + return true; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to add item"); + return false; + } + }, + [fetchInventory] + ); + + // Remove item from inventory + const removeInventoryItem = useCallback( + async (userId: string, itemId: number) => { + try { + await del(`/api/users/${userId}/inventory/${itemId}`); + await fetchInventory(userId); + return true; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to remove item"); + return false; + } + }, + [fetchInventory] + ); + + // Set filters and reset to page 1 + const setFilters = useCallback((newFilters: Partial) => { + setFiltersState((prev) => ({ ...prev, ...newFilters })); + setCurrentPage(1); + }, []); + + // Debounced search setter + const setSearchDebounced = useCallback( + (() => { + let timeoutId: NodeJS.Timeout; + return (search: string) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + setFilters({ search }); + }, 300); + }; + })(), + [setFilters] + ); + + // Navigate to page + const setPage = useCallback((page: number) => { + setCurrentPage(page); + }, []); + + // Select user and open detail panel + const selectUser = useCallback( + async (user: User) => { + setSelectedUser(user); + + // Fetch fresh data + const freshUser = await fetchUserById(user.id); + if (freshUser) { + setSelectedUser(freshUser); + setUserDraft(structuredClone(freshUser)); + await fetchInventory(freshUser.id); + } + }, + [fetchUserById, fetchInventory] + ); + + // Close detail panel + const closeDetail = useCallback(() => { + setSelectedUser(null); + setUserDraft(null); + setInventoryDraft([]); + }, []); + + // Update draft field + const updateDraft = useCallback((field: keyof User, value: unknown) => { + setUserDraft((prev) => { + if (!prev) return null; + return { ...prev, [field]: value }; + }); + }, []); + + // Save draft changes + const saveDraft = useCallback(async () => { + if (!selectedUser || !userDraft) return false; + + const updated = await updateUser(selectedUser.id, userDraft); + if (updated) { + setSelectedUser(updated); + setUserDraft(structuredClone(updated)); + + // Refresh the list to show updated data + await fetchUsers(); + return true; + } + + return false; + }, [selectedUser, userDraft, updateUser, fetchUsers]); + + // Discard draft changes + const discardDraft = useCallback(() => { + if (selectedUser) { + setUserDraft(structuredClone(selectedUser)); + } + }, [selectedUser]); + + // Check if draft has changes + const isDirty = useCallback(() => { + if (!selectedUser || !userDraft) return false; + return JSON.stringify(selectedUser) !== JSON.stringify(userDraft); + }, [selectedUser, userDraft]); + + // Initial load + useEffect(() => { + fetchUsers(); + fetchClasses(); + fetchItems(); + }, [fetchUsers, fetchClasses, fetchItems]); + + return { + // User list + users, + total, + currentPage, + limit, + setLimit, + + // Filters + filters, + setFilters, + setSearchDebounced, + + // Pagination + setPage, + + // Detail panel + selectedUser, + selectUser, + closeDetail, + + // Editing + userDraft, + updateDraft, + saveDraft, + discardDraft, + isDirty: isDirty(), + + // Inventory + inventoryDraft, + addInventoryItem, + removeInventoryItem, + + // Reference data + classes, + items, + + // UI state + loading, + saving, + error, + + // Actions + refetch: fetchUsers, + }; +} diff --git a/panel/src/pages/Users.tsx b/panel/src/pages/Users.tsx new file mode 100644 index 0000000..73bb9ed --- /dev/null +++ b/panel/src/pages/Users.tsx @@ -0,0 +1,1062 @@ +import { useState, useEffect } from "react"; +import { + Loader2, + AlertTriangle, + Save, + Check, + UserCircle2, + Search, + X, + ChevronLeft, + ChevronRight, + Package, + Plus, + Trash2, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { useUsers, type User, type InventoryEntry } from "../lib/useUsers"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatNumber(num: number | string): string { + const n = typeof num === "string" ? parseInt(num) : num; + return n.toLocaleString(); +} + +function formatBigInt(value: string): string { + try { + const num = BigInt(value); + return num.toLocaleString(); + } catch { + return value; + } +} + +// --------------------------------------------------------------------------- +// Reusable field components (from Settings.tsx) +// --------------------------------------------------------------------------- + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +function NumberInput({ + value, + onChange, + min, + max, + step, + className, +}: { + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + step?: number; + className?: string; +}) { + return ( + onChange(Number(e.target.value))} + min={min} + max={max} + step={step} + className={cn( + "w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground", + "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", + "transition-colors", + className + )} + /> + ); +} + +function StringInput({ + value, + onChange, + placeholder, + className, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + className?: string; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className={cn( + "w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground", + "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", + "transition-colors", + className + )} + /> + ); +} + +function Toggle({ + checked, + onChange, +}: { + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +function SelectInput({ + value, + onChange, + options, +}: { + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; +}) { + return ( + + ); +} + +function SectionCard({ + title, + icon: Icon, + children, +}: { + title: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// SearchFilterBar Component +// --------------------------------------------------------------------------- + +function SearchFilterBar({ + search, + onSearchChange, + classId, + onClassChange, + isActive, + onActiveChange, + sortBy, + onSortByChange, + sortOrder, + onSortOrderChange, + onClear, + classes, +}: { + search: string; + onSearchChange: (v: string) => void; + classId: string | null; + onClassChange: (v: string | null) => void; + isActive: boolean | null; + onActiveChange: (v: boolean | null) => void; + sortBy: string; + onSortByChange: (v: string) => void; + sortOrder: string; + onSortOrderChange: (v: string) => void; + onClear: () => void; + classes: { id: string; name: string }[]; +}) { + return ( +
+ {/* Search input */} +
+ + onSearchChange(e.target.value)} + placeholder="Search by username..." + className={cn( + "w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground", + "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", + "transition-colors" + )} + /> +
+ + {/* Class filter */} + + + {/* Active status filter */} + + + {/* Sort by */} + + + {/* Sort order */} + + + {/* Clear filters */} + +
+ ); +} + +// --------------------------------------------------------------------------- +// UserTable Component +// --------------------------------------------------------------------------- + +function UserTable({ + users, + loading, + onSelectUser, +}: { + users: User[]; + loading: boolean; + onSelectUser: (user: User) => void; +}) { + if (loading) { + return ( +
+
+ + + + + + + + + + + + {[...Array(5)].map((_, i) => ( + + + + + + + + ))} + +
+ Username + + Level + + Balance + + Class + + Status +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (users.length === 0) { + return ( +
+ +

+ No users found +

+

+ Try adjusting your search or filter criteria +

+
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {users.map((user) => ( + onSelectUser(user)} + className="border-b border-border hover:bg-raised cursor-pointer transition-colors" + > + + + + + + + + ))} + +
+ Username + + Level + + Balance + + XP + + Class + + Status +
+
+
+ +
+
+

+ {user.username} +

+

+ {user.id} +

+
+
+
+ + {user.level} + + + + {formatBigInt(user.balance)} + + + + {formatBigInt(user.xp)} + + + + {user.class?.name || "—"} + + + + {user.isActive ? "Active" : "Inactive"} + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Pagination Component +// --------------------------------------------------------------------------- + +function Pagination({ + currentPage, + totalPages, + limit, + total, + onPageChange, + onLimitChange, +}: { + currentPage: number; + totalPages: number; + limit: number; + total: number; + onPageChange: (page: number) => void; + onLimitChange: (limit: number) => void; +}) { + const startItem = (currentPage - 1) * limit + 1; + const endItem = Math.min(currentPage * limit, total); + + // Calculate page numbers to show + const getPageNumbers = () => { + const pages: (number | string)[] = []; + const showPages = 5; + const halfShow = Math.floor(showPages / 2); + + let start = Math.max(1, currentPage - halfShow); + let end = Math.min(totalPages, start + showPages - 1); + + if (end - start < showPages - 1) { + start = Math.max(1, end - showPages + 1); + } + + if (start > 1) { + pages.push(1); + if (start > 2) pages.push("..."); + } + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (end < totalPages) { + if (end < totalPages - 1) pages.push("..."); + pages.push(totalPages); + } + + return pages; + }; + + return ( +
+ {/* Items info */} +

+ Showing {startItem}–{endItem} of {formatNumber(total)} users +

+ + {/* Page controls */} +
+ {/* Previous button */} + + + {/* Page numbers */} + {getPageNumbers().map((page, i) => + typeof page === "number" ? ( + + ) : ( + + {page} + + ) + )} + + {/* Next button */} + + + {/* Items per page */} + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// InventoryAddForm Component +// --------------------------------------------------------------------------- + +function InventoryAddForm({ + items, + onAdd, +}: { + items: { id: number; name: string }[]; + onAdd: (itemId: number, quantity: string) => void; +}) { + const [selectedItemId, setSelectedItemId] = useState(""); + const [quantity, setQuantity] = useState("1"); + + const handleAdd = () => { + if (!selectedItemId) return; + onAdd(parseInt(selectedItemId), quantity); + setSelectedItemId(""); + setQuantity("1"); + }; + + return ( +
+

Add Item

+
+ + setQuantity(e.target.value)} + min="1" + placeholder="Qty" + className={cn( + "w-20 bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground", + "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", + "transition-colors" + )} + /> + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// DetailPanel Component +// --------------------------------------------------------------------------- + +function DetailPanel({ + user, + userDraft, + onClose, + onUpdateDraft, + onSave, + onDiscard, + isDirty, + saving, + saveSuccess, + classes, + inventoryDraft, + items, + onAddItem, + onRemoveItem, +}: { + user: User; + userDraft: Partial | null; + onClose: () => void; + onUpdateDraft: (field: keyof User, value: unknown) => void; + onSave: () => void; + onDiscard: () => void; + isDirty: boolean; + saving: boolean; + saveSuccess: boolean; + classes: { id: string; name: string }[]; + inventoryDraft: InventoryEntry[]; + items: { id: number; name: string }[]; + onAddItem: (itemId: number, quantity: string) => void; + onRemoveItem: (itemId: number) => void; +}) { + if (!userDraft) return null; + + const classOptions = [ + { value: "", label: "No Class" }, + ...(Array.isArray(classes) ? classes.map((c) => ({ value: c.id, label: c.name })) : []), + ]; + + return ( +
+
+ {/* Header */} +
+
+

+ {user.username} +

+

{user.id}

+

+ Joined {new Date(user.createdAt).toLocaleDateString()} +

+
+ +
+ + {/* User Info (Editable) */} +
+ + onUpdateDraft("balance", v)} + placeholder="0" + /> + + + + onUpdateDraft("xp", v)} + placeholder="0" + /> + + + + onUpdateDraft("level", v)} + min={1} + max={100} + /> + + + + onUpdateDraft("dailyStreak", v)} + min={0} + /> + + + + onUpdateDraft("classId", v || null)} + options={classOptions} + /> + + + +
+ onUpdateDraft("isActive", v)} + /> + + {userDraft.isActive ? "Active" : "Inactive"} + +
+
+
+ + {/* Inventory Section */} + + {inventoryDraft.length === 0 ? ( +

No items in inventory

+ ) : ( +
+ {inventoryDraft.map((entry) => ( +
+
+

+ {entry.item?.name || `Item #${entry.itemId}`} +

+

+ Quantity: {formatBigInt(entry.quantity)} +

+
+ +
+ ))} +
+ )} + + {/* Add Item Form */} +
+ +
+
+
+ + {/* Sticky footer for save/discard (only shown when dirty) */} + {isDirty && ( +
+
+ + You have unsaved changes +
+
+ + +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Users Component +// --------------------------------------------------------------------------- + +export default function Users() { + const { + users, + total, + currentPage, + limit, + setLimit, + filters, + setFilters, + setSearchDebounced, + setPage, + selectedUser, + selectUser, + closeDetail, + userDraft, + updateDraft, + saveDraft, + discardDraft, + isDirty, + inventoryDraft, + addInventoryItem, + removeInventoryItem, + classes, + items, + loading, + saving, + error, + } = useUsers(); + + const [saveSuccess, setSaveSuccess] = useState(false); + const [searchInput, setSearchInput] = useState(filters.search); + + // Sync searchInput with filters.search when it changes externally + useEffect(() => { + setSearchInput(filters.search); + }, [filters.search]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && selectedUser) { + if (isDirty) { + const confirm = window.confirm( + "You have unsaved changes. Are you sure you want to close?" + ); + if (confirm) closeDetail(); + } else { + closeDetail(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedUser, isDirty, closeDetail]); + + const handleSave = async () => { + const success = await saveDraft(); + if (success) { + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + } + }; + + const handleClearFilters = () => { + setSearchInput(""); + setFilters({ + search: "", + classId: null, + isActive: null, + sortBy: "balance", + sortOrder: "desc", + }); + }; + + const handleSearchChange = (value: string) => { + setSearchInput(value); + setSearchDebounced(value); + }; + + return ( +
+ {/* Header */} +
+

Users

+ setFilters({ classId: v })} + isActive={filters.isActive} + onActiveChange={(v) => setFilters({ isActive: v })} + sortBy={filters.sortBy} + onSortByChange={(v) => setFilters({ sortBy: v as any })} + sortOrder={filters.sortOrder} + onSortOrderChange={(v) => setFilters({ sortOrder: v as any })} + onClear={handleClearFilters} + classes={classes} + /> +
+ + {/* Error banner */} + {error && ( +
+ +
+

Error

+

{error}

+
+
+ )} + + {/* Content area */} +
+ {/* Main content */} +
+ + +
+ + {/* Detail panel */} + {selectedUser && userDraft && ( + + addInventoryItem(selectedUser.id, itemId, quantity) + } + onRemoveItem={(itemId) => removeInventoryItem(selectedUser.id, itemId)} + /> + )} +
+
+ ); +}