forked from syntaxbullet/aurorabot
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:
@@ -162,5 +162,11 @@ export const questService = {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
}, tx);
|
}, tx);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllQuests() {
|
||||||
|
return await DrizzleClient.query.quests.findMany({
|
||||||
|
orderBy: (quests, { asc }) => [asc(quests.id)],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const TRIGGER_EVENTS = [
|
|||||||
{ label: "Trivia Win", value: "TRIVIA_WIN" },
|
{ label: "Trivia Win", value: "TRIVIA_WIN" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function QuestForm() {
|
export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
const form = useForm<QuestFormValues>({
|
const form = useForm<QuestFormValues>({
|
||||||
resolver: zodResolver(questSchema),
|
resolver: zodResolver(questSchema),
|
||||||
@@ -73,6 +73,7 @@ export function QuestForm() {
|
|||||||
description: `${data.name} has been added to the database.`,
|
description: `${data.name} has been added to the database.`,
|
||||||
});
|
});
|
||||||
form.reset();
|
form.reset();
|
||||||
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Submission error:", error);
|
console.error("Submission error:", error);
|
||||||
toast.error("Failed to create quest", {
|
toast.error("Failed to create quest", {
|
||||||
|
|||||||
273
web/src/components/quest-table.tsx
Normal file
273
web/src/components/quest-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,70 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { QuestForm } from "../components/quest-form";
|
import { QuestForm } from "../components/quest-form";
|
||||||
|
import { QuestTable } from "../components/quest-table";
|
||||||
import { SectionHeader } from "../components/section-header";
|
import { SectionHeader } from "../components/section-header";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface QuestListItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
triggerEvent: string;
|
||||||
|
requirements: { target?: number };
|
||||||
|
rewards: { xp?: number; balance?: number };
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminQuests() {
|
export function AdminQuests() {
|
||||||
|
const [quests, setQuests] = React.useState<QuestListItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||||
|
const [lastCreatedQuestId, setLastCreatedQuestId] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
const fetchQuests = React.useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/quests");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch quests");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
setQuests(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching quests:", error);
|
||||||
|
toast.error("Failed to load quests", {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchQuests();
|
||||||
|
}, [fetchQuests]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (lastCreatedQuestId !== null) {
|
||||||
|
const element = document.getElementById(`quest-row-${lastCreatedQuestId}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
element.classList.add("bg-primary/10");
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.remove("bg-primary/10");
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
setLastCreatedQuestId(null);
|
||||||
|
}
|
||||||
|
}, [lastCreatedQuestId, quests]);
|
||||||
|
|
||||||
|
const handleQuestCreated = () => {
|
||||||
|
fetchQuests();
|
||||||
|
toast.success("Quest list updated", {
|
||||||
|
description: "The quest inventory has been refreshed.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="pt-8 px-8 pb-12 max-w-7xl mx-auto space-y-12">
|
<main className="pt-8 px-8 pb-12 max-w-7xl mx-auto space-y-12">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -11,8 +73,16 @@ export function AdminQuests() {
|
|||||||
description="Create and manage quests for the Aurora RPG students."
|
description="Create and manage quests for the Aurora RPG students."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<QuestTable
|
||||||
|
quests={quests}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onRefresh={fetchQuests}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="animate-in fade-in slide-up duration-700">
|
<div className="animate-in fade-in slide-up duration-700">
|
||||||
<QuestForm />
|
<QuestForm onSuccess={handleQuestCreated} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -197,6 +197,31 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/quests" && req.method === "GET") {
|
||||||
|
try {
|
||||||
|
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||||
|
const quests = await questService.getAllQuests();
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: quests.map(q => ({
|
||||||
|
id: q.id,
|
||||||
|
name: q.name,
|
||||||
|
description: q.description,
|
||||||
|
triggerEvent: q.triggerEvent,
|
||||||
|
requirements: q.requirements,
|
||||||
|
rewards: q.rewards,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("web", "Error fetching quests", error);
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Failed to fetch quests", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings Management
|
// Settings Management
|
||||||
if (url.pathname === "/api/settings") {
|
if (url.pathname === "/api/settings") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user