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 { useState, useEffect } from "react";
|
||||||
import {
|
import { AlertTriangle } from "lucide-react";
|
||||||
Loader2,
|
import { useUsers } from "../lib/useUsers";
|
||||||
AlertTriangle,
|
import { SearchFilterBar } from "./components/SearchFilterBar";
|
||||||
Save,
|
import { UserTable } from "./components/UserTable";
|
||||||
Check,
|
import { UserPagination } from "./components/UserPagination";
|
||||||
UserCircle2,
|
import { UserDetailPanel } from "./components/UserDetailPanel";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Users Component
|
// Main Users Component
|
||||||
@@ -1025,7 +127,7 @@ export default function Users() {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
<UserTable users={users} loading={loading} onSelectUser={selectUser} />
|
<UserTable users={users} loading={loading} onSelectUser={selectUser} />
|
||||||
<Pagination
|
<UserPagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={Math.ceil(total / limit)}
|
totalPages={Math.ceil(total / limit)}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
@@ -1037,7 +139,7 @@ export default function Users() {
|
|||||||
|
|
||||||
{/* Detail panel */}
|
{/* Detail panel */}
|
||||||
{selectedUser && userDraft && (
|
{selectedUser && userDraft && (
|
||||||
<DetailPanel
|
<UserDetailPanel
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
userDraft={userDraft}
|
userDraft={userDraft}
|
||||||
onClose={closeDetail}
|
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