From e3c49effdbbb10740ab48faf77759b1f8c233060 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 5 Apr 2026 16:19:23 +0200 Subject: [PATCH] feat(panel): replace all placeholder pages with real admin views - Classes: full CRUD with list + detail panel - Quests: CRUD with search, trigger events and reward fields - Lootdrops: stat cards, spawn form, filter tabs, cancel action - Moderation: case list with filters, detail panel, create + resolve - Transactions: color-coded amounts, type/user filters, pagination - Rename player dashboard currency label from Gold to AU (Astral Units) - Remove unused placeholders map from App.tsx Co-Authored-By: Claude Opus 4.6 (1M context) --- panel/src/App.tsx | 39 +- panel/src/pages/Classes.tsx | 613 ++++++++++++++++ panel/src/pages/Lootdrops.tsx | 563 +++++++++++++++ panel/src/pages/Moderation.tsx | 1012 +++++++++++++++++++++++++++ panel/src/pages/PlayerDashboard.tsx | 2 +- panel/src/pages/Quests.tsx | 574 +++++++++++++++ panel/src/pages/Transactions.tsx | 376 ++++++++++ 7 files changed, 3149 insertions(+), 30 deletions(-) create mode 100644 panel/src/pages/Classes.tsx create mode 100644 panel/src/pages/Lootdrops.tsx create mode 100644 panel/src/pages/Moderation.tsx create mode 100644 panel/src/pages/Quests.tsx create mode 100644 panel/src/pages/Transactions.tsx diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 20a9657..ba98deb 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -6,36 +6,17 @@ import Dashboard from "./pages/Dashboard"; import Settings from "./pages/Settings"; import Users from "./pages/Users"; import Items from "./pages/Items"; -import PlaceholderPage from "./pages/PlaceholderPage"; +import Classes from "./pages/Classes"; +import Quests from "./pages/Quests"; +import Lootdrops from "./pages/Lootdrops"; +import Moderation from "./pages/Moderation"; +import Transactions from "./pages/Transactions"; import Leaderboards from "./pages/Leaderboards"; import NotEnrolled from "./pages/NotEnrolled"; import PlayerDashboard from "./pages/PlayerDashboard"; import { GameLobby } from "./games/GameLobby"; import { GameRoom } from "./games/GameRoom"; -const placeholders: Record = { - classes: { - title: "Classes", - description: "Manage academy classes, assign Discord roles, and track class balances.", - }, - quests: { - title: "Quests", - description: "Configure quests with trigger events, targets, and XP/balance rewards.", - }, - lootdrops: { - title: "Lootdrops", - description: "View active lootdrops, spawn new drops, and manage lootdrop history.", - }, - moderation: { - title: "Moderation", - description: "Review moderation cases — warnings, timeouts, kicks, bans — and manage appeals.", - }, - transactions: { - title: "Transactions", - description: "Browse the economy transaction log with filtering by user, type, and date.", - }, -}; - function AppRoutes() { const { loading, user, enrolled, logout } = useAuth(); @@ -87,11 +68,11 @@ function AppRoutes() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> )} diff --git a/panel/src/pages/Classes.tsx b/panel/src/pages/Classes.tsx new file mode 100644 index 0000000..250e661 --- /dev/null +++ b/panel/src/pages/Classes.tsx @@ -0,0 +1,613 @@ +import { useState, useEffect, useCallback } from "react"; +import { + GraduationCap, + AlertTriangle, + Loader2, + Plus, + Save, + Trash2, + X, + Coins, + Hash, + Shield, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { get, post, put, del } from "../lib/api"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ClassData { + id: string; + name: string; + balance: string; + roleId: string | null; +} + +interface ClassDraft { + id: string; + name: string; + balance: string; + roleId: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatBalance(balance: string | number): string { + const n = typeof balance === "string" ? parseInt(balance) : balance; + return n.toLocaleString(); +} + +function emptyDraft(): ClassDraft { + return { id: "", name: "", balance: "0", roleId: "" }; +} + +function classToDraft(c: ClassData): ClassDraft { + return { + id: c.id, + name: c.name, + balance: c.balance ?? "0", + roleId: c.roleId ?? "", + }; +} + +// --------------------------------------------------------------------------- +// ClassDetailPanel +// --------------------------------------------------------------------------- + +function ClassDetailPanel({ + isNew, + draft, + onUpdate, + onSave, + onDelete, + onClose, + saving, + saveSuccess, + isDirty, +}: { + isNew: boolean; + draft: ClassDraft; + onUpdate: (patch: Partial) => void; + onSave: () => void; + onDelete: () => void; + onClose: () => void; + saving: boolean; + saveSuccess: boolean; + isDirty: boolean; +}) { + return ( +
+ {/* Panel header */} +
+

+ {isNew ? "Create Class" : "Edit Class"} +

+ +
+ + {/* Form fields */} +
+ {/* ID field (only editable on create) */} +
+ + onUpdate({ id: e.target.value })} + disabled={!isNew} + placeholder="Discord role snowflake ID" + className={cn( + "w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground", + "focus:outline-none focus:border-primary transition-colors", + "placeholder:text-text-disabled", + !isNew && "opacity-50 cursor-not-allowed" + )} + /> + {isNew && ( +

+ Use a unique numeric identifier (e.g. Discord role ID). +

+ )} +
+ + {/* Name */} +
+ + onUpdate({ name: e.target.value })} + placeholder="e.g. Warrior, Mage, Rogue" + className={cn( + "w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground", + "focus:outline-none focus:border-primary transition-colors", + "placeholder:text-text-disabled" + )} + /> +
+ + {/* Balance */} +
+ + onUpdate({ balance: e.target.value.replace(/[^0-9-]/g, "") })} + placeholder="0" + className={cn( + "w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground font-mono", + "focus:outline-none focus:border-primary transition-colors", + "placeholder:text-text-disabled" + )} + /> +
+ + {/* Role ID */} +
+ + onUpdate({ roleId: e.target.value })} + placeholder="Optional Discord role snowflake" + className={cn( + "w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground font-mono", + "focus:outline-none focus:border-primary transition-colors", + "placeholder:text-text-disabled" + )} + /> +
+
+ + {/* Panel footer */} +
+ {/* Save status */} + {saveSuccess && ( +

+ Saved successfully +

+ )} + +
+ + + {!isNew && ( + + )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// ClassTable +// --------------------------------------------------------------------------- + +function ClassTable({ + classes, + loading, + selectedId, + onSelect, +}: { + classes: ClassData[]; + loading: boolean; + selectedId: string | null; + onSelect: (c: ClassData) => void; +}) { + const columns = ["ID", "Name", "Balance", "Role ID"]; + + if (loading) { + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {[...Array(4)].map((_, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+
+
+
+
+ ); + } + + if (classes.length === 0) { + return ( +
+ +

+ No classes found +

+

+ Create your first class to get started. +

+
+ ); + } + + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {classes.map((c) => ( + onSelect(c)} + className={cn( + "cursor-pointer transition-colors", + selectedId === c.id + ? "bg-primary/10" + : "hover:bg-raised/40" + )} + > + + + + + + ))} + +
+ {col} +
+ + {c.id} + + + + {c.name} + + + + {formatBalance(c.balance)} + + + + {c.roleId || "---"} + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main Classes Component +// --------------------------------------------------------------------------- + +export default function Classes() { + const [classes, setClasses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + + // Selection & draft state + const [selectedClass, setSelectedClass] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [draft, setDraft] = useState(emptyDraft()); + const [originalDraft, setOriginalDraft] = useState(emptyDraft()); + + // ----------------------------------------------------------------------- + // Fetch + // ----------------------------------------------------------------------- + + const fetchClasses = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await get<{ classes: ClassData[] }>("/api/classes"); + setClasses(data.classes ?? []); + } catch (e: any) { + setError(e?.error ?? "Failed to fetch classes"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchClasses(); + }, [fetchClasses]); + + // ----------------------------------------------------------------------- + // Selection helpers + // ----------------------------------------------------------------------- + + const isDirty = + draft.name !== originalDraft.name || + draft.balance !== originalDraft.balance || + draft.roleId !== originalDraft.roleId || + (isCreating && draft.id !== originalDraft.id); + + const selectClass = (c: ClassData) => { + if (isDirty) { + const confirmed = window.confirm( + "You have unsaved changes. Discard them?" + ); + if (!confirmed) return; + } + setIsCreating(false); + setSelectedClass(c); + const d = classToDraft(c); + setDraft(d); + setOriginalDraft(d); + setSaveSuccess(false); + }; + + const startCreate = () => { + if (isDirty) { + const confirmed = window.confirm( + "You have unsaved changes. Discard them?" + ); + if (!confirmed) return; + } + setSelectedClass(null); + setIsCreating(true); + const d = emptyDraft(); + setDraft(d); + setOriginalDraft(d); + setSaveSuccess(false); + }; + + const closePanel = () => { + if (isDirty) { + const confirmed = window.confirm( + "You have unsaved changes. Discard them?" + ); + if (!confirmed) return; + } + setSelectedClass(null); + setIsCreating(false); + setSaveSuccess(false); + }; + + // Keyboard shortcut + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && (selectedClass || isCreating)) { + closePanel(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedClass, isCreating, isDirty]); + + // ----------------------------------------------------------------------- + // CRUD + // ----------------------------------------------------------------------- + + const handleSave = async () => { + try { + setSaving(true); + setError(null); + + if (isCreating) { + if (!draft.id || !draft.name.trim()) { + setError("ID and Name are required."); + return; + } + await post("/api/classes", { + id: draft.id, + name: draft.name.trim(), + balance: draft.balance || "0", + roleId: draft.roleId || null, + }); + } else if (selectedClass) { + await put(`/api/classes/${selectedClass.id}`, { + name: draft.name.trim(), + balance: draft.balance || "0", + roleId: draft.roleId || null, + }); + } + + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + + await fetchClasses(); + + // If creating, switch to edit mode for the new class + if (isCreating) { + setIsCreating(false); + const created = (await get<{ classes: ClassData[] }>("/api/classes")).classes.find( + (c) => c.id === draft.id + ); + if (created) { + setSelectedClass(created); + const d = classToDraft(created); + setDraft(d); + setOriginalDraft(d); + } + } else { + // Update original draft so isDirty resets + setOriginalDraft({ ...draft }); + } + } catch (e: any) { + setError(e?.error ?? "Failed to save class"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!selectedClass) return; + const confirmed = window.confirm( + `Delete class "${selectedClass.name}"? Users in this class will need to be reassigned.` + ); + if (!confirmed) return; + + try { + setSaving(true); + setError(null); + await del(`/api/classes/${selectedClass.id}`); + setSelectedClass(null); + setIsCreating(false); + await fetchClasses(); + } catch (e: any) { + setError(e?.error ?? "Failed to delete class"); + } finally { + setSaving(false); + } + }; + + // ----------------------------------------------------------------------- + // Render + // ----------------------------------------------------------------------- + + const panelOpen = selectedClass !== null || isCreating; + + return ( +
+ {/* Header */} +
+
+

+ Classes +

+ +
+

+ Manage academy classes, assign Discord roles, and track class balances. +

+
+ + {/* Error banner */} + {error && ( +
+ +
+

Error

+

{error}

+
+ +
+ )} + + {/* Content area */} +
+ {/* Main content */} +
+ + + {!loading && classes.length > 0 && ( +

+ {classes.length} class{classes.length !== 1 ? "es" : ""} total +

+ )} +
+ + {/* Detail panel */} + {panelOpen && ( + setDraft((prev) => ({ ...prev, ...patch }))} + onSave={handleSave} + onDelete={handleDelete} + onClose={closePanel} + saving={saving} + saveSuccess={saveSuccess} + isDirty={isDirty} + /> + )} +
+
+ ); +} diff --git a/panel/src/pages/Lootdrops.tsx b/panel/src/pages/Lootdrops.tsx new file mode 100644 index 0000000..5fcb687 --- /dev/null +++ b/panel/src/pages/Lootdrops.tsx @@ -0,0 +1,563 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Gift, + Loader2, + AlertTriangle, + RefreshCw, + Clock, + Coins, + User, + Hash, + Trash2, + Send, + Check, + X, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { get, post, del, type ApiError } from "../lib/api"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Lootdrop { + messageId: string; + channelId: string; + rewardAmount: number; + currency: string; + claimedBy: string | null; + createdAt: string; + expiresAt: string | null; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatTimestamp(iso: string): string { + const date = new Date(iso); + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function isActive(drop: Lootdrop): boolean { + if (drop.claimedBy) return false; + if (!drop.expiresAt) return true; + return new Date(drop.expiresAt).getTime() > Date.now(); +} + +function getStatus(drop: Lootdrop): "active" | "claimed" | "expired" { + if (drop.claimedBy) return "claimed"; + if (!drop.expiresAt) return "active"; + return new Date(drop.expiresAt).getTime() > Date.now() ? "active" : "expired"; +} + +const STATUS_STYLES: Record = { + active: "bg-success/20 text-success", + claimed: "bg-primary/20 text-primary", + expired: "bg-text-tertiary/20 text-text-tertiary", +}; + +const STATUS_LABELS: Record = { + active: "Active", + claimed: "Claimed", + expired: "Expired", +}; + +// --------------------------------------------------------------------------- +// SpawnForm +// --------------------------------------------------------------------------- + +function SpawnForm({ onSpawned }: { onSpawned: () => void }) { + const [channelId, setChannelId] = useState(""); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!channelId.trim()) return; + + setSubmitting(true); + setResult(null); + + try { + const body: Record = { channelId: channelId.trim() }; + if (amount) body.amount = parseInt(amount); + if (currency.trim()) body.currency = currency.trim(); + + await post("/api/lootdrops", body); + setResult({ ok: true, message: "Lootdrop spawned successfully." }); + setChannelId(""); + setAmount(""); + setCurrency(""); + onSpawned(); + } catch (err) { + const apiErr = err as ApiError; + setResult({ ok: false, message: apiErr.error ?? "Failed to spawn lootdrop." }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+

+ + Spawn Lootdrop +

+ +
+
+ + setChannelId(e.target.value)} + placeholder="e.g. 1234567890123456" + required + className={cn( + "w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground", + "focus:outline-none focus:border-primary", + "transition-colors placeholder:text-text-disabled" + )} + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="Random if empty" + min="1" + className={cn( + "w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground", + "focus:outline-none focus:border-primary", + "transition-colors placeholder:text-text-disabled" + )} + /> +
+ +
+ + setCurrency(e.target.value)} + placeholder="Default from config" + className={cn( + "w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground", + "focus:outline-none focus:border-primary", + "transition-colors placeholder:text-text-disabled" + )} + /> +
+
+ +
+ + + {result && ( + + {result.ok ? : } + {result.message} + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// LootdropTable +// --------------------------------------------------------------------------- + +function LootdropTable({ + drops, + loading, + onDelete, + deletingId, +}: { + drops: Lootdrop[]; + loading: boolean; + onDelete: (messageId: string) => void; + deletingId: string | null; +}) { + const columns = ["Status", "Reward", "Channel", "Claimed By", "Created", "Expires", ""]; + + if (loading) { + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {[...Array(5)].map((_, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+
+
+
+
+ ); + } + + if (drops.length === 0) { + return ( +
+ +

No lootdrops found

+

+ Lootdrops will appear here when they are spawned in Discord channels. +

+
+ ); + } + + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {drops.map((drop) => { + const status = getStatus(drop); + return ( + + + + + + + + + + ); + })} + +
+ {col} +
+ + {STATUS_LABELS[status]} + + + + + {drop.rewardAmount.toLocaleString()} {drop.currency} + + + + + {drop.channelId} + + + {drop.claimedBy ? ( + + + {drop.claimedBy} + + ) : ( + -- + )} + + + {timeAgo(drop.createdAt)} + + + {drop.expiresAt ? ( + + + {formatTimestamp(drop.expiresAt)} + + ) : ( + -- + )} + + {status === "active" && ( + + )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// StatCards +// --------------------------------------------------------------------------- + +function StatCards({ drops }: { drops: Lootdrop[] }) { + const active = drops.filter((d) => isActive(d)); + const claimed = drops.filter((d) => d.claimedBy !== null); + const expired = drops.filter((d) => getStatus(d) === "expired"); + const totalRewards = claimed.reduce((sum, d) => sum + d.rewardAmount, 0); + + const cards = [ + { label: "Active", value: active.length, color: "text-success" }, + { label: "Claimed", value: claimed.length, color: "text-primary" }, + { label: "Expired", value: expired.length, color: "text-text-tertiary" }, + { label: "Total Rewarded", value: totalRewards.toLocaleString(), color: "text-foreground" }, + ]; + + return ( +
+ {cards.map((card) => ( +
+

+ {card.label} +

+

{card.value}

+
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +type FilterTab = "all" | "active" | "claimed" | "expired"; + +export default function Lootdrops() { + const [drops, setDrops] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterTab, setFilterTab] = useState("all"); + const [deletingId, setDeletingId] = useState(null); + + const fetchDrops = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await get<{ lootdrops: Lootdrop[] }>("/api/lootdrops?limit=100"); + setDrops(res.lootdrops); + } catch (err) { + const apiErr = err as ApiError; + setError(apiErr.error ?? "Failed to load lootdrops."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchDrops(); + }, [fetchDrops]); + + const handleDelete = async (messageId: string) => { + setDeletingId(messageId); + try { + await del(`/api/lootdrops/${messageId}`); + setDrops((prev) => prev.filter((d) => d.messageId !== messageId)); + } catch (err) { + const apiErr = err as ApiError; + setError(apiErr.error ?? "Failed to cancel lootdrop."); + } finally { + setDeletingId(null); + } + }; + + const filteredDrops = drops.filter((d) => { + if (filterTab === "all") return true; + return getStatus(d) === filterTab; + }); + + const tabs: { key: FilterTab; label: string; count: number }[] = [ + { key: "all", label: "All", count: drops.length }, + { key: "active", label: "Active", count: drops.filter((d) => isActive(d)).length }, + { key: "claimed", label: "Claimed", count: drops.filter((d) => d.claimedBy !== null).length }, + { key: "expired", label: "Expired", count: drops.filter((d) => getStatus(d) === "expired").length }, + ]; + + return ( +
+ {/* Header */} +
+
+

Lootdrops

+ +
+ + {/* Filter tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Error banner */} + {error && ( +
+ +
+

Error

+

{error}

+
+ +
+ )} + + {/* Content */} +
+ {/* Spawn form */} + + + {/* Stats */} + {!loading && drops.length > 0 && } + + {/* Table */} + +
+
+ ); +} diff --git a/panel/src/pages/Moderation.tsx b/panel/src/pages/Moderation.tsx new file mode 100644 index 0000000..83ff9bf --- /dev/null +++ b/panel/src/pages/Moderation.tsx @@ -0,0 +1,1012 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Search, + ChevronLeft, + ChevronRight, + Shield, + AlertTriangle, + Loader2, + X, + Clock, + Ban, + LogOut, + StickyNote, + Trash2, + CheckCircle2, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { get, post, put } from "../lib/api"; +import { useAuth } from "../lib/useAuth"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ModerationCase { + id: string; + caseId: string; + type: string; + userId: string; + username: string; + moderatorId: string; + moderatorName: string; + reason: string; + metadata: Record; + active: boolean; + createdAt: string; + resolvedAt: string | null; + resolvedBy: string | null; + resolvedReason: string | null; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const CASE_TYPE_CONFIG: Record< + string, + { label: string; color: string; icon: React.ComponentType<{ className?: string }> } +> = { + warn: { label: "Warning", color: "bg-warning/20 text-warning", icon: AlertTriangle }, + timeout: { label: "Timeout", color: "bg-orange-500/20 text-orange-400", icon: Clock }, + kick: { label: "Kick", color: "bg-info/20 text-info", icon: LogOut }, + ban: { label: "Ban", color: "bg-destructive/20 text-destructive", icon: Ban }, + note: { label: "Note", color: "bg-gray-500/20 text-gray-400", icon: StickyNote }, + prune: { label: "Prune", color: "bg-purple-500/20 text-purple-400", icon: Trash2 }, +}; + +const CASE_TYPES = ["warn", "timeout", "kick", "ban", "note", "prune"] as const; +const PAGE_SIZES = [10, 25, 50, 100]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatDate(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function CaseTypeBadge({ type }: { type: string }) { + const config = CASE_TYPE_CONFIG[type] ?? { + label: type, + color: "bg-gray-500/20 text-gray-400", + icon: Shield, + }; + const Icon = config.icon; + return ( + + + {config.label} + + ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + {active ? "Active" : "Resolved"} + + ); +} + +// --------------------------------------------------------------------------- +// Search / Filter Bar +// --------------------------------------------------------------------------- + +function SearchFilterBar({ + search, + onSearchChange, + type, + onTypeChange, + active, + onActiveChange, + onClear, +}: { + search: string; + onSearchChange: (v: string) => void; + type: string | null; + onTypeChange: (v: string | null) => void; + active: string | null; + onActiveChange: (v: string | null) => void; + onClear: () => void; +}) { + return ( +
+
+ + onSearchChange(e.target.value)} + placeholder="Search by username or case ID..." + className={cn( + "w-full bg-input border-b-2 border-outline-variant rounded-none pl-10 pr-3 py-2 text-sm text-foreground", + "focus:outline-none focus:border-primary", + "transition-colors placeholder:text-text-disabled" + )} + /> +
+ + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Case Table +// --------------------------------------------------------------------------- + +function CaseTable({ + cases, + loading, + onCaseClick, +}: { + cases: ModerationCase[]; + loading: boolean; + onCaseClick: (c: ModerationCase) => void; +}) { + const columns = ["Case ID", "Type", "User", "Moderator", "Reason", "Status", "Date"]; + + if (loading) { + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {[...Array(5)].map((_, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+
+
+
+
+ ); + } + + if (cases.length === 0) { + return ( +
+ +

No cases found

+

+ Try adjusting your search or filter criteria +

+
+ ); + } + + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {cases.map((c) => ( + onCaseClick(c)} + > + + + + + + + + + ))} + +
+ {col} +
+ {c.caseId} + + + + {c.username} + {c.userId} + + {c.moderatorName} + + {c.reason} + + + + + {formatDate(c.createdAt)} + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Pagination +// --------------------------------------------------------------------------- + +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); + + const getPageNumbers = () => { + const pages: (number | string)[] = []; + const showPages = 5; + const halfShow = Math.floor(showPages / 2); + + let start = Math.max(1, currentPage - halfShow); + const 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 ( +
+

+ Showing {startItem}–{endItem} of {total.toLocaleString()} cases +

+ +
+ + + {getPageNumbers().map((page, i) => + typeof page === "number" ? ( + + ) : ( + + {page} + + ) + )} + + + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Case Detail Panel +// --------------------------------------------------------------------------- + +function CaseDetailPanel({ + caseData, + onClose, + onClear, + clearing, +}: { + caseData: ModerationCase; + onClose: () => void; + onClear: (reason: string) => void; + clearing: boolean; +}) { + const [clearReason, setClearReason] = useState(""); + + const config = CASE_TYPE_CONFIG[caseData.type] ?? { + label: caseData.type, + color: "bg-gray-500/20 text-gray-400", + icon: Shield, + }; + const Icon = config.icon; + + return ( +
+
+ {/* Header */} +
+
+

+ {caseData.caseId} +

+
+ + +
+
+ +
+ + {/* Target User */} +
+

+ Target User +

+
+

{caseData.username}

+

{caseData.userId}

+
+
+ + {/* Moderator */} +
+

+ Moderator +

+
+

{caseData.moderatorName}

+

{caseData.moderatorId}

+
+
+ + {/* Reason */} +
+

+ Reason +

+

{caseData.reason}

+
+ + {/* Metadata */} + {caseData.metadata && Object.keys(caseData.metadata).length > 0 && ( +
+

+ Metadata +

+
+ {Object.entries(caseData.metadata).map(([key, value]) => ( +
+ {key} + {String(value)} +
+ ))} +
+
+ )} + + {/* Timestamps */} +
+

+ Timeline +

+
+
+ Created + {formatDate(caseData.createdAt)} +
+ {caseData.resolvedAt && ( + <> +
+ Resolved + + {formatDate(caseData.resolvedAt)} + +
+ {caseData.resolvedBy && ( +
+ Resolved by + {caseData.resolvedBy} +
+ )} + {caseData.resolvedReason && ( +
+ Resolution + {caseData.resolvedReason} +
+ )} + + )} +
+
+ + {/* Clear action */} + {caseData.active && ( +
+

+ Resolve Case +

+