refactor(panel): extract page sub-components from mega-files
Some checks failed
Deploy to Production / test (push) Failing after 33s
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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
174
panel/src/pages/components/EffectEditor.tsx
Normal file
174
panel/src/pages/components/EffectEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
panel/src/pages/components/ItemPreviewCard.tsx
Normal file
152
panel/src/pages/components/ItemPreviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
panel/src/pages/components/ItemSearchPicker.tsx
Normal file
152
panel/src/pages/components/ItemSearchPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
panel/src/pages/components/ItemStudioForm.tsx
Normal file
379
panel/src/pages/components/ItemStudioForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
panel/src/pages/components/ItemStudioShared.tsx
Normal file
38
panel/src/pages/components/ItemStudioShared.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
panel/src/pages/components/ItemStudioSubmit.ts
Normal file
185
panel/src/pages/components/ItemStudioSubmit.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
304
panel/src/pages/components/ItemStudioTypes.ts
Normal file
304
panel/src/pages/components/ItemStudioTypes.ts
Normal 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"
|
||||
);
|
||||
222
panel/src/pages/components/LootPoolEntryEditor.tsx
Normal file
222
panel/src/pages/components/LootPoolEntryEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
panel/src/pages/components/LootboxEditor.tsx
Normal file
111
panel/src/pages/components/LootboxEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
panel/src/pages/components/SearchFilterBar.tsx
Normal file
127
panel/src/pages/components/SearchFilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
486
panel/src/pages/components/SettingsFormFields.tsx
Normal file
486
panel/src/pages/components/SettingsFormFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
649
panel/src/pages/components/SettingsSections.tsx
Normal file
649
panel/src/pages/components/SettingsSections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
panel/src/pages/components/UserDetailPanel.tsx
Normal file
309
panel/src/pages/components/UserDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
panel/src/pages/components/UserFormFields.tsx
Normal file
179
panel/src/pages/components/UserFormFields.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
134
panel/src/pages/components/UserPagination.tsx
Normal file
134
panel/src/pages/components/UserPagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
panel/src/pages/components/UserTable.tsx
Normal file
171
panel/src/pages/components/UserTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user