From 1e978dff58e2ce5b7f9f70e0f9a6184153a12bd5 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 2 Apr 2026 11:36:39 +0200 Subject: [PATCH] refactor(panel): extract page sub-components from mega-files Split ItemStudio (1863->388), Settings (1445->355), and Users (1062->164) into focused sub-components under pages/components/. Co-Authored-By: Claude Opus 4.6 (1M context) --- panel/src/pages/ItemStudio.tsx | 1617 +---------------- panel/src/pages/Settings.tsx | 1114 +----------- panel/src/pages/Users.tsx | 914 +--------- panel/src/pages/components/EffectEditor.tsx | 174 ++ .../src/pages/components/ItemPreviewCard.tsx | 152 ++ .../src/pages/components/ItemSearchPicker.tsx | 152 ++ panel/src/pages/components/ItemStudioForm.tsx | 379 ++++ .../src/pages/components/ItemStudioShared.tsx | 38 + .../src/pages/components/ItemStudioSubmit.ts | 185 ++ panel/src/pages/components/ItemStudioTypes.ts | 304 ++++ .../pages/components/LootPoolEntryEditor.tsx | 222 +++ panel/src/pages/components/LootboxEditor.tsx | 111 ++ .../src/pages/components/SearchFilterBar.tsx | 127 ++ .../pages/components/SettingsFormFields.tsx | 486 +++++ .../src/pages/components/SettingsSections.tsx | 649 +++++++ .../src/pages/components/UserDetailPanel.tsx | 309 ++++ panel/src/pages/components/UserFormFields.tsx | 179 ++ panel/src/pages/components/UserPagination.tsx | 134 ++ panel/src/pages/components/UserTable.tsx | 171 ++ 19 files changed, 3863 insertions(+), 3554 deletions(-) create mode 100644 panel/src/pages/components/EffectEditor.tsx create mode 100644 panel/src/pages/components/ItemPreviewCard.tsx create mode 100644 panel/src/pages/components/ItemSearchPicker.tsx create mode 100644 panel/src/pages/components/ItemStudioForm.tsx create mode 100644 panel/src/pages/components/ItemStudioShared.tsx create mode 100644 panel/src/pages/components/ItemStudioSubmit.ts create mode 100644 panel/src/pages/components/ItemStudioTypes.ts create mode 100644 panel/src/pages/components/LootPoolEntryEditor.tsx create mode 100644 panel/src/pages/components/LootboxEditor.tsx create mode 100644 panel/src/pages/components/SearchFilterBar.tsx create mode 100644 panel/src/pages/components/SettingsFormFields.tsx create mode 100644 panel/src/pages/components/SettingsSections.tsx create mode 100644 panel/src/pages/components/UserDetailPanel.tsx create mode 100644 panel/src/pages/components/UserFormFields.tsx create mode 100644 panel/src/pages/components/UserPagination.tsx create mode 100644 panel/src/pages/components/UserTable.tsx diff --git a/panel/src/pages/ItemStudio.tsx b/panel/src/pages/ItemStudio.tsx index f31b942..c596738 100644 --- a/panel/src/pages/ItemStudio.tsx +++ b/panel/src/pages/ItemStudio.tsx @@ -1,1126 +1,28 @@ import { useState, useRef, useCallback, useEffect } from "react"; -import { - Upload, - X, - Plus, - Trash2, - Package, - AlertTriangle, - CheckCircle, - Zap, - MessageSquare, - TrendingUp, - Shield, - Palette, - CircleDollarSign, - ImageIcon, - Loader2, - Gift, - Search, -} from "lucide-react"; +import { AlertTriangle, CheckCircle, Loader2 } from "lucide-react"; import { cn } from "../lib/utils"; import { get } from "../lib/api"; - -// ===== Types ===== - -type ItemType = "MATERIAL" | "CONSUMABLE" | "EQUIPMENT" | "QUEST"; -type ItemRarity = "C" | "R" | "SR" | "SSR"; -type EffectKind = - | "ADD_XP" - | "ADD_BALANCE" - | "REPLY_MESSAGE" - | "XP_BOOST" - | "TEMP_ROLE" - | "COLOR_ROLE" - | "LOOTBOX"; - -type LootEntryType = "NOTHING" | "CURRENCY" | "XP" | "ITEM"; - -interface LootPoolEntry { - _id: string; - type: LootEntryType; - weight: string; - amountMode: "fixed" | "range"; - amount: string; - minAmount: string; - maxAmount: string; - itemId: string; - selectedItemName: string; - selectedItemRarity: string; - message: string; -} - -interface EffectDraft { - _id: string; - kind: EffectKind; - amount: string; - multiplier: string; - durationSeconds: string; - roleId: string; - message: string; - pool: LootPoolEntry[]; -} - -interface Draft { - name: string; - description: string; - type: ItemType; - rarity: ItemRarity; - price: string; - consume: boolean; - effects: EffectDraft[]; -} - -// ===== Constants ===== - -const TYPE_META: Record< - ItemType, - { label: string; icon: React.ComponentType<{ className?: string }> } -> = { - MATERIAL: { label: "Material", icon: Package }, - CONSUMABLE: { label: "Consumable", icon: Zap }, - EQUIPMENT: { label: "Equipment", icon: Shield }, - QUEST: { label: "Quest", icon: MessageSquare }, -}; - -const RARITY_META: Record< - ItemRarity, - { - label: string; - bg: string; - text: string; - activeBorder: string; - badgeBg: string; - } -> = { - C: { - label: "Common", - bg: "bg-gray-500/15", - text: "text-gray-300", - activeBorder: "border-gray-500", - badgeBg: "bg-gray-500/20", - }, - R: { - label: "Rare", - bg: "bg-blue-500/15", - text: "text-blue-300", - activeBorder: "border-blue-500", - badgeBg: "bg-blue-500/20", - }, - SR: { - label: "Super Rare", - bg: "bg-purple-500/15", - text: "text-purple-300", - activeBorder: "border-purple-500", - badgeBg: "bg-purple-500/20", - }, - SSR: { - label: "SSR", - bg: "bg-amber-500/15", - text: "text-amber-300", - activeBorder: "border-amber-500", - badgeBg: "bg-amber-500/20", - }, -}; - -const RARITY_BADGE: Record = { - C: "bg-gray-500/20 text-gray-400", - R: "bg-blue-500/20 text-blue-400", - SR: "bg-purple-500/20 text-purple-400", - SSR: "bg-amber-500/20 text-amber-400", -}; - -const EFFECT_META: Record< - EffectKind, - { label: string; icon: React.ComponentType<{ className?: string }> } -> = { - ADD_XP: { label: "Add XP", icon: Zap }, - ADD_BALANCE: { label: "Add Balance", icon: CircleDollarSign }, - REPLY_MESSAGE: { label: "Reply Message", icon: MessageSquare }, - XP_BOOST: { label: "XP Boost", icon: TrendingUp }, - TEMP_ROLE: { label: "Temporary Role", icon: Shield }, - COLOR_ROLE: { label: "Color Role", icon: Palette }, - LOOTBOX: { label: "Lootbox", icon: Gift }, -}; - -const LOOT_TYPE_META: Record< - LootEntryType, - { label: string; barColor: string; textColor: string } -> = { - NOTHING: { - label: "Nothing", - barColor: "bg-text-disabled", - textColor: "text-text-tertiary", - }, - CURRENCY: { - label: "Currency", - barColor: "bg-gold", - textColor: "text-gold", - }, - XP: { - label: "XP", - barColor: "bg-blue-400", - textColor: "text-blue-400", - }, - ITEM: { - label: "Item", - barColor: "bg-purple-400", - textColor: "text-purple-400", - }, -}; - -// ===== Full item shape returned by GET /api/items/:id ===== - -interface LootPayloadEntry { - type: LootEntryType; - weight: number; - amount?: number; - minAmount?: number; - maxAmount?: number; - itemId?: number; - message?: string; -} - -type StoredEffect = - | { type: "ADD_XP"; amount: number } - | { type: "ADD_BALANCE"; amount: number } - | { type: "REPLY_MESSAGE"; message: string } - | { type: "XP_BOOST"; multiplier: number; durationSeconds?: number } - | { type: "TEMP_ROLE"; roleId: string; durationSeconds?: number } - | { type: "COLOR_ROLE"; roleId: string } - | { type: "LOOTBOX"; pool: LootPayloadEntry[] }; - -interface FullItem { - id: number; - name: string; - description: string | null; - type: ItemType; - rarity: ItemRarity; - price: string | null; - iconUrl: string; - imageUrl: string; - usageData: { consume: boolean; effects: StoredEffect[] } | null; -} - -// ===== Helpers ===== - -function uid() { - return Math.random().toString(36).slice(2); -} - -function makeLootEntry(): LootPoolEntry { - return { - _id: uid(), - type: "CURRENCY", - weight: "1", - amountMode: "fixed", - amount: "", - minAmount: "", - maxAmount: "", - itemId: "", - selectedItemName: "", - selectedItemRarity: "", - message: "", - }; -} - -function makeEffect(kind: EffectKind): EffectDraft { - return { - _id: uid(), - kind, - amount: "", - multiplier: "", - durationSeconds: "", - roleId: "", - message: "", - pool: [], - }; -} - -function defaultDraft(): Draft { - return { - name: "", - description: "", - type: "MATERIAL", - rarity: "C", - price: "", - consume: false, - effects: [], - }; -} - -function draftFromItem(item: FullItem): Draft { - const effects: EffectDraft[] = (item.usageData?.effects ?? []).map((eff) => { - const base = makeEffect(eff.type as EffectKind); - switch (eff.type) { - case "ADD_XP": - case "ADD_BALANCE": - return { ...base, amount: String(eff.amount) }; - case "REPLY_MESSAGE": - return { ...base, message: eff.message }; - case "XP_BOOST": - return { - ...base, - multiplier: String(eff.multiplier), - durationSeconds: String(eff.durationSeconds ?? ""), - }; - case "TEMP_ROLE": - return { - ...base, - roleId: eff.roleId, - durationSeconds: String(eff.durationSeconds ?? ""), - }; - case "COLOR_ROLE": - return { ...base, roleId: eff.roleId }; - case "LOOTBOX": - return { - ...base, - pool: eff.pool.map((entry) => ({ - _id: uid(), - type: entry.type, - weight: String(entry.weight), - amountMode: - entry.minAmount !== undefined || entry.maxAmount !== undefined - ? ("range" as const) - : ("fixed" as const), - amount: String(entry.amount ?? ""), - minAmount: String(entry.minAmount ?? ""), - maxAmount: String(entry.maxAmount ?? ""), - itemId: String(entry.itemId ?? ""), - selectedItemName: "", - selectedItemRarity: "", - message: entry.message ?? "", - })), - }; - default: - return base; - } - }); - - return { - name: item.name, - description: item.description ?? "", - type: item.type, - rarity: item.rarity, - price: item.price ? String(parseInt(item.price)) : "", - consume: item.usageData?.consume ?? false, - effects, - }; -} - -const inputCls = cn( - "w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground", - "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", - "placeholder:text-text-tertiary transition-colors" -); - -// ===== Shared UI atoms ===== - -function SectionCard({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-

- {title} -

- {children} -
- ); -} - -function Field({ - label, - error, - children, -}: { - label: string; - error?: string; - children: React.ReactNode; -}) { - return ( -
- - {children} - {error &&

{error}

} -
- ); -} - -// ===== Item Search Picker ===== - -interface ItemResult { - id: number; - name: string; - rarity: string; - iconUrl: string; -} - -function ItemSearchPicker({ - value, - onChange, -}: { - value: { id: number; name: string; rarity: string } | null; - onChange: (item: { id: number; name: string; rarity: string } | null) => void; -}) { - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [open, setOpen] = useState(false); - const containerRef = useRef(null); - - useEffect(() => { - if (!query.trim()) { - setResults([]); - setOpen(false); - return; - } - const t = setTimeout(async () => { - setLoading(true); - try { - const data = await get<{ items: ItemResult[] }>( - `/api/items?search=${encodeURIComponent(query)}&limit=8` - ); - setResults(data.items); - setOpen(data.items.length > 0); - } catch { - // silently fail – network error shouldn't break the form - } finally { - setLoading(false); - } - }, 300); - return () => clearTimeout(t); - }, [query]); - - useEffect(() => { - function onOutsideClick(e: MouseEvent) { - if ( - containerRef.current && - !containerRef.current.contains(e.target as Node) - ) { - setOpen(false); - } - } - document.addEventListener("mousedown", onOutsideClick); - return () => document.removeEventListener("mousedown", onOutsideClick); - }, []); - - if (value) { - return ( -
- - {value.rarity} - - - {value.name} - - - #{value.id} - - -
- ); - } - - return ( -
-
- - setQuery(e.target.value)} - placeholder="Search items by name..." - className={cn(inputCls, "pl-9 pr-9")} - /> - {loading && ( - - )} -
- {open && results.length > 0 && ( -
- {results.map((item) => ( - - ))} -
- )} -
- ); -} - -// ===== Loot Pool Entry Editor ===== - -function LootPoolEntryEditor({ - entry, - totalWeight, - onChange, - onRemove, -}: { - entry: LootPoolEntry; - totalWeight: number; - onChange: (updated: LootPoolEntry) => void; - onRemove: () => void; -}) { - const upd = (fields: Partial) => - onChange({ ...entry, ...fields }); - - const weight = Number(entry.weight || 0); - const pct = - totalWeight > 0 ? ((weight / totalWeight) * 100).toFixed(1) : "0.0"; - const meta = LOOT_TYPE_META[entry.type]; - - const resetAmounts = { - amount: "", - minAmount: "", - maxAmount: "", - itemId: "", - selectedItemName: "", - selectedItemRarity: "", - }; - - return ( -
- {/* Header row: type tabs + weight input + percentage + remove */} -
-
- {(Object.keys(LOOT_TYPE_META) as LootEntryType[]).map((t) => ( - - ))} -
-
- - upd({ weight: e.target.value })} - className={cn( - "w-14 bg-input border border-border rounded-md px-2 py-1 text-xs text-foreground text-center", - "focus:outline-none focus:border-primary transition-colors" - )} - /> - - {pct}% - -
- -
- - {/* NOTHING */} - {entry.type === "NOTHING" && ( - - upd({ message: e.target.value })} - placeholder="You found nothing inside..." - className={inputCls} - /> - - )} - - {/* CURRENCY / XP */} - {(entry.type === "CURRENCY" || entry.type === "XP") && ( -
-
- {(["fixed", "range"] as const).map((mode) => ( - - ))} -
- - {entry.amountMode === "fixed" ? ( - - upd({ amount: e.target.value })} - placeholder="e.g. 100" - className={inputCls} - /> - - ) : ( -
- - upd({ minAmount: e.target.value })} - placeholder="e.g. 50" - className={inputCls} - /> - - - upd({ maxAmount: e.target.value })} - placeholder="e.g. 200" - className={inputCls} - /> - -
- )} - - - upd({ message: e.target.value })} - placeholder={ - entry.type === "CURRENCY" - ? "You received {amount} coins!" - : "You gained {amount} XP!" - } - className={inputCls} - /> - -
- )} - - {/* ITEM */} - {entry.type === "ITEM" && ( -
- - - upd({ - itemId: item ? String(item.id) : "", - selectedItemName: item?.name ?? "", - selectedItemRarity: item?.rarity ?? "", - }) - } - /> - - - upd({ amount: e.target.value })} - placeholder="1" - className={inputCls} - /> - - - upd({ message: e.target.value })} - placeholder="You found a rare item!" - className={inputCls} - /> - -
- )} -
- ); -} - -// ===== Lootbox Pool Builder ===== - -function LootboxEditor({ - pool, - onChange, -}: { - pool: LootPoolEntry[]; - onChange: (pool: LootPoolEntry[]) => void; -}) { - const totalWeight = pool.reduce((sum, e) => sum + Number(e.weight || 0), 0); - - const addEntry = () => onChange([...pool, makeLootEntry()]); - const updateEntry = (i: number, updated: LootPoolEntry) => { - const next = [...pool]; - next[i] = updated; - onChange(next); - }; - const removeEntry = (i: number) => - onChange(pool.filter((_, idx) => idx !== i)); - - return ( -
- {/* Summary bar */} - {pool.length > 0 && ( -
-
- - {pool.length} entr{pool.length === 1 ? "y" : "ies"} · total - weight {totalWeight} - -
- {(Object.keys(LOOT_TYPE_META) as LootEntryType[]).map((t) => { - const count = pool.filter((e) => e.type === t).length; - if (!count) return null; - return ( - - {LOOT_TYPE_META[t].label} ×{count} - - ); - })} -
-
- {/* Stacked probability bar */} -
- {pool.map((e) => { - const w = Number(e.weight || 0); - const pct = totalWeight > 0 ? (w / totalWeight) * 100 : 0; - return ( -
- ); - })} -
-
- )} - - {/* Pool entries */} - {pool.map((entry, i) => ( - updateEntry(i, updated)} - onRemove={() => removeEntry(i)} - /> - ))} - - {/* Empty hint */} - {pool.length === 0 && ( -

- Add pool entries — each has a type, weight, and reward amount. -

- )} - - {/* Add entry */} - -
- ); -} - -// ===== Effect Editor ===== - -function EffectEditor({ - effect, - onChange, - onRemove, -}: { - effect: EffectDraft; - onChange: (updated: EffectDraft) => void; - onRemove: () => void; -}) { - const update = (fields: Partial) => - onChange({ ...effect, ...fields }); - - const resetFields = { - amount: "", - multiplier: "", - durationSeconds: "", - roleId: "", - message: "", - pool: [], - }; - - const EffectIcon = EFFECT_META[effect.kind].icon; - const isLootbox = effect.kind === "LOOTBOX"; - - return ( -
-
- - - -
- - {(effect.kind === "ADD_XP" || effect.kind === "ADD_BALANCE") && ( - - update({ amount: e.target.value })} - placeholder="e.g. 100" - className={inputCls} - /> - - )} - - {effect.kind === "REPLY_MESSAGE" && ( - -