feat(panel): replace all placeholder pages with real admin views
Some checks failed
Deploy to Production / test (push) Failing after 38s
Some checks failed
Deploy to Production / test (push) Failing after 38s
- Classes: full CRUD with list + detail panel - Quests: CRUD with search, trigger events and reward fields - Lootdrops: stat cards, spawn form, filter tabs, cancel action - Moderation: case list with filters, detail panel, create + resolve - Transactions: color-coded amounts, type/user filters, pagination - Rename player dashboard currency label from Gold to AU (Astral Units) - Remove unused placeholders map from App.tsx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,36 +6,17 @@ import Dashboard from "./pages/Dashboard";
|
|||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import Items from "./pages/Items";
|
import Items from "./pages/Items";
|
||||||
import PlaceholderPage from "./pages/PlaceholderPage";
|
import Classes from "./pages/Classes";
|
||||||
|
import Quests from "./pages/Quests";
|
||||||
|
import Lootdrops from "./pages/Lootdrops";
|
||||||
|
import Moderation from "./pages/Moderation";
|
||||||
|
import Transactions from "./pages/Transactions";
|
||||||
import Leaderboards from "./pages/Leaderboards";
|
import Leaderboards from "./pages/Leaderboards";
|
||||||
import NotEnrolled from "./pages/NotEnrolled";
|
import NotEnrolled from "./pages/NotEnrolled";
|
||||||
import PlayerDashboard from "./pages/PlayerDashboard";
|
import PlayerDashboard from "./pages/PlayerDashboard";
|
||||||
import { GameLobby } from "./games/GameLobby";
|
import { GameLobby } from "./games/GameLobby";
|
||||||
import { GameRoom } from "./games/GameRoom";
|
import { GameRoom } from "./games/GameRoom";
|
||||||
|
|
||||||
const placeholders: Record<string, { title: string; description: string }> = {
|
|
||||||
classes: {
|
|
||||||
title: "Classes",
|
|
||||||
description: "Manage academy classes, assign Discord roles, and track class balances.",
|
|
||||||
},
|
|
||||||
quests: {
|
|
||||||
title: "Quests",
|
|
||||||
description: "Configure quests with trigger events, targets, and XP/balance rewards.",
|
|
||||||
},
|
|
||||||
lootdrops: {
|
|
||||||
title: "Lootdrops",
|
|
||||||
description: "View active lootdrops, spawn new drops, and manage lootdrop history.",
|
|
||||||
},
|
|
||||||
moderation: {
|
|
||||||
title: "Moderation",
|
|
||||||
description: "Review moderation cases — warnings, timeouts, kicks, bans — and manage appeals.",
|
|
||||||
},
|
|
||||||
transactions: {
|
|
||||||
title: "Transactions",
|
|
||||||
description: "Browse the economy transaction log with filtering by user, type, and date.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { loading, user, enrolled, logout } = useAuth();
|
const { loading, user, enrolled, logout } = useAuth();
|
||||||
|
|
||||||
@@ -87,11 +68,11 @@ function AppRoutes() {
|
|||||||
<Route path="/admin" element={<Dashboard />} />
|
<Route path="/admin" element={<Dashboard />} />
|
||||||
<Route path="/admin/users" element={<Users />} />
|
<Route path="/admin/users" element={<Users />} />
|
||||||
<Route path="/admin/items" element={<Items />} />
|
<Route path="/admin/items" element={<Items />} />
|
||||||
<Route path="/admin/classes" element={<PlaceholderPage {...placeholders.classes} />} />
|
<Route path="/admin/classes" element={<Classes />} />
|
||||||
<Route path="/admin/quests" element={<PlaceholderPage {...placeholders.quests} />} />
|
<Route path="/admin/quests" element={<Quests />} />
|
||||||
<Route path="/admin/lootdrops" element={<PlaceholderPage {...placeholders.lootdrops} />} />
|
<Route path="/admin/lootdrops" element={<Lootdrops />} />
|
||||||
<Route path="/admin/moderation" element={<PlaceholderPage {...placeholders.moderation} />} />
|
<Route path="/admin/moderation" element={<Moderation />} />
|
||||||
<Route path="/admin/transactions" element={<PlaceholderPage {...placeholders.transactions} />} />
|
<Route path="/admin/transactions" element={<Transactions />} />
|
||||||
<Route path="/admin/settings" element={<Settings />} />
|
<Route path="/admin/settings" element={<Settings />} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
613
panel/src/pages/Classes.tsx
Normal file
613
panel/src/pages/Classes.tsx
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
GraduationCap,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Coins,
|
||||||
|
Hash,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { get, post, put, del } from "../lib/api";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ClassData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
balance: string;
|
||||||
|
roleId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassDraft {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
balance: string;
|
||||||
|
roleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatBalance(balance: string | number): string {
|
||||||
|
const n = typeof balance === "string" ? parseInt(balance) : balance;
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraft(): ClassDraft {
|
||||||
|
return { id: "", name: "", balance: "0", roleId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classToDraft(c: ClassData): ClassDraft {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
balance: c.balance ?? "0",
|
||||||
|
roleId: c.roleId ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ClassDetailPanel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ClassDetailPanel({
|
||||||
|
isNew,
|
||||||
|
draft,
|
||||||
|
onUpdate,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
onClose,
|
||||||
|
saving,
|
||||||
|
saveSuccess,
|
||||||
|
isDirty,
|
||||||
|
}: {
|
||||||
|
isNew: boolean;
|
||||||
|
draft: ClassDraft;
|
||||||
|
onUpdate: (patch: Partial<ClassDraft>) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
saveSuccess: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="w-[380px] border-l border-border/30 bg-surface flex flex-col overflow-y-auto">
|
||||||
|
{/* Panel header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border/30">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-foreground">
|
||||||
|
{isNew ? "Create Class" : "Edit Class"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-raised transition-colors text-text-tertiary hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form fields */}
|
||||||
|
<div className="flex-1 p-4 space-y-5">
|
||||||
|
{/* ID field (only editable on create) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-label font-semibold text-text-secondary uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<Hash className="w-3.5 h-3.5" />
|
||||||
|
ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.id}
|
||||||
|
onChange={(e) => onUpdate({ id: e.target.value })}
|
||||||
|
disabled={!isNew}
|
||||||
|
placeholder="Discord role snowflake ID"
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary transition-colors",
|
||||||
|
"placeholder:text-text-disabled",
|
||||||
|
!isNew && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isNew && (
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
Use a unique numeric identifier (e.g. Discord role ID).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-label font-semibold text-text-secondary uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<GraduationCap className="w-3.5 h-3.5" />
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => onUpdate({ name: e.target.value })}
|
||||||
|
placeholder="e.g. Warrior, Mage, Rogue"
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary transition-colors",
|
||||||
|
"placeholder:text-text-disabled"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-label font-semibold text-text-secondary uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<Coins className="w-3.5 h-3.5" />
|
||||||
|
Balance
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft.balance}
|
||||||
|
onChange={(e) => onUpdate({ balance: e.target.value.replace(/[^0-9-]/g, "") })}
|
||||||
|
placeholder="0"
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary transition-colors",
|
||||||
|
"placeholder:text-text-disabled"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role ID */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-label font-semibold text-text-secondary uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
Discord Role ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.roleId}
|
||||||
|
onChange={(e) => onUpdate({ roleId: e.target.value })}
|
||||||
|
placeholder="Optional Discord role snowflake"
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary transition-colors",
|
||||||
|
"placeholder:text-text-disabled"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel footer */}
|
||||||
|
<div className="p-4 border-t border-border/30 space-y-3">
|
||||||
|
{/* Save status */}
|
||||||
|
{saveSuccess && (
|
||||||
|
<p className="text-xs text-success font-medium text-center">
|
||||||
|
Saved successfully
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || (!isDirty && !isNew)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors",
|
||||||
|
saving || (!isDirty && !isNew)
|
||||||
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
|
: "bg-primary text-on-primary hover:bg-primary/90"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{isNew ? "Create" : "Save"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={saving}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors",
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ClassTable
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ClassTable({
|
||||||
|
classes,
|
||||||
|
loading,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
classes: ClassData[];
|
||||||
|
loading: boolean;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (c: ClassData) => void;
|
||||||
|
}) {
|
||||||
|
const columns = ["ID", "Name", "Balance", "Role ID"];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-raised">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col} className="px-4 py-3">
|
||||||
|
<div className="h-4 bg-raised rounded animate-pulse w-24" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl p-12 text-center">
|
||||||
|
<GraduationCap className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
||||||
|
<p className="text-lg font-semibold text-text-secondary mb-2">
|
||||||
|
No classes found
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-tertiary">
|
||||||
|
Create your first class to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-raised">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<tr
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => onSelect(c)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-colors",
|
||||||
|
selectedId === c.id
|
||||||
|
? "bg-primary/10"
|
||||||
|
: "hover:bg-raised/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-mono text-text-tertiary">
|
||||||
|
{c.id}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-mono text-foreground">
|
||||||
|
{formatBalance(c.balance)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-mono text-text-tertiary">
|
||||||
|
{c.roleId || "---"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Classes Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Classes() {
|
||||||
|
const [classes, setClasses] = useState<ClassData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Selection & draft state
|
||||||
|
const [selectedClass, setSelectedClass] = useState<ClassData | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<ClassDraft>(emptyDraft());
|
||||||
|
const [originalDraft, setOriginalDraft] = useState<ClassDraft>(emptyDraft());
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Fetch
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fetchClasses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await get<{ classes: ClassData[] }>("/api/classes");
|
||||||
|
setClasses(data.classes ?? []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.error ?? "Failed to fetch classes");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClasses();
|
||||||
|
}, [fetchClasses]);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Selection helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const isDirty =
|
||||||
|
draft.name !== originalDraft.name ||
|
||||||
|
draft.balance !== originalDraft.balance ||
|
||||||
|
draft.roleId !== originalDraft.roleId ||
|
||||||
|
(isCreating && draft.id !== originalDraft.id);
|
||||||
|
|
||||||
|
const selectClass = (c: ClassData) => {
|
||||||
|
if (isDirty) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"You have unsaved changes. Discard them?"
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
setSelectedClass(c);
|
||||||
|
const d = classToDraft(c);
|
||||||
|
setDraft(d);
|
||||||
|
setOriginalDraft(d);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
if (isDirty) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"You have unsaved changes. Discard them?"
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
setSelectedClass(null);
|
||||||
|
setIsCreating(true);
|
||||||
|
const d = emptyDraft();
|
||||||
|
setDraft(d);
|
||||||
|
setOriginalDraft(d);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePanel = () => {
|
||||||
|
if (isDirty) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"You have unsaved changes. Discard them?"
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
setSelectedClass(null);
|
||||||
|
setIsCreating(false);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcut
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && (selectedClass || isCreating)) {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [selectedClass, isCreating, isDirty]);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isCreating) {
|
||||||
|
if (!draft.id || !draft.name.trim()) {
|
||||||
|
setError("ID and Name are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await post("/api/classes", {
|
||||||
|
id: draft.id,
|
||||||
|
name: draft.name.trim(),
|
||||||
|
balance: draft.balance || "0",
|
||||||
|
roleId: draft.roleId || null,
|
||||||
|
});
|
||||||
|
} else if (selectedClass) {
|
||||||
|
await put(`/api/classes/${selectedClass.id}`, {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
balance: draft.balance || "0",
|
||||||
|
roleId: draft.roleId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setTimeout(() => setSaveSuccess(false), 2000);
|
||||||
|
|
||||||
|
await fetchClasses();
|
||||||
|
|
||||||
|
// If creating, switch to edit mode for the new class
|
||||||
|
if (isCreating) {
|
||||||
|
setIsCreating(false);
|
||||||
|
const created = (await get<{ classes: ClassData[] }>("/api/classes")).classes.find(
|
||||||
|
(c) => c.id === draft.id
|
||||||
|
);
|
||||||
|
if (created) {
|
||||||
|
setSelectedClass(created);
|
||||||
|
const d = classToDraft(created);
|
||||||
|
setDraft(d);
|
||||||
|
setOriginalDraft(d);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update original draft so isDirty resets
|
||||||
|
setOriginalDraft({ ...draft });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.error ?? "Failed to save class");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedClass) return;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Delete class "${selectedClass.name}"? Users in this class will need to be reassigned.`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
await del(`/api/classes/${selectedClass.id}`);
|
||||||
|
setSelectedClass(null);
|
||||||
|
setIsCreating(false);
|
||||||
|
await fetchClasses();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.error ?? "Failed to delete class");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const panelOpen = selectedClass !== null || isCreating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-display font-bold text-foreground">
|
||||||
|
Classes
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={startCreate}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors",
|
||||||
|
"bg-primary text-on-primary hover:bg-primary/90"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Class
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-tertiary">
|
||||||
|
Manage academy classes, assign Discord roles, and track class balances.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-6 bg-destructive/10 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Error</p>
|
||||||
|
<p className="text-sm text-destructive/90">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="p-1 rounded hover:bg-destructive/10 transition-colors text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
<ClassTable
|
||||||
|
classes={classes}
|
||||||
|
loading={loading}
|
||||||
|
selectedId={selectedClass?.id ?? null}
|
||||||
|
onSelect={selectClass}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!loading && classes.length > 0 && (
|
||||||
|
<p className="mt-4 text-sm text-text-secondary">
|
||||||
|
{classes.length} class{classes.length !== 1 ? "es" : ""} total
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail panel */}
|
||||||
|
{panelOpen && (
|
||||||
|
<ClassDetailPanel
|
||||||
|
isNew={isCreating}
|
||||||
|
draft={draft}
|
||||||
|
onUpdate={(patch) => setDraft((prev) => ({ ...prev, ...patch }))}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onClose={closePanel}
|
||||||
|
saving={saving}
|
||||||
|
saveSuccess={saveSuccess}
|
||||||
|
isDirty={isDirty}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
563
panel/src/pages/Lootdrops.tsx
Normal file
563
panel/src/pages/Lootdrops.tsx
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Gift,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
Coins,
|
||||||
|
User,
|
||||||
|
Hash,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { get, post, del, type ApiError } from "../lib/api";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Lootdrop {
|
||||||
|
messageId: string;
|
||||||
|
channelId: string;
|
||||||
|
rewardAmount: number;
|
||||||
|
currency: string;
|
||||||
|
claimedBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return "just now";
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(drop: Lootdrop): boolean {
|
||||||
|
if (drop.claimedBy) return false;
|
||||||
|
if (!drop.expiresAt) return true;
|
||||||
|
return new Date(drop.expiresAt).getTime() > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus(drop: Lootdrop): "active" | "claimed" | "expired" {
|
||||||
|
if (drop.claimedBy) return "claimed";
|
||||||
|
if (!drop.expiresAt) return "active";
|
||||||
|
return new Date(drop.expiresAt).getTime() > Date.now() ? "active" : "expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
active: "bg-success/20 text-success",
|
||||||
|
claimed: "bg-primary/20 text-primary",
|
||||||
|
expired: "bg-text-tertiary/20 text-text-tertiary",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: "Active",
|
||||||
|
claimed: "Claimed",
|
||||||
|
expired: "Expired",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SpawnForm
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SpawnForm({ onSpawned }: { onSpawned: () => void }) {
|
||||||
|
const [channelId, setChannelId] = useState("");
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [currency, setCurrency] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!channelId.trim()) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = { channelId: channelId.trim() };
|
||||||
|
if (amount) body.amount = parseInt(amount);
|
||||||
|
if (currency.trim()) body.currency = currency.trim();
|
||||||
|
|
||||||
|
await post("/api/lootdrops", body);
|
||||||
|
setResult({ ok: true, message: "Lootdrop spawned successfully." });
|
||||||
|
setChannelId("");
|
||||||
|
setAmount("");
|
||||||
|
setCurrency("");
|
||||||
|
onSpawned();
|
||||||
|
} catch (err) {
|
||||||
|
const apiErr = err as ApiError;
|
||||||
|
setResult({ ok: false, message: apiErr.error ?? "Failed to spawn lootdrop." });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-card rounded-xl p-5 space-y-4">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Send className="w-5 h-5 text-primary" />
|
||||||
|
Spawn Lootdrop
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-label text-text-secondary uppercase tracking-wide">
|
||||||
|
Channel ID <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={channelId}
|
||||||
|
onChange={(e) => setChannelId(e.target.value)}
|
||||||
|
placeholder="e.g. 1234567890123456"
|
||||||
|
required
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary",
|
||||||
|
"transition-colors placeholder:text-text-disabled"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-label text-text-secondary uppercase tracking-wide">
|
||||||
|
Amount <span className="text-text-tertiary">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="Random if empty"
|
||||||
|
min="1"
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary",
|
||||||
|
"transition-colors placeholder:text-text-disabled"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-label text-text-secondary uppercase tracking-wide">
|
||||||
|
Currency <span className="text-text-tertiary">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
|
placeholder="Default from config"
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary",
|
||||||
|
"transition-colors placeholder:text-text-disabled"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !channelId.trim()}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-label font-medium transition-colors",
|
||||||
|
submitting || !channelId.trim()
|
||||||
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
|
: "bg-primary text-on-primary hover:bg-primary/90"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Spawn
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 text-sm",
|
||||||
|
result.ok ? "text-success" : "text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result.ok ? <Check className="w-4 h-4" /> : <X className="w-4 h-4" />}
|
||||||
|
{result.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LootdropTable
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LootdropTable({
|
||||||
|
drops,
|
||||||
|
loading,
|
||||||
|
onDelete,
|
||||||
|
deletingId,
|
||||||
|
}: {
|
||||||
|
drops: Lootdrop[];
|
||||||
|
loading: boolean;
|
||||||
|
onDelete: (messageId: string) => void;
|
||||||
|
deletingId: string | null;
|
||||||
|
}) {
|
||||||
|
const columns = ["Status", "Reward", "Channel", "Claimed By", "Created", "Expires", ""];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-raised">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col} className="px-4 py-3">
|
||||||
|
<div className="h-4 bg-raised rounded animate-pulse w-20" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drops.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl p-12 text-center">
|
||||||
|
<Gift className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
||||||
|
<p className="text-lg font-semibold text-text-secondary mb-2">No lootdrops found</p>
|
||||||
|
<p className="text-sm text-text-tertiary">
|
||||||
|
Lootdrops will appear here when they are spawned in Discord channels.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-raised">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{drops.map((drop) => {
|
||||||
|
const status = getStatus(drop);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={drop.messageId}
|
||||||
|
className="hover:bg-raised/40 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
|
||||||
|
STATUS_STYLES[status]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-mono text-foreground">
|
||||||
|
<Coins className="w-3.5 h-3.5 text-primary" />
|
||||||
|
{drop.rewardAmount.toLocaleString()} {drop.currency}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-mono text-text-secondary">
|
||||||
|
<Hash className="w-3.5 h-3.5 text-text-tertiary" />
|
||||||
|
{drop.channelId}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{drop.claimedBy ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-mono text-foreground">
|
||||||
|
<User className="w-3.5 h-3.5 text-text-tertiary" />
|
||||||
|
{drop.claimedBy}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-text-tertiary">--</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className="text-sm text-text-secondary"
|
||||||
|
title={formatTimestamp(drop.createdAt)}
|
||||||
|
>
|
||||||
|
{timeAgo(drop.createdAt)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{drop.expiresAt ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 text-sm",
|
||||||
|
status === "expired" ? "text-text-tertiary" : "text-text-secondary"
|
||||||
|
)}
|
||||||
|
title={formatTimestamp(drop.expiresAt)}
|
||||||
|
>
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{formatTimestamp(drop.expiresAt)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-text-tertiary">--</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{status === "active" && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(drop.messageId)}
|
||||||
|
disabled={deletingId === drop.messageId}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-label font-medium transition-colors",
|
||||||
|
deletingId === drop.messageId
|
||||||
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
|
: "bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||||
|
)}
|
||||||
|
title="Cancel this lootdrop"
|
||||||
|
>
|
||||||
|
{deletingId === drop.messageId ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// StatCards
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatCards({ drops }: { drops: Lootdrop[] }) {
|
||||||
|
const active = drops.filter((d) => isActive(d));
|
||||||
|
const claimed = drops.filter((d) => d.claimedBy !== null);
|
||||||
|
const expired = drops.filter((d) => getStatus(d) === "expired");
|
||||||
|
const totalRewards = claimed.reduce((sum, d) => sum + d.rewardAmount, 0);
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: "Active", value: active.length, color: "text-success" },
|
||||||
|
{ label: "Claimed", value: claimed.length, color: "text-primary" },
|
||||||
|
{ label: "Expired", value: expired.length, color: "text-text-tertiary" },
|
||||||
|
{ label: "Total Rewarded", value: totalRewards.toLocaleString(), color: "text-foreground" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<div key={card.label} className="bg-card rounded-xl p-4">
|
||||||
|
<p className="text-xs font-label text-text-secondary uppercase tracking-wide mb-1">
|
||||||
|
{card.label}
|
||||||
|
</p>
|
||||||
|
<p className={cn("text-xl font-display font-bold", card.color)}>{card.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FilterTab = "all" | "active" | "claimed" | "expired";
|
||||||
|
|
||||||
|
export default function Lootdrops() {
|
||||||
|
const [drops, setDrops] = useState<Lootdrop[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filterTab, setFilterTab] = useState<FilterTab>("all");
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchDrops = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await get<{ lootdrops: Lootdrop[] }>("/api/lootdrops?limit=100");
|
||||||
|
setDrops(res.lootdrops);
|
||||||
|
} catch (err) {
|
||||||
|
const apiErr = err as ApiError;
|
||||||
|
setError(apiErr.error ?? "Failed to load lootdrops.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDrops();
|
||||||
|
}, [fetchDrops]);
|
||||||
|
|
||||||
|
const handleDelete = async (messageId: string) => {
|
||||||
|
setDeletingId(messageId);
|
||||||
|
try {
|
||||||
|
await del(`/api/lootdrops/${messageId}`);
|
||||||
|
setDrops((prev) => prev.filter((d) => d.messageId !== messageId));
|
||||||
|
} catch (err) {
|
||||||
|
const apiErr = err as ApiError;
|
||||||
|
setError(apiErr.error ?? "Failed to cancel lootdrop.");
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredDrops = drops.filter((d) => {
|
||||||
|
if (filterTab === "all") return true;
|
||||||
|
return getStatus(d) === filterTab;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs: { key: FilterTab; label: string; count: number }[] = [
|
||||||
|
{ key: "all", label: "All", count: drops.length },
|
||||||
|
{ key: "active", label: "Active", count: drops.filter((d) => isActive(d)).length },
|
||||||
|
{ key: "claimed", label: "Claimed", count: drops.filter((d) => d.claimedBy !== null).length },
|
||||||
|
{ key: "expired", label: "Expired", count: drops.filter((d) => getStatus(d) === "expired").length },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-display font-bold text-foreground">Lootdrops</h1>
|
||||||
|
<button
|
||||||
|
onClick={fetchDrops}
|
||||||
|
disabled={loading}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-label font-medium transition-colors",
|
||||||
|
loading
|
||||||
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
|
: "bg-raised text-foreground hover:bg-surface-container-highest"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-1 border-b border-border/30 -mb-4 pb-px">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setFilterTab(tab.key)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
filterTab === tab.key
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-1.5 py-0.5 rounded-full",
|
||||||
|
filterTab === tab.key
|
||||||
|
? "bg-primary/20 text-primary"
|
||||||
|
: "bg-raised text-text-tertiary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-6 bg-destructive/10 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Error</p>
|
||||||
|
<p className="text-sm text-destructive/90">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-destructive/60 hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||||
|
{/* Spawn form */}
|
||||||
|
<SpawnForm onSpawned={fetchDrops} />
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{!loading && drops.length > 0 && <StatCards drops={drops} />}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<LootdropTable
|
||||||
|
drops={filteredDrops}
|
||||||
|
loading={loading}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
deletingId={deletingId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1012
panel/src/pages/Moderation.tsx
Normal file
1012
panel/src/pages/Moderation.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -72,7 +72,7 @@ export default function PlayerDashboard({ userId }: { userId: string }) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
<StatCard label="Level" value={String(user.level)} accent="primary" subtitle={user.className ?? undefined} />
|
<StatCard label="Level" value={String(user.level)} accent="primary" subtitle={user.className ?? undefined} />
|
||||||
<StatCard label="Gold" value={formatNumber(user.balance)} accent="gold" />
|
<StatCard label="AU" value={formatNumber(user.balance)} accent="gold" subtitle="Astral Units" />
|
||||||
<StatCard label="XP" value={formatNumber(user.xp)} accent="info" />
|
<StatCard label="XP" value={formatNumber(user.xp)} accent="info" />
|
||||||
<StatCard label="Items" value={inventoryError ? "—" : String(inventory.length)} accent="success" />
|
<StatCard label="Items" value={inventoryError ? "—" : String(inventory.length)} accent="success" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
574
panel/src/pages/Quests.tsx
Normal file
574
panel/src/pages/Quests.tsx
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
Scroll,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
|
Zap,
|
||||||
|
Target,
|
||||||
|
Coins,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { get, post, put, del } from "../lib/api";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Quest {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
triggerEvent: string;
|
||||||
|
requirements: { target?: number };
|
||||||
|
rewards: { xp?: number; balance?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestDraft {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
triggerEvent: string;
|
||||||
|
target: number;
|
||||||
|
xpReward: number;
|
||||||
|
balanceReward: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: QuestDraft = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
triggerEvent: "",
|
||||||
|
target: 1,
|
||||||
|
xpReward: 0,
|
||||||
|
balanceReward: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function questToDraft(q: Quest): QuestDraft {
|
||||||
|
return {
|
||||||
|
name: q.name,
|
||||||
|
description: q.description ?? "",
|
||||||
|
triggerEvent: q.triggerEvent,
|
||||||
|
target: q.requirements?.target ?? 1,
|
||||||
|
xpReward: q.rewards?.xp ?? 0,
|
||||||
|
balanceReward: q.rewards?.balance ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Quest Form
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function QuestForm({
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
saving,
|
||||||
|
isNew,
|
||||||
|
}: {
|
||||||
|
draft: QuestDraft;
|
||||||
|
onChange: (d: QuestDraft) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
isNew: boolean;
|
||||||
|
}) {
|
||||||
|
const inputClass = cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary transition-colors",
|
||||||
|
"placeholder:text-text-disabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelClass = "block text-xs font-label font-semibold text-text-secondary uppercase tracking-wide mb-1";
|
||||||
|
|
||||||
|
const valid = draft.name.trim().length > 0 && draft.triggerEvent.trim().length > 0 && draft.target >= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-foreground">
|
||||||
|
{isNew ? "New Quest" : "Edit Quest"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-raised transition-colors text-text-tertiary hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className={labelClass}>Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => onChange({ ...draft, name: e.target.value })}
|
||||||
|
placeholder="e.g. Win 5 Battles"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className={labelClass}>Description</label>
|
||||||
|
<textarea
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(e) => onChange({ ...draft, description: e.target.value })}
|
||||||
|
placeholder="A brief description of the quest objective..."
|
||||||
|
rows={2}
|
||||||
|
className={cn(inputClass, "resize-none")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trigger Event */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Trigger Event</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.triggerEvent}
|
||||||
|
onChange={(e) => onChange({ ...draft, triggerEvent: e.target.value })}
|
||||||
|
placeholder="e.g. battle_win, ITEM_COLLECT"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-text-tertiary">
|
||||||
|
The system event that advances this quest
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Target</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={draft.target}
|
||||||
|
onChange={(e) => onChange({ ...draft, target: Math.max(1, Number(e.target.value)) })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-text-tertiary">
|
||||||
|
How many events to complete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* XP Reward */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>XP Reward</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={draft.xpReward}
|
||||||
|
onChange={(e) => onChange({ ...draft, xpReward: Math.max(0, Number(e.target.value)) })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance Reward */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Balance Reward</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={draft.balanceReward}
|
||||||
|
onChange={(e) => onChange({ ...draft, balanceReward: Math.max(0, Number(e.target.value)) })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || !valid}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors",
|
||||||
|
valid && !saving
|
||||||
|
? "bg-primary text-on-primary hover:bg-primary/90"
|
||||||
|
: "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{isNew ? "Create Quest" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 rounded-xl text-sm font-medium bg-raised text-text-secondary hover:bg-surface-container-highest transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isNew && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="ml-auto inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Quest Table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function QuestTable({
|
||||||
|
quests,
|
||||||
|
loading,
|
||||||
|
onSelect,
|
||||||
|
selectedId,
|
||||||
|
}: {
|
||||||
|
quests: Quest[];
|
||||||
|
loading: boolean;
|
||||||
|
onSelect: (q: Quest) => void;
|
||||||
|
selectedId: number | null;
|
||||||
|
}) {
|
||||||
|
const columns = ["ID", "Name", "Trigger", "Target", "XP", "Balance"];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-raised">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col} className="px-4 py-3">
|
||||||
|
<div className="h-4 bg-raised rounded animate-pulse w-20" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quests.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl p-12 text-center">
|
||||||
|
<Scroll className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
||||||
|
<p className="text-lg font-semibold text-text-secondary mb-2">No quests found</p>
|
||||||
|
<p className="text-sm text-text-tertiary">
|
||||||
|
Create your first quest to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-raised">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{quests.map((q) => (
|
||||||
|
<tr
|
||||||
|
key={q.id}
|
||||||
|
onClick={() => onSelect(q)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-colors",
|
||||||
|
q.id === selectedId
|
||||||
|
? "bg-primary/10"
|
||||||
|
: "hover:bg-raised/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-mono text-text-tertiary">{q.id}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-foreground">{q.name}</span>
|
||||||
|
{q.description && (
|
||||||
|
<p className="text-xs text-text-tertiary truncate max-w-[240px]">
|
||||||
|
{q.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-info/10 text-info">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
{q.triggerEvent}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm text-foreground">
|
||||||
|
<Target className="w-3.5 h-3.5 text-text-tertiary" />
|
||||||
|
{q.requirements?.target ?? 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm font-mono text-foreground">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
{(q.rewards?.xp ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm font-mono text-foreground">
|
||||||
|
<Coins className="w-3.5 h-3.5 text-primary" />
|
||||||
|
{(q.rewards?.balance ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Quests Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Quests() {
|
||||||
|
const [quests, setQuests] = useState<Quest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedQuest, setSelectedQuest] = useState<Quest | null>(null);
|
||||||
|
const [draft, setDraft] = useState<QuestDraft | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
|
// ---- Fetch quests ----
|
||||||
|
const fetchQuests = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await get<{ success: boolean; data: Quest[] }>("/api/quests");
|
||||||
|
setQuests(res.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.error ?? "Failed to load quests");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQuests();
|
||||||
|
}, [fetchQuests]);
|
||||||
|
|
||||||
|
// ---- Keyboard shortcuts ----
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && (draft || isCreating)) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [draft, isCreating]);
|
||||||
|
|
||||||
|
// ---- Filtered quests ----
|
||||||
|
const filtered = quests.filter((q) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const s = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
q.name.toLowerCase().includes(s) ||
|
||||||
|
(q.description ?? "").toLowerCase().includes(s) ||
|
||||||
|
q.triggerEvent.toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Handlers ----
|
||||||
|
function handleSelect(q: Quest) {
|
||||||
|
setIsCreating(false);
|
||||||
|
setSelectedQuest(q);
|
||||||
|
setDraft(questToDraft(q));
|
||||||
|
setSaveSuccess(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartCreate() {
|
||||||
|
setSelectedQuest(null);
|
||||||
|
setDraft({ ...EMPTY_DRAFT });
|
||||||
|
setIsCreating(true);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setSelectedQuest(null);
|
||||||
|
setDraft(null);
|
||||||
|
setIsCreating(false);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!draft) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
await post("/api/quests", draft);
|
||||||
|
} else if (selectedQuest) {
|
||||||
|
await put(`/api/quests/${selectedQuest.id}`, draft);
|
||||||
|
}
|
||||||
|
await fetchQuests();
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setTimeout(() => setSaveSuccess(false), 2000);
|
||||||
|
if (isCreating) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.error ?? "Failed to save quest");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!selectedQuest) return;
|
||||||
|
const confirmed = window.confirm(`Delete quest "${selectedQuest.name}"? This cannot be undone.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await del(`/api/quests/${selectedQuest.id}`);
|
||||||
|
handleCancel();
|
||||||
|
await fetchQuests();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.error ?? "Failed to delete quest");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-display font-bold text-foreground">Quests</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleStartCreate}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-primary text-on-primary hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Quest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<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) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search quests..."
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none pl-10 pr-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary",
|
||||||
|
"transition-colors placeholder:text-text-disabled"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-6 bg-destructive/10 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Error</p>
|
||||||
|
<p className="text-sm text-destructive/90">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="p-1 rounded hover:bg-destructive/10 text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save success banner */}
|
||||||
|
{saveSuccess && (
|
||||||
|
<div className="mx-6 bg-success/10 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<Sparkles className="w-5 h-5 text-success flex-shrink-0" />
|
||||||
|
<p className="text-sm font-medium text-success">Quest saved successfully</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Quest list */}
|
||||||
|
<div className="flex-1 overflow-auto p-6 pt-2">
|
||||||
|
<QuestTable
|
||||||
|
quests={filtered}
|
||||||
|
loading={loading}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
selectedId={selectedQuest?.id ?? null}
|
||||||
|
/>
|
||||||
|
{!loading && (
|
||||||
|
<p className="mt-3 text-xs text-text-tertiary">
|
||||||
|
{filtered.length} quest{filtered.length !== 1 ? "s" : ""} total
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail / Create panel */}
|
||||||
|
{draft && (
|
||||||
|
<div className="w-[420px] border-l border-border/30 overflow-auto p-6">
|
||||||
|
<QuestForm
|
||||||
|
draft={draft}
|
||||||
|
onChange={setDraft}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onDelete={isCreating ? undefined : handleDelete}
|
||||||
|
saving={saving}
|
||||||
|
isNew={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
376
panel/src/pages/Transactions.tsx
Normal file
376
panel/src/pages/Transactions.tsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownLeft,
|
||||||
|
Receipt,
|
||||||
|
X,
|
||||||
|
Calendar,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { get } from "../lib/api";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
relatedUserId: string | null;
|
||||||
|
amount: string;
|
||||||
|
type: string;
|
||||||
|
description: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransactionsResponse {
|
||||||
|
transactions: Transaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TRANSACTION_TYPES = [
|
||||||
|
"DAILY_REWARD",
|
||||||
|
"TRANSFER_IN",
|
||||||
|
"TRANSFER_OUT",
|
||||||
|
"LOOTDROP_CLAIM",
|
||||||
|
"SHOP_BUY",
|
||||||
|
"QUEST_REWARD",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TYPE_CONFIG: Record<string, { label: string; color: string; sign: "+" | "-" | null }> = {
|
||||||
|
DAILY_REWARD: { label: "Daily Reward", color: "bg-green-500/20 text-green-400", sign: "+" },
|
||||||
|
TRANSFER_IN: { label: "Transfer In", color: "bg-blue-500/20 text-blue-400", sign: "+" },
|
||||||
|
TRANSFER_OUT: { label: "Transfer Out", color: "bg-orange-500/20 text-orange-400", sign: "-" },
|
||||||
|
LOOTDROP_CLAIM:{ label: "Lootdrop", color: "bg-purple-500/20 text-purple-400", sign: "+" },
|
||||||
|
SHOP_BUY: { label: "Shop Purchase", color: "bg-amber-500/20 text-amber-400", sign: "-" },
|
||||||
|
QUEST_REWARD: { label: "Quest Reward", color: "bg-emerald-500/20 text-emerald-400", sign: "+" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatAmount(amount: string): string {
|
||||||
|
const n = BigInt(amount);
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeConfig(type: string) {
|
||||||
|
return TYPE_CONFIG[type] ?? { label: type, color: "bg-gray-500/20 text-gray-400", sign: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositive(type: string): boolean {
|
||||||
|
const config = getTypeConfig(type);
|
||||||
|
return config.sign === "+";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Transactions() {
|
||||||
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [userIdFilter, setUserIdFilter] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
|
||||||
|
// Debounce timer for user ID search
|
||||||
|
const [debouncedUserId, setDebouncedUserId] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedUserId(userIdFilter.trim());
|
||||||
|
setPage(0);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [userIdFilter]);
|
||||||
|
|
||||||
|
// Reset page when type filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [typeFilter]);
|
||||||
|
|
||||||
|
const fetchTransactions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (debouncedUserId) params.set("userId", debouncedUserId);
|
||||||
|
if (typeFilter) params.set("type", typeFilter);
|
||||||
|
params.set("limit", String(limit + 1)); // fetch one extra to detect "has more"
|
||||||
|
params.set("offset", String(page * limit));
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await get<TransactionsResponse>(`/api/transactions?${qs}`);
|
||||||
|
const rows = data.transactions ?? [];
|
||||||
|
|
||||||
|
if (rows.length > limit) {
|
||||||
|
setHasMore(true);
|
||||||
|
setTransactions(rows.slice(0, limit));
|
||||||
|
} else {
|
||||||
|
setHasMore(false);
|
||||||
|
setTransactions(rows);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.error ?? "Failed to fetch transactions");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [debouncedUserId, typeFilter, page, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTransactions();
|
||||||
|
}, [fetchTransactions]);
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setUserIdFilter("");
|
||||||
|
setTypeFilter(null);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = userIdFilter.trim() !== "" || typeFilter !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Transactions</h1>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* User ID search */}
|
||||||
|
<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={userIdFilter}
|
||||||
|
onChange={(e) => setUserIdFilter(e.target.value)}
|
||||||
|
placeholder="Filter by User ID..."
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-input border-b-2 border-outline-variant rounded-none pl-10 pr-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary",
|
||||||
|
"transition-colors placeholder:text-text-disabled"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type filter */}
|
||||||
|
<select
|
||||||
|
value={typeFilter ?? ""}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value || null)}
|
||||||
|
className={cn(
|
||||||
|
"bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary transition-colors"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{TRANSACTION_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{getTypeConfig(t).label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Clear filters */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-6 mt-4 bg-destructive/10 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Error</p>
|
||||||
|
<p className="text-sm text-destructive/90">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-32">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : transactions.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32 text-center">
|
||||||
|
<Receipt className="w-10 h-10 text-text-tertiary mb-4" />
|
||||||
|
<p className="font-display text-lg font-semibold mb-1">No transactions found</p>
|
||||||
|
<p className="text-sm text-text-tertiary max-w-md">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters to find what you're looking for."
|
||||||
|
: "Transaction history will appear here once economy activity begins."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
|
{/* Table header */}
|
||||||
|
<div className="grid grid-cols-[1fr_120px_1fr_140px_1.2fr] gap-4 px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider border-b border-outline-variant/30">
|
||||||
|
<span>User</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span className="text-right">Amount</span>
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Description</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{transactions.map((tx) => {
|
||||||
|
const config = getTypeConfig(tx.type);
|
||||||
|
const positive = isPositive(tx.type);
|
||||||
|
const amt = formatAmount(tx.amount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tx.id}
|
||||||
|
className="grid grid-cols-[1fr_120px_1fr_140px_1.2fr] gap-4 px-4 py-3 items-center hover:bg-raised/30 transition-colors rounded-lg"
|
||||||
|
>
|
||||||
|
{/* User */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm font-mono text-foreground truncate">
|
||||||
|
{tx.userId}
|
||||||
|
</span>
|
||||||
|
{tx.relatedUserId && (
|
||||||
|
<span className="text-xs text-text-tertiary truncate flex items-center gap-1">
|
||||||
|
{positive ? (
|
||||||
|
<ArrowDownLeft className="w-3 h-3 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight className="w-3 h-3 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
{tx.relatedUserId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type badge */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center px-2 py-0.5 rounded text-xs font-medium w-fit",
|
||||||
|
config.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-mono text-right font-medium",
|
||||||
|
positive ? "text-green-400" : "text-red-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{positive ? "+" : "-"}{amt}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<span className="text-xs text-text-tertiary flex items-center gap-1.5">
|
||||||
|
<Calendar className="w-3 h-3 flex-shrink-0" />
|
||||||
|
{formatDate(tx.createdAt)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<span className="text-sm text-text-secondary truncate">
|
||||||
|
{tx.description ?? "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!loading && transactions.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-outline-variant/20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-text-tertiary">Rows per page</span>
|
||||||
|
<select
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLimit(Number(e.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"bg-input border-b-2 border-outline-variant rounded-none px-2 py-1 text-xs text-foreground",
|
||||||
|
"focus:outline-none focus:border-primary transition-colors"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{PAGE_SIZES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-text-tertiary">
|
||||||
|
Page {page + 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors",
|
||||||
|
page === 0
|
||||||
|
? "text-text-disabled cursor-not-allowed"
|
||||||
|
: "text-text-tertiary hover:text-foreground hover:bg-raised/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
disabled={!hasMore}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors",
|
||||||
|
!hasMore
|
||||||
|
? "text-text-disabled cursor-not-allowed"
|
||||||
|
: "text-text-tertiary hover:text-foreground hover:bg-raised/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user