feat(panel): replace all placeholder pages with real admin views
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:
syntaxbullet
2026-04-05 16:19:23 +02:00
parent 5c40249a18
commit e3c49effdb
7 changed files with 3149 additions and 30 deletions

View File

@@ -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
View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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>
);
}

View 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>
);
}