feat: (web) add quest table component for admin quests page

- Add getAllQuests() method to quest.service.ts
- Add GET /api/quests endpoint to server.ts
- Create QuestTable component with data display, formatting, and states
- Update AdminQuests.tsx to fetch and display quests above the form
- Add onSuccess callback to QuestForm for refresh handling
This commit is contained in:
syntaxbullet
2026-01-16 15:12:41 +01:00
parent d243a11bd3
commit 3ef9773990
5 changed files with 377 additions and 2 deletions

View File

@@ -0,0 +1,273 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Badge } from "./ui/badge";
import { Skeleton } from "./ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
interface QuestListItem {
id: number;
name: string;
description: string | null;
triggerEvent: string;
requirements: { target?: number };
rewards: { xp?: number; balance?: number };
}
interface QuestTableProps {
quests: QuestListItem[];
isLoading: boolean;
onRefresh?: () => void;
}
const TRIGGER_EVENT_LABELS: Record<string, string> = {
XP_GAIN: "XP Gain",
ITEM_COLLECT: "Item Collect",
ITEM_USE: "Item Use",
DAILY_REWARD: "Daily Reward",
LOOTBOX: "Lootbox Currency Reward",
EXAM_REWARD: "Exam Reward",
PURCHASE: "Purchase",
TRANSFER_IN: "Transfer In",
TRANSFER_OUT: "Transfer Out",
TRADE_IN: "Trade In",
TRADE_OUT: "Trade Out",
QUEST_REWARD: "Quest Reward",
TRIVIA_ENTRY: "Trivia Entry",
TRIVIA_WIN: "Trivia Win",
};
function formatQuestRewards(rewards: { xp?: number; balance?: number }): string {
const parts: string[] = [];
if (rewards?.xp) parts.push(`${rewards.xp}`);
if (rewards?.balance) parts.push(`${rewards.balance} 🪙`);
return parts.join(" • ") || "None";
}
function getTriggerEventLabel(triggerEvent: string): string {
return TRIGGER_EVENT_LABELS[triggerEvent] || triggerEvent;
}
function TruncatedText({ text, maxLength = 100 }: { text: string; maxLength?: number }) {
if (!text || text.length <= maxLength) {
return <span>{text || "-"}</span>;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help border-b border-dashed border-muted-foreground/50">
{text.slice(0, maxLength)}...
</span>
</TooltipTrigger>
<TooltipContent className="max-w-md">
<p>{text}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function QuestTableSkeleton() {
return (
<div className="space-y-3">
<div className="grid grid-cols-7 gap-4 px-4 py-2 text-sm font-medium text-muted-foreground">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="grid grid-cols-7 gap-4 px-4 py-3 border-t border-border/50">
<Skeleton className="h-5 w-8" />
<Skeleton className="h-5 w-32" />
<Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-24" />
</div>
))}
</div>
);
}
function EmptyQuestState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted/50 p-4 mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<h3 className="text-lg font-semibold text-foreground">No quests available</h3>
<p className="text-muted-foreground mt-1 max-w-sm">
There are no quests in the database yet. Create your first quest using the form below.
</p>
</div>
);
}
export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) {
if (isLoading) {
return (
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
<Badge variant="secondary" className="animate-pulse">
Loading...
</Badge>
</div>
</CardHeader>
<CardContent>
<QuestTableSkeleton />
</CardContent>
</Card>
);
}
return (
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
<div className="flex items-center gap-3">
<Badge variant="outline" className="border-border/50">
{quests.length} quest{quests.length !== 1 ? "s" : ""}
</Badge>
<button
onClick={onRefresh}
className="p-2 rounded-md hover:bg-muted/50 transition-colors"
title="Refresh quests"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("text-muted-foreground", isLoading && "animate-spin")}
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</button>
</div>
</div>
</CardHeader>
<CardContent>
{quests.length === 0 ? (
<EmptyQuestState />
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-16">
ID
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-40">
Name
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-64">
Description
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-36">
Trigger Event
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-20">
Target
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
XP Reward
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
AU Reward
</th>
</tr>
</thead>
<tbody>
{quests.map((quest) => {
const requirements = quest.requirements as { target?: number };
const rewards = quest.rewards as { xp?: number; balance?: number };
const target = requirements?.target || 1;
return (
<tr
key={quest.id}
className="border-b border-border/30 hover:bg-muted/20 transition-colors"
>
<td className="py-3 px-4 text-sm text-muted-foreground font-mono">
#{quest.id}
</td>
<td className="py-3 px-4 text-sm font-medium text-foreground">
{quest.name}
</td>
<td className="py-3 px-4 text-sm text-muted-foreground">
<TruncatedText text={quest.description || ""} maxLength={50} />
</td>
<td className="py-3 px-4">
<Badge variant="outline" className="text-xs border-border/50">
{getTriggerEventLabel(quest.triggerEvent)}
</Badge>
</td>
<td className="py-3 px-4 text-sm text-foreground font-mono">
{target}
</td>
<td className="py-3 px-4 text-sm text-foreground">
{rewards?.xp ? (
<span className="flex items-center gap-1">
<span></span>
<span className="font-mono">{rewards.xp}</span>
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="py-3 px-4 text-sm text-foreground">
{rewards?.balance ? (
<span className="flex items-center gap-1">
<span>🪙</span>
<span className="font-mono">{rewards.balance}</span>
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}