refactor(panel): extract page sub-components from mega-files
Some checks failed
Deploy to Production / test (push) Failing after 33s

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) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-02 11:36:39 +02:00
parent 3c256ba0b2
commit 1e978dff58
19 changed files with 3863 additions and 3554 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,908 +1,10 @@
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 (
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-text-secondary">
{label}
</label>
{children}
{hint && <p className="text-[11px] text-text-tertiary">{hint}</p>}
</div>
);
}
function NumberInput({
value,
onChange,
min,
max,
step,
className,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
className?: string;
}) {
return (
<input
type="number"
value={value}
onChange={(e) => 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 (
<input
type="text"
value={value}
onChange={(e) => 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 (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-raised"
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
checked ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
);
}
function SelectInput({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={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",
"transition-colors"
)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
function SectionCard({
title,
icon: Icon,
children,
}: {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="flex flex-wrap gap-3 items-center">
{/* Search input */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={search}
onChange={(e) => 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"
)}
/>
</div>
{/* Class filter */}
<select
value={classId ?? ""}
onChange={(e) => onClassChange(e.target.value || null)}
className={cn(
"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",
"transition-colors"
)}
>
<option value="">All Classes</option>
{Array.isArray(classes) && classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* Active status filter */}
<select
value={isActive === null ? "" : String(isActive)}
onChange={(e) =>
onActiveChange(e.target.value === "" ? null : e.target.value === "true")
}
className={cn(
"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",
"transition-colors"
)}
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
{/* Sort by */}
<select
value={sortBy}
onChange={(e) => onSortByChange(e.target.value)}
className={cn(
"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",
"transition-colors"
)}
>
<option value="balance">Sort by Balance</option>
<option value="level">Sort by Level</option>
<option value="xp">Sort by XP</option>
<option value="username">Sort by Username</option>
</select>
{/* Sort order */}
<button
onClick={() => onSortOrderChange(sortOrder === "asc" ? "desc" : "asc")}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"hover:bg-raised transition-colors"
)}
>
{sortOrder === "asc" ? "↑ Asc" : "↓ Desc"}
</button>
{/* Clear filters */}
<button
onClick={onClear}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
)}
>
Clear
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// UserTable Component
// ---------------------------------------------------------------------------
function UserTable({
users,
loading,
onSelectUser,
}: {
users: User[];
loading: boolean;
onSelectUser: (user: User) => void;
}) {
if (loading) {
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Balance
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Class
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Status
</th>
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, i) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-32"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-12"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-24"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-16"></div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
if (users.length === 0) {
return (
<div className="bg-card border border-border rounded-lg p-12 text-center">
<UserCircle2 className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
<p className="text-lg font-semibold text-text-secondary mb-2">
No users found
</p>
<p className="text-sm text-text-tertiary">
Try adjusting your search or filter criteria
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Balance
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
XP
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Class
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Status
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
onClick={() => onSelectUser(user)}
className="border-b border-border hover:bg-raised cursor-pointer transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<UserCircle2 className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-foreground">
{user.username}
</p>
<p className="text-xs text-text-tertiary font-mono">
{user.id}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{user.level}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{formatBigInt(user.balance)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-text-secondary">
{formatBigInt(user.xp)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground">
{user.class?.name || "—"}
</span>
</td>
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
user.isActive
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
)}
>
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
{/* Items info */}
<p className="text-sm text-text-secondary">
Showing {startItem}{endItem} of {formatNumber(total)} users
</p>
{/* Page controls */}
<div className="flex items-center gap-2">
{/* Previous button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === 1
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{/* Page numbers */}
{getPageNumbers().map((page, i) =>
typeof page === "number" ? (
<button
key={i}
onClick={() => onPageChange(page)}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
page === currentPage
? "bg-primary text-white"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
{page}
</button>
) : (
<span key={i} className="px-2 text-text-tertiary">
{page}
</span>
)
)}
{/* Next button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === totalPages
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronRight className="w-4 h-4" />
</button>
{/* Items per page */}
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value))}
className={cn(
"ml-2 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",
"transition-colors"
)}
>
<option value="10">10 / page</option>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// InventoryAddForm Component
// ---------------------------------------------------------------------------
function InventoryAddForm({
items,
onAdd,
}: {
items: { id: number; name: string }[];
onAdd: (itemId: number, quantity: string) => void;
}) {
const [selectedItemId, setSelectedItemId] = useState<string>("");
const [quantity, setQuantity] = useState<string>("1");
const handleAdd = () => {
if (!selectedItemId) return;
onAdd(parseInt(selectedItemId), quantity);
setSelectedItemId("");
setQuantity("1");
};
return (
<div className="space-y-3">
<p className="text-xs font-semibold text-text-secondary">Add Item</p>
<select
value={selectedItemId}
onChange={(e) => setSelectedItemId(e.target.value)}
className={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",
"transition-colors"
)}
>
<option value="">Select item...</option>
{Array.isArray(items) && items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<div className="flex gap-2">
<input
type="number"
value={quantity}
onChange={(e) => 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"
)}
/>
<button
onClick={handleAdd}
disabled={!selectedItemId}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed",
"flex items-center justify-center gap-1.5"
)}
>
<Plus className="w-4 h-4" />
Add
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// DetailPanel Component
// ---------------------------------------------------------------------------
function DetailPanel({
user,
userDraft,
onClose,
onUpdateDraft,
onSave,
onDiscard,
isDirty,
saving,
saveSuccess,
classes,
inventoryDraft,
items,
onAddItem,
onRemoveItem,
}: {
user: User;
userDraft: Partial<User> | 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 (
<div className="fixed inset-0 md:relative md:w-96 border-l border-border bg-card overflow-auto z-50 md:z-auto">
<div className="p-6 space-y-6 pb-24">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex-1">
<h2 className="text-lg font-semibold text-foreground mb-1">
{user.username}
</h2>
<p className="text-xs font-mono text-text-tertiary">{user.id}</p>
<p className="text-xs text-text-tertiary mt-1">
Joined {new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={onClose}
className="text-text-tertiary hover:text-foreground transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* User Info (Editable) */}
<div className="space-y-4">
<Field label="Balance" hint="User's currency balance">
<StringInput
value={String(userDraft.balance || "0")}
onChange={(v) => onUpdateDraft("balance", v)}
placeholder="0"
/>
</Field>
<Field label="XP" hint="User's experience points">
<StringInput
value={String(userDraft.xp || "0")}
onChange={(v) => onUpdateDraft("xp", v)}
placeholder="0"
/>
</Field>
<Field label="Level" hint="User's current level">
<NumberInput
value={userDraft.level || 1}
onChange={(v) => onUpdateDraft("level", v)}
min={1}
max={100}
/>
</Field>
<Field label="Daily Streak" hint="Consecutive days of daily command usage">
<NumberInput
value={userDraft.dailyStreak || 0}
onChange={(v) => onUpdateDraft("dailyStreak", v)}
min={0}
/>
</Field>
<Field label="Class" hint="User's selected class">
<SelectInput
value={String(userDraft.classId || "")}
onChange={(v) => onUpdateDraft("classId", v || null)}
options={classOptions}
/>
</Field>
<Field label="Active Status" hint="Whether the user is active in the system">
<div className="flex items-center gap-3">
<Toggle
checked={userDraft.isActive ?? true}
onChange={(v) => onUpdateDraft("isActive", v)}
/>
<span
className={cn(
"text-sm font-medium",
userDraft.isActive ? "text-green-400" : "text-gray-400"
)}
>
{userDraft.isActive ? "Active" : "Inactive"}
</span>
</div>
</Field>
</div>
{/* Inventory Section */}
<SectionCard title="Inventory" icon={Package}>
{inventoryDraft.length === 0 ? (
<p className="text-sm text-text-tertiary">No items in inventory</p>
) : (
<div className="space-y-2">
{inventoryDraft.map((entry) => (
<div
key={entry.itemId}
className="flex items-center justify-between gap-3 p-2 bg-raised rounded-md"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{entry.item?.name || `Item #${entry.itemId}`}
</p>
<p className="text-xs text-text-tertiary">
Quantity: {formatBigInt(entry.quantity)}
</p>
</div>
<button
onClick={() => onRemoveItem(entry.itemId)}
className="p-1.5 text-text-tertiary hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
title="Remove item"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* Add Item Form */}
<div className="mt-4 pt-4 border-t border-border">
<InventoryAddForm items={items} onAdd={onAddItem} />
</div>
</SectionCard>
</div>
{/* Sticky footer for save/discard (only shown when dirty) */}
{isDirty && (
<div className="sticky bottom-0 left-0 right-0 border-t border-border bg-card p-4 space-y-3">
<div className="flex items-center gap-2 text-amber-400">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">You have unsaved changes</span>
</div>
<div className="flex gap-2">
<button
onClick={onDiscard}
disabled={saving}
className={cn(
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
"bg-input border border-border text-foreground hover:bg-raised",
saving && "opacity-50 cursor-not-allowed"
)}
>
Discard
</button>
<button
onClick={onSave}
disabled={saving}
className={cn(
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90",
"flex items-center justify-center gap-2",
saving && "opacity-50 cursor-not-allowed"
)}
>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : saveSuccess ? (
<>
<Check className="w-4 h-4" />
Saved!
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</button>
</div>
</div>
)}
</div>
);
}
import { AlertTriangle } from "lucide-react";
import { useUsers } from "../lib/useUsers";
import { SearchFilterBar } from "./components/SearchFilterBar";
import { UserTable } from "./components/UserTable";
import { UserPagination } from "./components/UserPagination";
import { UserDetailPanel } from "./components/UserDetailPanel";
// ---------------------------------------------------------------------------
// Main Users Component
@@ -1025,7 +127,7 @@ export default function Users() {
{/* Main content */}
<div className="flex-1 overflow-auto p-6">
<UserTable users={users} loading={loading} onSelectUser={selectUser} />
<Pagination
<UserPagination
currentPage={currentPage}
totalPages={Math.ceil(total / limit)}
limit={limit}
@@ -1037,7 +139,7 @@ export default function Users() {
{/* Detail panel */}
{selectedUser && userDraft && (
<DetailPanel
<UserDetailPanel
user={selectedUser}
userDraft={userDraft}
onClose={closeDetail}

View File

@@ -0,0 +1,174 @@
import { Trash2 } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type EffectDraft,
type EffectKind,
EFFECT_META,
inputCls,
} from "./ItemStudioTypes";
import { Field } from "./ItemStudioShared";
import { LootboxEditor } from "./LootboxEditor";
// ===== Effect Editor =====
export function EffectEditor({
effect,
onChange,
onRemove,
}: {
effect: EffectDraft;
onChange: (updated: EffectDraft) => void;
onRemove: () => void;
}) {
const update = (fields: Partial<EffectDraft>) =>
onChange({ ...effect, ...fields });
const resetFields = {
amount: "",
multiplier: "",
durationSeconds: "",
roleId: "",
message: "",
pool: [],
};
const EffectIcon = EFFECT_META[effect.kind].icon;
const isLootbox = effect.kind === "LOOTBOX";
return (
<div
className={cn(
"border rounded-lg p-4 space-y-3",
isLootbox
? "bg-amber-500/5 border-amber-500/25"
: "bg-raised/20 border-border"
)}
>
<div className="flex items-center gap-2">
<EffectIcon
className={cn(
"w-4 h-4 shrink-0",
isLootbox ? "text-amber-400" : "text-primary"
)}
/>
<select
value={effect.kind}
onChange={(e) =>
update({ kind: e.target.value as EffectKind, ...resetFields })
}
className={cn(
"flex-1 bg-input border border-border rounded-md px-3 py-1.5 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30 transition-colors"
)}
>
{(Object.keys(EFFECT_META) as EffectKind[]).map((kind) => (
<option key={kind} value={kind}>
{EFFECT_META[kind].label}
</option>
))}
</select>
<button
onClick={onRemove}
className="p-1.5 rounded-md text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Remove effect"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{(effect.kind === "ADD_XP" || effect.kind === "ADD_BALANCE") && (
<Field label="Amount">
<input
type="number"
min="1"
value={effect.amount}
onChange={(e) => update({ amount: e.target.value })}
placeholder="e.g. 100"
className={inputCls}
/>
</Field>
)}
{effect.kind === "REPLY_MESSAGE" && (
<Field label="Message">
<textarea
value={effect.message}
onChange={(e) => update({ message: e.target.value })}
placeholder="The message the bot will reply with..."
rows={2}
className={cn(inputCls, "resize-none")}
/>
</Field>
)}
{effect.kind === "XP_BOOST" && (
<div className="grid grid-cols-2 gap-3">
<Field label="Multiplier (x)">
<input
type="number"
min="1"
step="0.1"
value={effect.multiplier}
onChange={(e) => update({ multiplier: e.target.value })}
placeholder="e.g. 2"
className={inputCls}
/>
</Field>
<Field label="Duration (sec, 0 = permanent)">
<input
type="number"
min="0"
value={effect.durationSeconds}
onChange={(e) => update({ durationSeconds: e.target.value })}
placeholder="e.g. 3600"
className={inputCls}
/>
</Field>
</div>
)}
{effect.kind === "TEMP_ROLE" && (
<div className="grid grid-cols-2 gap-3">
<Field label="Role ID">
<input
type="text"
value={effect.roleId}
onChange={(e) => update({ roleId: e.target.value })}
placeholder="Discord role ID"
className={inputCls}
/>
</Field>
<Field label="Duration (sec, 0 = permanent)">
<input
type="number"
min="0"
value={effect.durationSeconds}
onChange={(e) => update({ durationSeconds: e.target.value })}
placeholder="e.g. 86400"
className={inputCls}
/>
</Field>
</div>
)}
{effect.kind === "COLOR_ROLE" && (
<Field label="Role ID">
<input
type="text"
value={effect.roleId}
onChange={(e) => update({ roleId: e.target.value })}
placeholder="Discord role ID"
className={inputCls}
/>
</Field>
)}
{effect.kind === "LOOTBOX" && (
<LootboxEditor
pool={effect.pool}
onChange={(pool) => update({ pool })}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { ImageIcon, CircleDollarSign, Gift, Zap } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type Draft,
TYPE_META,
RARITY_META,
LOOT_TYPE_META,
} from "./ItemStudioTypes";
// ===== Item Preview Card =====
export function ItemPreviewCard({
draft,
previewImageSrc,
}: {
draft: Draft;
previewImageSrc: string | null;
}) {
const rarity = RARITY_META[draft.rarity];
const type = TYPE_META[draft.type];
const TypeIcon = type.icon;
const lootboxEffect = draft.effects.find((e) => e.kind === "LOOTBOX");
return (
<div
className={cn(
"bg-card border-2 rounded-xl overflow-hidden transition-all duration-300",
rarity.activeBorder
)}
>
{/* Image area */}
<div className="relative aspect-square bg-raised">
{previewImageSrc ? (
<img
src={previewImageSrc}
alt="Preview"
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.opacity = "0.2";
}}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-text-tertiary gap-2">
<ImageIcon className="w-10 h-10 opacity-25" />
<span className="text-xs opacity-40">No image</span>
</div>
)}
<span
className={cn(
"absolute top-3 right-3 px-2.5 py-1 rounded-full text-xs font-bold backdrop-blur-sm",
rarity.badgeBg,
rarity.text
)}
>
{draft.rarity}
</span>
</div>
{/* Info */}
<div className="p-4 space-y-2">
<h4 className="text-base font-bold text-foreground leading-tight">
{draft.name.trim() ? (
draft.name
) : (
<span className="text-text-tertiary italic font-normal text-sm">
Item name...
</span>
)}
</h4>
<div className="flex items-center gap-1.5">
<TypeIcon className="w-3.5 h-3.5 text-text-tertiary" />
<span className="text-xs text-text-tertiary">{type.label}</span>
</div>
{draft.description.trim() && (
<p className="text-xs text-text-secondary line-clamp-3 leading-relaxed">
{draft.description}
</p>
)}
{draft.price && Number(draft.price) > 0 && (
<div className="flex items-center gap-1.5">
<CircleDollarSign className="w-3.5 h-3.5 text-gold" />
<span className="text-xs font-mono text-gold font-medium">
{parseInt(draft.price).toLocaleString()} coins
</span>
</div>
)}
{/* Lootbox pool mini-preview */}
{lootboxEffect && lootboxEffect.pool.length > 0 && (
<div className="pt-2 border-t border-border space-y-1.5">
<div className="flex items-center gap-1.5">
<Gift className="w-3.5 h-3.5 text-amber-400" />
<span className="text-xs text-amber-400 font-medium">
Lootbox · {lootboxEffect.pool.length} outcome
{lootboxEffect.pool.length !== 1 ? "s" : ""}
</span>
</div>
{/* Stacked bar */}
{(() => {
const total = lootboxEffect.pool.reduce(
(s, e) => s + Number(e.weight || 0),
0
);
return (
<div className="flex h-1.5 w-full rounded-full overflow-hidden gap-px bg-raised">
{lootboxEffect.pool.map((e) => {
const pct =
total > 0
? (Number(e.weight || 0) / total) * 100
: 0;
return (
<div
key={e._id}
style={{ width: `${pct}%` }}
className={cn(
"transition-all",
LOOT_TYPE_META[e.type].barColor
)}
title={`${LOOT_TYPE_META[e.type].label} ${pct.toFixed(1)}%`}
/>
);
})}
</div>
);
})()}
</div>
)}
{(draft.effects.length > 0 || draft.consume) && !lootboxEffect && (
<div className="pt-2 border-t border-border flex flex-wrap gap-1.5">
{draft.consume && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 text-xs">
Consumed on use
</span>
)}
{draft.effects.length > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs">
<Zap className="w-3 h-3" />
{draft.effects.length} effect
{draft.effects.length !== 1 ? "s" : ""}
</span>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { useState, useRef, useEffect } from "react";
import { Search, X, Package, Loader2 } from "lucide-react";
import { cn } from "../../lib/utils";
import { get } from "../../lib/api";
import { RARITY_BADGE, inputCls } from "./ItemStudioTypes";
// ===== Item Search Picker =====
interface ItemResult {
id: number;
name: string;
rarity: string;
iconUrl: string;
}
export 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<ItemResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(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 (
<div className="flex items-center gap-2 p-2 bg-raised/50 rounded-md border border-border">
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-bold shrink-0",
RARITY_BADGE[value.rarity] ?? "bg-gray-500/20 text-gray-400"
)}
>
{value.rarity}
</span>
<span className="text-sm flex-1 text-foreground truncate">
{value.name}
</span>
<span className="text-xs text-text-tertiary font-mono shrink-0">
#{value.id}
</span>
<button
onClick={() => onChange(null)}
className="p-0.5 text-text-tertiary hover:text-destructive transition-colors shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
return (
<div className="relative" ref={containerRef}>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search items by name..."
className={cn(inputCls, "pl-9 pr-9")}
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary animate-spin pointer-events-none" />
)}
</div>
{open && results.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-card border border-border rounded-lg shadow-xl overflow-hidden">
{results.map((item) => (
<button
key={item.id}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange({ id: item.id, name: item.name, rarity: item.rarity });
setQuery("");
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-3 py-2 hover:bg-raised text-left transition-colors"
>
{item.iconUrl ? (
<img
src={item.iconUrl}
alt=""
className="w-6 h-6 rounded object-cover bg-raised shrink-0"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
) : (
<Package className="w-6 h-6 text-text-tertiary shrink-0" />
)}
<span className="text-sm text-foreground flex-1 truncate">
{item.name}
</span>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-bold shrink-0",
RARITY_BADGE[item.rarity] ?? "bg-gray-500/20 text-gray-400"
)}
>
{item.rarity}
</span>
<span className="text-xs text-text-tertiary font-mono shrink-0">
#{item.id}
</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,379 @@
import { Upload, X, Plus, CircleDollarSign } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type Draft,
type EffectDraft,
type ItemType,
type ItemRarity,
TYPE_META,
RARITY_META,
inputCls,
} from "./ItemStudioTypes";
import { SectionCard, Field } from "./ItemStudioShared";
import { EffectEditor } from "./EffectEditor";
// ===== Item Studio Form Sections =====
export function IdentitySection({
draft,
update,
validationErrors,
}: {
draft: Draft;
update: (fields: Partial<Draft>) => void;
validationErrors: Record<string, string>;
}) {
return (
<SectionCard title="Identity">
<Field label="Item Name *" error={validationErrors.name}>
<input
type="text"
value={draft.name}
onChange={(e) => update({ name: e.target.value })}
placeholder="e.g. Treasure Chest"
maxLength={255}
className={cn(
inputCls,
validationErrors.name &&
"border-destructive focus:border-destructive focus:ring-destructive/30"
)}
/>
</Field>
<Field label="Description">
<textarea
value={draft.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="A brief description of this item..."
rows={3}
className={cn(inputCls, "resize-none")}
/>
</Field>
</SectionCard>
);
}
export function ClassificationSection({
draft,
update,
}: {
draft: Draft;
update: (fields: Partial<Draft>) => void;
}) {
return (
<SectionCard title="Classification">
<Field label="Type">
<div className="grid grid-cols-4 gap-2">
{(Object.keys(TYPE_META) as ItemType[]).map((t) => {
const meta = TYPE_META[t];
const Icon = meta.icon;
const active = draft.type === t;
return (
<button
key={t}
onClick={() => update({ type: t })}
className={cn(
"flex flex-col items-center gap-1.5 py-3 px-2 rounded-lg border text-xs font-medium transition-all",
active
? "bg-primary/15 border-primary text-primary"
: "bg-input border-border text-text-tertiary hover:border-primary/40 hover:text-foreground"
)}
>
<Icon className="w-4 h-4" />
{meta.label}
</button>
);
})}
</div>
</Field>
<Field label="Rarity">
<div className="grid grid-cols-4 gap-2">
{(Object.keys(RARITY_META) as ItemRarity[]).map((r) => {
const meta = RARITY_META[r];
const active = draft.rarity === r;
return (
<button
key={r}
onClick={() => update({ rarity: r })}
className={cn(
"py-3 px-2 rounded-lg border text-center transition-all",
active
? cn(meta.bg, meta.text, meta.activeBorder)
: "bg-input border-border text-text-tertiary hover:border-primary/40"
)}
>
<div className="text-sm font-bold">{r}</div>
<div
className={cn(
"text-xs mt-0.5 opacity-80",
active ? meta.text : "text-text-disabled"
)}
>
{meta.label}
</div>
</button>
);
})}
</div>
</Field>
</SectionCard>
);
}
export function EconomySection({
draft,
update,
}: {
draft: Draft;
update: (fields: Partial<Draft>) => void;
}) {
return (
<SectionCard title="Economy">
<Field label="Price (0 or empty = not for sale)">
<div className="relative">
<CircleDollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary pointer-events-none" />
<input
type="number"
min="0"
step="1"
value={draft.price}
onChange={(e) => update({ price: e.target.value })}
placeholder="0"
className={cn(inputCls, "pl-10")}
/>
</div>
</Field>
</SectionCard>
);
}
export function ArtworkSection({
imageMode,
setImageMode,
imageFile,
imagePreview,
iconUrlInput,
setIconUrlInput,
imageUrlInput,
setImageUrlInput,
dragOver,
setDragOver,
handleDrop,
clearImage,
handleImageFile,
fileInputRef,
validationErrors,
}: {
imageMode: "upload" | "url";
setImageMode: (mode: "upload" | "url") => void;
imageFile: File | null;
imagePreview: string | null;
iconUrlInput: string;
setIconUrlInput: (v: string) => void;
imageUrlInput: string;
setImageUrlInput: (v: string) => void;
dragOver: boolean;
setDragOver: (v: boolean) => void;
handleDrop: (e: React.DragEvent) => void;
clearImage: () => void;
handleImageFile: (file: File) => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
validationErrors: Record<string, string>;
}) {
return (
<SectionCard title="Artwork">
<div className="flex gap-2">
{(["upload", "url"] as const).map((mode) => (
<button
key={mode}
onClick={() => setImageMode(mode)}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-medium transition-colors border",
imageMode === mode
? "bg-primary/15 border-primary text-primary"
: "bg-input border-border text-text-tertiary hover:text-foreground"
)}
>
{mode === "upload" ? "Upload File" : "Enter URL"}
</button>
))}
</div>
{imageMode === "upload" ? (
<div className="space-y-2">
{imageFile ? (
<div className="relative rounded-lg overflow-hidden border border-border">
<img
src={imagePreview!}
alt="Selected"
className="w-full max-h-56 object-contain bg-raised"
/>
<button
onClick={clearImage}
className="absolute top-2 right-2 p-1.5 bg-background/80 rounded-full hover:bg-destructive hover:text-white text-text-secondary transition-colors backdrop-blur-sm"
>
<X className="w-4 h-4" />
</button>
<div className="px-3 py-2 bg-card border-t border-border">
<p className="text-xs text-text-tertiary truncate">
{imageFile.name} ({(imageFile.size / 1024).toFixed(1)}{" "}
KB)
</p>
</div>
</div>
) : (
<div
onDrop={handleDrop}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-lg p-10 flex flex-col items-center gap-3 cursor-pointer transition-all select-none",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-primary/3"
)}
>
<Upload
className={cn(
"w-8 h-8 transition-colors",
dragOver ? "text-primary" : "text-text-tertiary"
)}
/>
<div className="text-center">
<p className="text-sm font-medium text-text-secondary">
{dragOver ? "Drop to upload" : "Drop an image here"}
</p>
<p className="text-xs text-text-tertiary mt-1">
or click to browse · PNG, JPEG, WebP, GIF · max 15 MB
</p>
</div>
</div>
)}
{validationErrors.image && (
<p className="text-xs text-destructive">
{validationErrors.image}
</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageFile(file);
}}
className="hidden"
/>
</div>
) : (
<div className="space-y-3">
<Field
label="Icon URL (small, used in lists)"
error={validationErrors.iconUrl}
>
<input
type="url"
value={iconUrlInput}
onChange={(e) => setIconUrlInput(e.target.value)}
placeholder="https://example.com/icon.png"
className={inputCls}
/>
</Field>
<Field
label="Image URL (full size)"
error={validationErrors.imageUrl}
>
<input
type="url"
value={imageUrlInput}
onChange={(e) => setImageUrlInput(e.target.value)}
placeholder="https://example.com/image.png"
className={inputCls}
/>
</Field>
{iconUrlInput && (
<div className="flex items-center gap-3 p-3 bg-raised/40 rounded-lg border border-border">
<img
src={iconUrlInput}
alt="Icon preview"
className="w-10 h-10 rounded object-cover bg-raised"
onError={(e) => {
e.currentTarget.style.opacity = "0.2";
}}
/>
<span className="text-xs text-text-tertiary">
Icon preview
</span>
</div>
)}
</div>
)}
</SectionCard>
);
}
export function EffectsSection({
draft,
update,
updateEffect,
removeEffect,
addEffect,
}: {
draft: Draft;
update: (fields: Partial<Draft>) => void;
updateEffect: (i: number, updated: EffectDraft) => void;
removeEffect: (i: number) => void;
addEffect: () => void;
}) {
return (
<SectionCard title="Effects">
<label className="flex items-center gap-3 cursor-pointer group select-none">
<button
role="switch"
aria-checked={draft.consume}
onClick={() => update({ consume: !draft.consume })}
className={cn(
"relative w-10 h-5 rounded-full border transition-all shrink-0",
draft.consume
? "bg-primary border-primary"
: "bg-input border-border"
)}
>
<span
className={cn(
"absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-all duration-200",
draft.consume ? "left-5" : "left-0.5"
)}
/>
</button>
<span className="text-sm text-text-secondary group-hover:text-foreground transition-colors">
Consume item on use
</span>
</label>
{draft.effects.map((eff, i) => (
<EffectEditor
key={eff._id}
effect={eff}
onChange={(updated) => updateEffect(i, updated)}
onRemove={() => removeEffect(i)}
/>
))}
<button
onClick={addEffect}
className={cn(
"w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed text-sm",
"border-border text-text-tertiary hover:border-primary/50 hover:text-primary transition-colors"
)}
>
<Plus className="w-4 h-4" />
Add Effect
</button>
</SectionCard>
);
}

View File

@@ -0,0 +1,38 @@
// ===== Shared UI atoms for ItemStudio =====
export function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
{title}
</h3>
{children}
</div>
);
}
export function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-text-secondary">
{label}
</label>
{children}
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,185 @@
import type { Draft, EffectDraft } from "./ItemStudioTypes";
// ===== Build effects payload from draft =====
export function buildEffectsPayload(effects: EffectDraft[]) {
return effects
.map((eff) => {
switch (eff.kind) {
case "ADD_XP":
return { type: "ADD_XP", amount: Number(eff.amount || 0) };
case "ADD_BALANCE":
return { type: "ADD_BALANCE", amount: Number(eff.amount || 0) };
case "REPLY_MESSAGE":
return { type: "REPLY_MESSAGE", message: eff.message };
case "XP_BOOST":
return {
type: "XP_BOOST",
multiplier: Number(eff.multiplier || 1),
...(eff.durationSeconds
? { durationSeconds: Number(eff.durationSeconds) }
: {}),
};
case "TEMP_ROLE":
return {
type: "TEMP_ROLE",
roleId: eff.roleId,
...(eff.durationSeconds
? { durationSeconds: Number(eff.durationSeconds) }
: {}),
};
case "COLOR_ROLE":
return { type: "COLOR_ROLE", roleId: eff.roleId };
case "LOOTBOX":
return {
type: "LOOTBOX",
pool: eff.pool.map((entry) => {
const loot: Record<string, unknown> = {
type: entry.type,
weight: Number(entry.weight || 1),
};
if (entry.type !== "NOTHING") {
if (entry.amountMode === "range") {
loot.minAmount = Number(entry.minAmount || 0);
loot.maxAmount = Number(entry.maxAmount || 0);
} else {
loot.amount = Number(entry.amount || 0);
}
}
if (entry.type === "ITEM" && entry.itemId) {
loot.itemId = Number(entry.itemId);
}
if (entry.message.trim()) {
loot.message = entry.message.trim();
}
return loot;
}),
};
default:
return null;
}
})
.filter(Boolean);
}
// ===== Build base payload from draft =====
export function buildBasePayload(draft: Draft) {
const effects = buildEffectsPayload(draft.effects);
return {
name: draft.name.trim(),
description: draft.description.trim() || null,
type: draft.type,
rarity: draft.rarity,
price:
draft.price && Number(draft.price) > 0 ? Number(draft.price) : null,
usageData:
draft.effects.length > 0
? { consume: draft.consume, effects }
: null,
} as Record<string, unknown>;
}
// ===== Submit: Edit existing item =====
export async function submitEditItem({
editItemId,
basePayload,
imageMode,
iconUrlInput,
imageUrlInput,
imageFile,
}: {
editItemId: number;
basePayload: Record<string, unknown>;
imageMode: "upload" | "url";
iconUrlInput: string;
imageUrlInput: string;
imageFile: File | null;
}) {
const editPayload = { ...basePayload };
if (imageMode === "url") {
editPayload.iconUrl = iconUrlInput.trim();
editPayload.imageUrl = imageUrlInput.trim();
}
const putRes = await fetch(`/api/items/${editItemId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editPayload),
credentials: "same-origin",
});
if (!putRes.ok) {
const body = (await putRes
.json()
.catch(() => ({ error: putRes.statusText }))) as { error?: string };
throw new Error(body.error || "Failed to save item");
}
if (imageMode === "upload" && imageFile) {
const form = new FormData();
form.append("image", imageFile);
const iconRes = await fetch(`/api/items/${editItemId}/icon`, {
method: "POST",
body: form,
credentials: "same-origin",
});
if (!iconRes.ok) {
const body = (await iconRes
.json()
.catch(() => ({ error: iconRes.statusText }))) as {
error?: string;
};
throw new Error(body.error || "Failed to upload image");
}
}
}
// ===== Submit: Create new item =====
export async function submitCreateItem({
basePayload,
imageMode,
iconUrlInput,
imageUrlInput,
imageFile,
}: {
basePayload: Record<string, unknown>;
imageMode: "upload" | "url";
iconUrlInput: string;
imageUrlInput: string;
imageFile: File | null;
}) {
const payload = {
...basePayload,
iconUrl: imageMode === "url" ? iconUrlInput.trim() : "",
imageUrl: imageMode === "url" ? imageUrlInput.trim() : "",
};
let res: Response;
if (imageMode === "upload" && imageFile) {
const form = new FormData();
form.append("data", JSON.stringify(payload));
form.append("image", imageFile);
res = await fetch("/api/items", {
method: "POST",
body: form,
credentials: "same-origin",
});
} else {
res = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "same-origin",
});
}
if (!res.ok) {
const body = (await res
.json()
.catch(() => ({ error: res.statusText }))) as { error?: string };
throw new Error(body.error || "Failed to create item");
}
}

View File

@@ -0,0 +1,304 @@
import {
Package,
Zap,
Shield,
MessageSquare,
TrendingUp,
Palette,
CircleDollarSign,
Gift,
} from "lucide-react";
import { cn } from "../../lib/utils";
// ===== Types =====
export type ItemType = "MATERIAL" | "CONSUMABLE" | "EQUIPMENT" | "QUEST";
export type ItemRarity = "C" | "R" | "SR" | "SSR";
export type EffectKind =
| "ADD_XP"
| "ADD_BALANCE"
| "REPLY_MESSAGE"
| "XP_BOOST"
| "TEMP_ROLE"
| "COLOR_ROLE"
| "LOOTBOX";
export type LootEntryType = "NOTHING" | "CURRENCY" | "XP" | "ITEM";
export 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;
}
export interface EffectDraft {
_id: string;
kind: EffectKind;
amount: string;
multiplier: string;
durationSeconds: string;
roleId: string;
message: string;
pool: LootPoolEntry[];
}
export interface Draft {
name: string;
description: string;
type: ItemType;
rarity: ItemRarity;
price: string;
consume: boolean;
effects: EffectDraft[];
}
// ===== Full item shape returned by GET /api/items/:id =====
export interface LootPayloadEntry {
type: LootEntryType;
weight: number;
amount?: number;
minAmount?: number;
maxAmount?: number;
itemId?: number;
message?: string;
}
export 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[] };
export 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;
}
// ===== Constants =====
export 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 },
};
export 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",
},
};
export const RARITY_BADGE: Record<string, string> = {
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",
};
export 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 },
};
export 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",
},
};
// ===== Helpers =====
export function uid() {
return Math.random().toString(36).slice(2);
}
export function makeLootEntry(): LootPoolEntry {
return {
_id: uid(),
type: "CURRENCY",
weight: "1",
amountMode: "fixed",
amount: "",
minAmount: "",
maxAmount: "",
itemId: "",
selectedItemName: "",
selectedItemRarity: "",
message: "",
};
}
export function makeEffect(kind: EffectKind): EffectDraft {
return {
_id: uid(),
kind,
amount: "",
multiplier: "",
durationSeconds: "",
roleId: "",
message: "",
pool: [],
};
}
export function defaultDraft(): Draft {
return {
name: "",
description: "",
type: "MATERIAL",
rarity: "C",
price: "",
consume: false,
effects: [],
};
}
export 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,
};
}
export 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"
);

View File

@@ -0,0 +1,222 @@
import { Trash2 } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type LootPoolEntry,
type LootEntryType,
LOOT_TYPE_META,
inputCls,
} from "./ItemStudioTypes";
import { Field } from "./ItemStudioShared";
import { ItemSearchPicker } from "./ItemSearchPicker";
// ===== Loot Pool Entry Editor =====
export function LootPoolEntryEditor({
entry,
totalWeight,
onChange,
onRemove,
}: {
entry: LootPoolEntry;
totalWeight: number;
onChange: (updated: LootPoolEntry) => void;
onRemove: () => void;
}) {
const upd = (fields: Partial<LootPoolEntry>) =>
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 (
<div className="bg-raised/30 border border-border rounded-lg p-3 space-y-3">
{/* Header row: type tabs + weight input + percentage + remove */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex gap-1 flex-1 flex-wrap">
{(Object.keys(LOOT_TYPE_META) as LootEntryType[]).map((t) => (
<button
key={t}
onClick={() => upd({ type: t, ...resetAmounts })}
className={cn(
"px-2 py-1 rounded text-xs font-medium transition-colors border",
entry.type === t
? "bg-primary/15 border-primary text-primary"
: "bg-input border-border text-text-tertiary hover:text-foreground"
)}
>
{LOOT_TYPE_META[t].label}
</button>
))}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<label className="text-xs text-text-tertiary">Weight</label>
<input
type="number"
min="1"
value={entry.weight}
onChange={(e) => 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"
)}
/>
<span className={cn("text-xs font-semibold w-12 text-right", meta.textColor)}>
{pct}%
</span>
</div>
<button
onClick={onRemove}
className="p-1 rounded text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
title="Remove entry"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{/* NOTHING */}
{entry.type === "NOTHING" && (
<Field label="Message (optional)">
<input
type="text"
value={entry.message}
onChange={(e) => upd({ message: e.target.value })}
placeholder="You found nothing inside..."
className={inputCls}
/>
</Field>
)}
{/* CURRENCY / XP */}
{(entry.type === "CURRENCY" || entry.type === "XP") && (
<div className="space-y-3">
<div className="flex gap-1.5">
{(["fixed", "range"] as const).map((mode) => (
<button
key={mode}
onClick={() => upd({ amountMode: mode })}
className={cn(
"px-2.5 py-1 rounded text-xs font-medium border transition-colors",
entry.amountMode === mode
? "bg-primary/15 border-primary text-primary"
: "bg-input border-border text-text-tertiary hover:text-foreground"
)}
>
{mode === "fixed" ? "Fixed" : "Random Range"}
</button>
))}
</div>
{entry.amountMode === "fixed" ? (
<Field
label={
entry.type === "CURRENCY" ? "Coins Amount" : "XP Amount"
}
>
<input
type="number"
min="1"
value={entry.amount}
onChange={(e) => upd({ amount: e.target.value })}
placeholder="e.g. 100"
className={inputCls}
/>
</Field>
) : (
<div className="grid grid-cols-2 gap-3">
<Field label="Min">
<input
type="number"
min="0"
value={entry.minAmount}
onChange={(e) => upd({ minAmount: e.target.value })}
placeholder="e.g. 50"
className={inputCls}
/>
</Field>
<Field label="Max">
<input
type="number"
min="0"
value={entry.maxAmount}
onChange={(e) => upd({ maxAmount: e.target.value })}
placeholder="e.g. 200"
className={inputCls}
/>
</Field>
</div>
)}
<Field label="Message (optional)">
<input
type="text"
value={entry.message}
onChange={(e) => upd({ message: e.target.value })}
placeholder={
entry.type === "CURRENCY"
? "You received {amount} coins!"
: "You gained {amount} XP!"
}
className={inputCls}
/>
</Field>
</div>
)}
{/* ITEM */}
{entry.type === "ITEM" && (
<div className="space-y-3">
<Field label="Item to Award">
<ItemSearchPicker
value={
entry.itemId && entry.selectedItemName
? {
id: Number(entry.itemId),
name: entry.selectedItemName,
rarity: entry.selectedItemRarity,
}
: null
}
onChange={(item) =>
upd({
itemId: item ? String(item.id) : "",
selectedItemName: item?.name ?? "",
selectedItemRarity: item?.rarity ?? "",
})
}
/>
</Field>
<Field label="Quantity (optional, defaults to 1)">
<input
type="number"
min="1"
value={entry.amount}
onChange={(e) => upd({ amount: e.target.value })}
placeholder="1"
className={inputCls}
/>
</Field>
<Field label="Message (optional)">
<input
type="text"
value={entry.message}
onChange={(e) => upd({ message: e.target.value })}
placeholder="You found a rare item!"
className={inputCls}
/>
</Field>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { Plus } from "lucide-react";
import { cn } from "../../lib/utils";
import {
type LootPoolEntry,
type LootEntryType,
LOOT_TYPE_META,
makeLootEntry,
} from "./ItemStudioTypes";
import { LootPoolEntryEditor } from "./LootPoolEntryEditor";
// ===== Lootbox Pool Builder =====
export 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 (
<div className="space-y-3 pt-1">
{/* Summary bar */}
{pool.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary">
{pool.length} entr{pool.length === 1 ? "y" : "ies"} · total
weight {totalWeight}
</span>
<div className="flex gap-2">
{(Object.keys(LOOT_TYPE_META) as LootEntryType[]).map((t) => {
const count = pool.filter((e) => e.type === t).length;
if (!count) return null;
return (
<span
key={t}
className={cn(
"text-xs",
LOOT_TYPE_META[t].textColor
)}
>
{LOOT_TYPE_META[t].label} ×{count}
</span>
);
})}
</div>
</div>
{/* Stacked probability bar */}
<div className="flex h-2 w-full rounded-full overflow-hidden gap-px bg-raised">
{pool.map((e) => {
const w = Number(e.weight || 0);
const pct = totalWeight > 0 ? (w / totalWeight) * 100 : 0;
return (
<div
key={e._id}
style={{ width: `${pct}%` }}
className={cn(
"transition-all duration-200",
LOOT_TYPE_META[e.type].barColor
)}
title={`${LOOT_TYPE_META[e.type].label}: ${pct.toFixed(1)}%`}
/>
);
})}
</div>
</div>
)}
{/* Pool entries */}
{pool.map((entry, i) => (
<LootPoolEntryEditor
key={entry._id}
entry={entry}
totalWeight={totalWeight}
onChange={(updated) => updateEntry(i, updated)}
onRemove={() => removeEntry(i)}
/>
))}
{/* Empty hint */}
{pool.length === 0 && (
<p className="text-xs text-text-tertiary text-center py-2">
Add pool entries each has a type, weight, and reward amount.
</p>
)}
{/* Add entry */}
<button
onClick={addEntry}
className={cn(
"w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed text-xs",
"border-border text-text-tertiary hover:border-amber-500/50 hover:text-amber-400 transition-colors"
)}
>
<Plus className="w-3.5 h-3.5" />
Add Pool Entry
</button>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { Search } from "lucide-react";
import { cn } from "../../lib/utils";
// ---------------------------------------------------------------------------
// SearchFilterBar Component
// ---------------------------------------------------------------------------
export 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 (
<div className="flex flex-wrap gap-3 items-center">
{/* Search input */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
value={search}
onChange={(e) => 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"
)}
/>
</div>
{/* Class filter */}
<select
value={classId ?? ""}
onChange={(e) => onClassChange(e.target.value || null)}
className={cn(
"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",
"transition-colors"
)}
>
<option value="">All Classes</option>
{Array.isArray(classes) && classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* Active status filter */}
<select
value={isActive === null ? "" : String(isActive)}
onChange={(e) =>
onActiveChange(e.target.value === "" ? null : e.target.value === "true")
}
className={cn(
"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",
"transition-colors"
)}
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
{/* Sort by */}
<select
value={sortBy}
onChange={(e) => onSortByChange(e.target.value)}
className={cn(
"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",
"transition-colors"
)}
>
<option value="balance">Sort by Balance</option>
<option value="level">Sort by Level</option>
<option value="xp">Sort by XP</option>
<option value="username">Sort by Username</option>
</select>
{/* Sort order */}
<button
onClick={() => onSortOrderChange(sortOrder === "asc" ? "desc" : "asc")}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"hover:bg-raised transition-colors"
)}
>
{sortOrder === "asc" ? "\u2191 Asc" : "\u2193 Desc"}
</button>
{/* Clear filters */}
<button
onClick={onClear}
className={cn(
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
)}
>
Clear
</button>
</div>
);
}

View File

@@ -0,0 +1,486 @@
import { useState } from "react";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
import type { SettingsMeta } from "../../lib/useSettings";
// ---------------------------------------------------------------------------
// Reusable field components
// ---------------------------------------------------------------------------
export function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-text-secondary">
{label}
</label>
{children}
{hint && <p className="text-[11px] text-text-tertiary">{hint}</p>}
</div>
);
}
export function NumberInput({
value,
onChange,
min,
max,
step,
className,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
className?: string;
}) {
return (
<input
type="number"
value={value}
onChange={(e) => 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
)}
/>
);
}
export function StringInput({
value,
onChange,
placeholder,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<input
type="text"
value={value}
onChange={(e) => 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
)}
/>
);
}
export function TextArea({
value,
onChange,
placeholder,
rows = 3,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
rows?: number;
}) {
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground resize-y",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
/>
);
}
export function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-raised"
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
checked ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
);
}
export function SelectInput({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={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",
"transition-colors"
)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
/** Role picker dropdown -- selects a single role ID */
export function RolePicker({
value,
onChange,
roles,
placeholder = "Select a role...",
}: {
value: string | undefined;
onChange: (v: string | undefined) => void;
roles: SettingsMeta["roles"];
placeholder?: string;
}) {
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || undefined)}
className={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",
"transition-colors"
)}
>
<option value="">{placeholder}</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
);
}
/** Channel picker dropdown -- selects a single channel ID (text channels only) */
export function ChannelPicker({
value,
onChange,
channels,
placeholder = "Select a channel...",
}: {
value: string | undefined;
onChange: (v: string | undefined) => void;
channels: SettingsMeta["channels"];
placeholder?: string;
}) {
// type 0 = text channel
const textChannels = channels.filter((c) => c.type === 0);
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || undefined)}
className={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",
"transition-colors"
)}
>
<option value="">{placeholder}</option>
{textChannels.map((c) => (
<option key={c.id} value={c.id}>
#{c.name}
</option>
))}
</select>
);
}
/** Multi-select role picker for color roles */
export function MultiRolePicker({
selected,
onChange,
roles,
}: {
selected: string[];
onChange: (v: string[]) => void;
roles: SettingsMeta["roles"];
}) {
const available = roles.filter((r) => !selected.includes(r.id));
const selectedRoles = selected
.map((id) => roles.find((r) => r.id === id))
.filter(Boolean) as SettingsMeta["roles"];
return (
<div className="space-y-2">
{selectedRoles.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedRoles.map((r) => (
<span
key={r.id}
className="inline-flex items-center gap-1.5 bg-primary/15 border border-primary/30 rounded-full px-3 py-1 text-xs font-medium"
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: r.color !== "#000000" ? r.color : "#6B7280" }}
/>
{r.name}
<button
type="button"
onClick={() => onChange(selected.filter((id) => id !== r.id))}
className="ml-0.5 text-text-tertiary hover:text-destructive transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{available.length > 0 && (
<select
value=""
onChange={(e) => {
if (e.target.value) onChange([...selected, e.target.value]);
}}
className={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",
"transition-colors"
)}
>
<option value="">Add a color role...</option>
{available.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
)}
</div>
);
}
/** OpenTDB category IDs */
const TRIVIA_CATEGORIES: { id: number; name: string }[] = [
{ id: 9, name: "General Knowledge" },
{ id: 10, name: "Books" },
{ id: 11, name: "Film" },
{ id: 12, name: "Music" },
{ id: 13, name: "Musicals & Theatre" },
{ id: 14, name: "Television" },
{ id: 15, name: "Video Games" },
{ id: 16, name: "Board Games" },
{ id: 17, name: "Science & Nature" },
{ id: 18, name: "Computers" },
{ id: 19, name: "Mathematics" },
{ id: 20, name: "Mythology" },
{ id: 21, name: "Sports" },
{ id: 22, name: "Geography" },
{ id: 23, name: "History" },
{ id: 24, name: "Politics" },
{ id: 25, name: "Art" },
{ id: 26, name: "Celebrities" },
{ id: 27, name: "Animals" },
{ id: 28, name: "Vehicles" },
{ id: 29, name: "Comics" },
{ id: 30, name: "Gadgets" },
{ id: 31, name: "Anime & Manga" },
{ id: 32, name: "Cartoons & Animations" },
];
export function CategoryPicker({
selected,
onChange,
}: {
selected: number[];
onChange: (v: number[]) => void;
}) {
const available = TRIVIA_CATEGORIES.filter((c) => !selected.includes(c.id));
const selectedCats = selected
.map((id) => TRIVIA_CATEGORIES.find((c) => c.id === id))
.filter(Boolean) as typeof TRIVIA_CATEGORIES;
return (
<div className="space-y-2">
{selectedCats.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedCats.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 bg-info/15 border border-info/30 rounded-full px-3 py-1 text-xs font-medium"
>
{c.name}
<button
type="button"
onClick={() => onChange(selected.filter((id) => id !== c.id))}
className="ml-0.5 text-text-tertiary hover:text-destructive transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{available.length > 0 && (
<select
value=""
onChange={(e) => {
const id = Number(e.target.value);
if (id) onChange([...selected, id]);
}}
className={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",
"transition-colors"
)}
>
<option value="">Add a category...</option>
{available.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
</div>
);
}
/** Key-value editor for feature override flags */
export function FeatureOverridesEditor({
value,
onChange,
}: {
value: Record<string, boolean>;
onChange: (v: Record<string, boolean>) => void;
}) {
const [newKey, setNewKey] = useState("");
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
const addFlag = () => {
const key = newKey.trim().toLowerCase().replace(/\s+/g, "_");
if (!key || key in value) return;
onChange({ ...value, [key]: true });
setNewKey("");
};
return (
<div className="space-y-3">
{entries.length > 0 && (
<div className="space-y-2">
{entries.map(([key, enabled]) => (
<div
key={key}
className="flex items-center justify-between py-1.5"
>
<span
className={cn(
"text-sm font-mono",
enabled ? "text-foreground" : "text-text-disabled"
)}
>
{key}
</span>
<div className="flex items-center gap-3">
<Toggle
checked={enabled}
onChange={(v) => onChange({ ...value, [key]: v })}
/>
<button
type="button"
onClick={() => {
const { [key]: _, ...rest } = value;
onChange(rest);
}}
className="text-text-tertiary hover:text-destructive transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
<div className="flex gap-2">
<StringInput
value={newKey}
onChange={setNewKey}
placeholder="feature_name"
className="flex-1"
/>
<button
type="button"
onClick={addFlag}
disabled={!newKey.trim()}
className={cn(
"rounded-md px-4 py-2 text-sm font-medium transition-colors",
newKey.trim()
? "bg-primary/15 text-primary hover:bg-primary/25 border border-primary/30"
: "bg-raised text-text-disabled cursor-not-allowed"
)}
>
Add
</button>
</div>
</div>
);
}
export function SectionCard({
title,
icon: Icon,
children,
}: {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<div className="bg-card rounded-lg border border-border">
<div className="flex items-center gap-2.5 px-6 py-4 border-b border-border">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
<div className="px-6 py-5 space-y-5">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,649 @@
import {
TrendingUp,
Coins,
Package,
Gift,
Brain,
Shield,
Terminal,
Server,
Scroll,
} from "lucide-react";
import { cn } from "../../lib/utils";
import type { GameSettings, GuildSettings, SettingsMeta } from "../../lib/useSettings";
import {
Field,
NumberInput,
StringInput,
TextArea,
Toggle,
SelectInput,
RolePicker,
ChannelPicker,
MultiRolePicker,
CategoryPicker,
FeatureOverridesEditor,
SectionCard,
} from "./SettingsFormFields";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatMs(ms: number): string {
if (ms >= 86_400_000) return `${ms / 86_400_000}h`;
if (ms >= 60_000) return `${ms / 60_000} min`;
return `${ms / 1_000}s`;
}
// ---------------------------------------------------------------------------
// Section editors
// ---------------------------------------------------------------------------
export function GuildSection({
data,
onChange,
meta,
}: {
data: GuildSettings;
onChange: (d: GuildSettings) => void;
meta: SettingsMeta | null;
}) {
const roles = meta?.roles ?? [];
const channels = meta?.channels ?? [];
return (
<div className="space-y-6">
<SectionCard title="Roles" icon={Server}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Student Role" hint="Assigned to verified students">
<RolePicker
value={data.studentRoleId}
onChange={(v) => onChange({ ...data, studentRoleId: v })}
roles={roles}
/>
</Field>
<Field label="Visitor Role" hint="Assigned to unverified members">
<RolePicker
value={data.visitorRoleId}
onChange={(v) => onChange({ ...data, visitorRoleId: v })}
roles={roles}
/>
</Field>
</div>
<Field label="Color Roles" hint="Roles users can self-assign for name color">
<MultiRolePicker
selected={data.colorRoleIds ?? []}
onChange={(v) => onChange({ ...data, colorRoleIds: v })}
roles={roles}
/>
</Field>
</SectionCard>
<SectionCard title="Channels" icon={Server}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Welcome Channel">
<ChannelPicker
value={data.welcomeChannelId}
onChange={(v) => onChange({ ...data, welcomeChannelId: v })}
channels={channels}
/>
</Field>
<Field label="Feedback Channel">
<ChannelPicker
value={data.feedbackChannelId}
onChange={(v) => onChange({ ...data, feedbackChannelId: v })}
channels={channels}
/>
</Field>
<Field label="Terminal Channel">
<ChannelPicker
value={data.terminalChannelId}
onChange={(v) => onChange({ ...data, terminalChannelId: v })}
channels={channels}
/>
</Field>
<Field label="Moderation Log Channel">
<ChannelPicker
value={data.moderationLogChannelId}
onChange={(v) => onChange({ ...data, moderationLogChannelId: v })}
channels={channels}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="Welcome Message" icon={Server}>
<Field label="Message" hint="Sent when a new member joins. Leave empty to disable.">
<TextArea
value={data.welcomeMessage ?? ""}
onChange={(v) => onChange({ ...data, welcomeMessage: v || undefined })}
placeholder="Welcome to the academy, {user}!"
rows={3}
/>
</Field>
</SectionCard>
<SectionCard title="Moderation" icon={Shield}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="DM on Warn" hint="Send a DM to users when they receive a warning">
<Toggle
checked={data.moderationDmOnWarn ?? true}
onChange={(v) => onChange({ ...data, moderationDmOnWarn: v })}
/>
</Field>
<Field label="Auto-Timeout Threshold" hint="Warnings before auto-timeout (empty = disabled)">
<NumberInput
value={data.moderationAutoTimeoutThreshold ?? 0}
onChange={(v) =>
onChange({
...data,
moderationAutoTimeoutThreshold: v || undefined,
})
}
min={0}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="Feature Overrides" icon={Server}>
<p className="text-xs text-text-tertiary">
Toggle feature flags for this guild. Add new flags by name.
</p>
<FeatureOverridesEditor
value={data.featureOverrides ?? {}}
onChange={(v) => onChange({ ...data, featureOverrides: v })}
/>
</SectionCard>
</div>
);
}
export function LevelingSection({
data,
onChange,
}: {
data: GameSettings["leveling"];
onChange: (d: GameSettings["leveling"]) => void;
}) {
return (
<SectionCard title="Leveling" icon={TrendingUp}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Base XP" hint="Base XP required for level 2">
<NumberInput
value={data.base}
onChange={(v) => onChange({ ...data, base: v })}
min={1}
/>
</Field>
<Field label="Exponent" hint="XP growth curve exponent">
<NumberInput
value={data.exponent}
onChange={(v) => onChange({ ...data, exponent: v })}
min={1}
max={5}
step={0.1}
/>
</Field>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider pt-2">
Chat XP
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<Field label="Cooldown (ms)" hint={formatMs(data.chat.cooldownMs)}>
<NumberInput
value={data.chat.cooldownMs}
onChange={(v) =>
onChange({ ...data, chat: { ...data.chat, cooldownMs: v } })
}
min={0}
step={1000}
/>
</Field>
<Field label="Min XP">
<NumberInput
value={data.chat.minXp}
onChange={(v) =>
onChange({ ...data, chat: { ...data.chat, minXp: v } })
}
min={0}
/>
</Field>
<Field label="Max XP">
<NumberInput
value={data.chat.maxXp}
onChange={(v) =>
onChange({ ...data, chat: { ...data.chat, maxXp: v } })
}
min={0}
/>
</Field>
</div>
</SectionCard>
);
}
export function EconomySection({
data,
onChange,
}: {
data: GameSettings["economy"];
onChange: (d: GameSettings["economy"]) => void;
}) {
const setDaily = (patch: Partial<GameSettings["economy"]["daily"]>) =>
onChange({ ...data, daily: { ...data.daily, ...patch } });
const setTransfers = (
patch: Partial<GameSettings["economy"]["transfers"]>
) => onChange({ ...data, transfers: { ...data.transfers, ...patch } });
const setExam = (patch: Partial<GameSettings["economy"]["exam"]>) =>
onChange({ ...data, exam: { ...data.exam, ...patch } });
return (
<SectionCard title="Economy" icon={Coins}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Daily Rewards
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field label="Amount (AU)">
<StringInput
value={data.daily.amount}
onChange={(v) => setDaily({ amount: v })}
/>
</Field>
<Field label="Streak Bonus">
<StringInput
value={data.daily.streakBonus}
onChange={(v) => setDaily({ streakBonus: v })}
/>
</Field>
<Field label="Weekly Bonus">
<StringInput
value={data.daily.weeklyBonus}
onChange={(v) => setDaily({ weeklyBonus: v })}
/>
</Field>
<Field label="Cooldown (ms)" hint={formatMs(data.daily.cooldownMs)}>
<NumberInput
value={data.daily.cooldownMs}
onChange={(v) => setDaily({ cooldownMs: v })}
min={0}
step={1000}
/>
</Field>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider pt-2">
Transfers
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Min Amount">
<StringInput
value={data.transfers.minAmount}
onChange={(v) => setTransfers({ minAmount: v })}
/>
</Field>
<Field label="Allow Self-Transfer">
<Toggle
checked={data.transfers.allowSelfTransfer}
onChange={(v) => setTransfers({ allowSelfTransfer: v })}
/>
</Field>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider pt-2">
Exam
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Min Multiplier">
<NumberInput
value={data.exam.multMin}
onChange={(v) => setExam({ multMin: v })}
min={0}
step={0.1}
/>
</Field>
<Field label="Max Multiplier">
<NumberInput
value={data.exam.multMax}
onChange={(v) => setExam({ multMax: v })}
min={0}
step={0.1}
/>
</Field>
</div>
</SectionCard>
);
}
export function InventorySection({
data,
onChange,
}: {
data: GameSettings["inventory"];
onChange: (d: GameSettings["inventory"]) => void;
}) {
return (
<SectionCard title="Inventory" icon={Package}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Max Stack Size">
<StringInput
value={data.maxStackSize}
onChange={(v) => onChange({ ...data, maxStackSize: v })}
/>
</Field>
<Field label="Max Slots">
<NumberInput
value={data.maxSlots}
onChange={(v) => onChange({ ...data, maxSlots: v })}
min={1}
/>
</Field>
</div>
</SectionCard>
);
}
export function LootdropSection({
data,
onChange,
}: {
data: GameSettings["lootdrop"];
onChange: (d: GameSettings["lootdrop"]) => void;
}) {
const setReward = (patch: Partial<GameSettings["lootdrop"]["reward"]>) =>
onChange({ ...data, reward: { ...data.reward, ...patch } });
return (
<SectionCard title="Lootdrops" icon={Gift}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field
label="Activity Window (ms)"
hint={formatMs(data.activityWindowMs)}
>
<NumberInput
value={data.activityWindowMs}
onChange={(v) => onChange({ ...data, activityWindowMs: v })}
min={0}
step={1000}
/>
</Field>
<Field label="Min Messages">
<NumberInput
value={data.minMessages}
onChange={(v) => onChange({ ...data, minMessages: v })}
min={1}
/>
</Field>
<Field label="Spawn Chance" hint="0.0 - 1.0">
<NumberInput
value={data.spawnChance}
onChange={(v) => onChange({ ...data, spawnChance: v })}
min={0}
max={1}
step={0.01}
/>
</Field>
<Field label="Cooldown (ms)" hint={formatMs(data.cooldownMs)}>
<NumberInput
value={data.cooldownMs}
onChange={(v) => onChange({ ...data, cooldownMs: v })}
min={0}
step={1000}
/>
</Field>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider pt-2">
Reward
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<Field label="Min Reward">
<NumberInput
value={data.reward.min}
onChange={(v) => setReward({ min: v })}
min={0}
/>
</Field>
<Field label="Max Reward">
<NumberInput
value={data.reward.max}
onChange={(v) => setReward({ max: v })}
min={0}
/>
</Field>
<Field label="Currency">
<SelectInput
value={data.reward.currency}
onChange={(v) => setReward({ currency: v })}
options={[
{ value: "AU", label: "AU (Astral Units)" },
{ value: "CU", label: "CU (Constellation Units)" },
]}
/>
</Field>
</div>
</SectionCard>
);
}
export function TriviaSection({
data,
onChange,
}: {
data: GameSettings["trivia"];
onChange: (d: GameSettings["trivia"]) => void;
}) {
return (
<SectionCard title="Trivia" icon={Brain}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field label="Entry Fee (AU)">
<StringInput
value={data.entryFee}
onChange={(v) => onChange({ ...data, entryFee: v })}
/>
</Field>
<Field label="Reward Multiplier">
<NumberInput
value={data.rewardMultiplier}
onChange={(v) => onChange({ ...data, rewardMultiplier: v })}
min={0}
step={0.1}
/>
</Field>
<Field label="Timeout (seconds)">
<NumberInput
value={data.timeoutSeconds}
onChange={(v) => onChange({ ...data, timeoutSeconds: v })}
min={5}
/>
</Field>
<Field label="Cooldown (ms)" hint={formatMs(data.cooldownMs)}>
<NumberInput
value={data.cooldownMs}
onChange={(v) => onChange({ ...data, cooldownMs: v })}
min={0}
step={1000}
/>
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<Field label="Difficulty">
<SelectInput
value={data.difficulty}
onChange={(v) => onChange({ ...data, difficulty: v })}
options={[
{ value: "random", label: "Random" },
{ value: "easy", label: "Easy" },
{ value: "medium", label: "Medium" },
{ value: "hard", label: "Hard" },
]}
/>
</Field>
</div>
<Field
label="Categories"
hint="Select which OpenTDB categories to pull from. Leave empty for all categories."
>
<CategoryPicker
selected={data.categories ?? []}
onChange={(v) => onChange({ ...data, categories: v })}
/>
</Field>
</SectionCard>
);
}
export function ModerationSection({
data,
onChange,
}: {
data: GameSettings["moderation"];
onChange: (d: GameSettings["moderation"]) => void;
}) {
const setPrune = (patch: Partial<GameSettings["moderation"]["prune"]>) =>
onChange({ ...data, prune: { ...data.prune, ...patch } });
return (
<SectionCard title="Moderation" icon={Shield}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Prune
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field label="Max Amount">
<NumberInput
value={data.prune.maxAmount}
onChange={(v) => setPrune({ maxAmount: v })}
min={1}
/>
</Field>
<Field
label="Confirm Threshold"
hint="Require confirmation above this"
>
<NumberInput
value={data.prune.confirmThreshold}
onChange={(v) => setPrune({ confirmThreshold: v })}
min={1}
/>
</Field>
<Field label="Batch Size">
<NumberInput
value={data.prune.batchSize}
onChange={(v) => setPrune({ batchSize: v })}
min={1}
/>
</Field>
<Field
label="Batch Delay (ms)"
hint={formatMs(data.prune.batchDelayMs)}
>
<NumberInput
value={data.prune.batchDelayMs}
onChange={(v) => setPrune({ batchDelayMs: v })}
min={0}
step={100}
/>
</Field>
</div>
</SectionCard>
);
}
export function QuestSection({
data,
onChange,
}: {
data: GameSettings["quest"];
onChange: (d: GameSettings["quest"]) => void;
}) {
return (
<SectionCard title="Quests" icon={Scroll}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field
label="Max Active Quests"
hint="How many incomplete quests a player can have at once"
>
<NumberInput
value={data.maxActiveQuests}
onChange={(v) => onChange({ ...data, maxActiveQuests: v })}
min={1}
/>
</Field>
</div>
</SectionCard>
);
}
export function CommandsSection({
commands,
onChange,
meta,
}: {
commands: GameSettings["commands"];
meta: SettingsMeta | null;
onChange: (d: GameSettings["commands"]) => void;
}) {
const grouped = (meta?.commands ?? []).reduce<
Record<string, Array<{ name: string; category: string }>>
>((acc, cmd) => {
(acc[cmd.category] ??= []).push(cmd);
return acc;
}, {});
const categories = Object.keys(grouped).sort();
if (categories.length === 0) {
return (
<SectionCard title="Commands" icon={Terminal}>
<p className="text-sm text-text-tertiary">
No registered commands found. Make sure the bot is online.
</p>
</SectionCard>
);
}
return (
<SectionCard title="Commands" icon={Terminal}>
<p className="text-xs text-text-tertiary">
Toggle commands on or off. Disabled commands cannot be used by anyone.
</p>
<div className="space-y-6">
{categories.map((cat) => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">
{cat}
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-3">
{grouped[cat]!.map((cmd) => {
const enabled = commands[cmd.name] !== false;
return (
<div
key={cmd.name}
className="flex items-center justify-between py-1.5"
>
<span
className={cn(
"text-sm font-mono",
enabled ? "text-foreground" : "text-text-disabled"
)}
>
/{cmd.name}
</span>
<Toggle
checked={enabled}
onChange={(v) =>
onChange({ ...commands, [cmd.name]: v })
}
/>
</div>
);
})}
</div>
</div>
))}
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,309 @@
import { useState } from "react";
import {
Loader2,
AlertTriangle,
Save,
Check,
X,
Package,
Plus,
Trash2,
} from "lucide-react";
import { cn } from "../../lib/utils";
import type { User, InventoryEntry } from "../../lib/useUsers";
import {
Field,
NumberInput,
StringInput,
Toggle,
SelectInput,
SectionCard,
formatBigInt,
} from "./UserFormFields";
// ---------------------------------------------------------------------------
// InventoryAddForm Component
// ---------------------------------------------------------------------------
function InventoryAddForm({
items,
onAdd,
}: {
items: { id: number; name: string }[];
onAdd: (itemId: number, quantity: string) => void;
}) {
const [selectedItemId, setSelectedItemId] = useState<string>("");
const [quantity, setQuantity] = useState<string>("1");
const handleAdd = () => {
if (!selectedItemId) return;
onAdd(parseInt(selectedItemId), quantity);
setSelectedItemId("");
setQuantity("1");
};
return (
<div className="space-y-3">
<p className="text-xs font-semibold text-text-secondary">Add Item</p>
<select
value={selectedItemId}
onChange={(e) => setSelectedItemId(e.target.value)}
className={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",
"transition-colors"
)}
>
<option value="">Select item...</option>
{Array.isArray(items) && items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<div className="flex gap-2">
<input
type="number"
value={quantity}
onChange={(e) => 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"
)}
/>
<button
onClick={handleAdd}
disabled={!selectedItemId}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed",
"flex items-center justify-center gap-1.5"
)}
>
<Plus className="w-4 h-4" />
Add
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// DetailPanel Component
// ---------------------------------------------------------------------------
export function UserDetailPanel({
user,
userDraft,
onClose,
onUpdateDraft,
onSave,
onDiscard,
isDirty,
saving,
saveSuccess,
classes,
inventoryDraft,
items,
onAddItem,
onRemoveItem,
}: {
user: User;
userDraft: Partial<User> | 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 (
<div className="fixed inset-0 md:relative md:w-96 border-l border-border bg-card overflow-auto z-50 md:z-auto">
<div className="p-6 space-y-6 pb-24">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex-1">
<h2 className="text-lg font-semibold text-foreground mb-1">
{user.username}
</h2>
<p className="text-xs font-mono text-text-tertiary">{user.id}</p>
<p className="text-xs text-text-tertiary mt-1">
Joined {new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={onClose}
className="text-text-tertiary hover:text-foreground transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* User Info (Editable) */}
<div className="space-y-4">
<Field label="Balance" hint="User's currency balance">
<StringInput
value={String(userDraft.balance || "0")}
onChange={(v) => onUpdateDraft("balance", v)}
placeholder="0"
/>
</Field>
<Field label="XP" hint="User's experience points">
<StringInput
value={String(userDraft.xp || "0")}
onChange={(v) => onUpdateDraft("xp", v)}
placeholder="0"
/>
</Field>
<Field label="Level" hint="User's current level">
<NumberInput
value={userDraft.level || 1}
onChange={(v) => onUpdateDraft("level", v)}
min={1}
max={100}
/>
</Field>
<Field label="Daily Streak" hint="Consecutive days of daily command usage">
<NumberInput
value={userDraft.dailyStreak || 0}
onChange={(v) => onUpdateDraft("dailyStreak", v)}
min={0}
/>
</Field>
<Field label="Class" hint="User's selected class">
<SelectInput
value={String(userDraft.classId || "")}
onChange={(v) => onUpdateDraft("classId", v || null)}
options={classOptions}
/>
</Field>
<Field label="Active Status" hint="Whether the user is active in the system">
<div className="flex items-center gap-3">
<Toggle
checked={userDraft.isActive ?? true}
onChange={(v) => onUpdateDraft("isActive", v)}
/>
<span
className={cn(
"text-sm font-medium",
userDraft.isActive ? "text-green-400" : "text-gray-400"
)}
>
{userDraft.isActive ? "Active" : "Inactive"}
</span>
</div>
</Field>
</div>
{/* Inventory Section */}
<SectionCard title="Inventory" icon={Package}>
{inventoryDraft.length === 0 ? (
<p className="text-sm text-text-tertiary">No items in inventory</p>
) : (
<div className="space-y-2">
{inventoryDraft.map((entry) => (
<div
key={entry.itemId}
className="flex items-center justify-between gap-3 p-2 bg-raised rounded-md"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{entry.item?.name || `Item #${entry.itemId}`}
</p>
<p className="text-xs text-text-tertiary">
Quantity: {formatBigInt(entry.quantity)}
</p>
</div>
<button
onClick={() => onRemoveItem(entry.itemId)}
className="p-1.5 text-text-tertiary hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
title="Remove item"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* Add Item Form */}
<div className="mt-4 pt-4 border-t border-border">
<InventoryAddForm items={items} onAdd={onAddItem} />
</div>
</SectionCard>
</div>
{/* Sticky footer for save/discard (only shown when dirty) */}
{isDirty && (
<div className="sticky bottom-0 left-0 right-0 border-t border-border bg-card p-4 space-y-3">
<div className="flex items-center gap-2 text-amber-400">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">You have unsaved changes</span>
</div>
<div className="flex gap-2">
<button
onClick={onDiscard}
disabled={saving}
className={cn(
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
"bg-input border border-border text-foreground hover:bg-raised",
saving && "opacity-50 cursor-not-allowed"
)}
>
Discard
</button>
<button
onClick={onSave}
disabled={saving}
className={cn(
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90",
"flex items-center justify-center gap-2",
saving && "opacity-50 cursor-not-allowed"
)}
>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : saveSuccess ? (
<>
<Check className="w-4 h-4" />
Saved!
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { cn } from "../../lib/utils";
// ---------------------------------------------------------------------------
// Reusable field components for Users page
// ---------------------------------------------------------------------------
export function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-text-secondary">
{label}
</label>
{children}
{hint && <p className="text-[11px] text-text-tertiary">{hint}</p>}
</div>
);
}
export function NumberInput({
value,
onChange,
min,
max,
step,
className,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
className?: string;
}) {
return (
<input
type="number"
value={value}
onChange={(e) => 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
)}
/>
);
}
export function StringInput({
value,
onChange,
placeholder,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<input
type="text"
value={value}
onChange={(e) => 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
)}
/>
);
}
export function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-raised"
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
checked ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
);
}
export function SelectInput({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={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",
"transition-colors"
)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
export function SectionCard({
title,
icon: Icon,
children,
}: {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 mb-4">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function formatNumber(num: number | string): string {
const n = typeof num === "string" ? parseInt(num) : num;
return n.toLocaleString();
}
export function formatBigInt(value: string): string {
try {
const num = BigInt(value);
return num.toLocaleString();
} catch {
return value;
}
}

View File

@@ -0,0 +1,134 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "../../lib/utils";
import { formatNumber } from "./UserFormFields";
// ---------------------------------------------------------------------------
// Pagination Component
// ---------------------------------------------------------------------------
export function UserPagination({
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 (
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
{/* Items info */}
<p className="text-sm text-text-secondary">
Showing {startItem}\u2013{endItem} of {formatNumber(total)} users
</p>
{/* Page controls */}
<div className="flex items-center gap-2">
{/* Previous button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === 1
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{/* Page numbers */}
{getPageNumbers().map((page, i) =>
typeof page === "number" ? (
<button
key={i}
onClick={() => onPageChange(page)}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
page === currentPage
? "bg-primary text-white"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
{page}
</button>
) : (
<span key={i} className="px-2 text-text-tertiary">
{page}
</span>
)
)}
{/* Next button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
currentPage === totalPages
? "bg-raised text-text-tertiary cursor-not-allowed"
: "bg-input border border-border text-foreground hover:bg-raised"
)}
>
<ChevronRight className="w-4 h-4" />
</button>
{/* Items per page */}
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value))}
className={cn(
"ml-2 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",
"transition-colors"
)}
>
<option value="10">10 / page</option>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { UserCircle2 } from "lucide-react";
import { cn } from "../../lib/utils";
import type { User } from "../../lib/useUsers";
import { formatBigInt } from "./UserFormFields";
// ---------------------------------------------------------------------------
// UserTable Component
// ---------------------------------------------------------------------------
export function UserTable({
users,
loading,
onSelectUser,
}: {
users: User[];
loading: boolean;
onSelectUser: (user: User) => void;
}) {
if (loading) {
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Balance
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Class
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Status
</th>
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, i) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-32"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-12"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-24"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
</td>
<td className="px-4 py-3">
<div className="h-4 bg-raised rounded animate-pulse w-16"></div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
if (users.length === 0) {
return (
<div className="bg-card border border-border rounded-lg p-12 text-center">
<UserCircle2 className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
<p className="text-lg font-semibold text-text-secondary mb-2">
No users found
</p>
<p className="text-sm text-text-tertiary">
Try adjusting your search or filter criteria
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-raised border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Balance
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
XP
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Class
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
Status
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
onClick={() => onSelectUser(user)}
className="border-b border-border hover:bg-raised cursor-pointer transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<UserCircle2 className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-foreground">
{user.username}
</p>
<p className="text-xs text-text-tertiary font-mono">
{user.id}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{user.level}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-foreground">
{formatBigInt(user.balance)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-mono text-text-secondary">
{formatBigInt(user.xp)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground">
{user.class?.name || "\u2014"}
</span>
</td>
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
user.isActive
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
)}
>
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}