diff --git a/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index 72ec622..bcc8048 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -162,5 +162,11 @@ export const questService = { }) .returning(); }, tx); + }, + + async getAllQuests() { + return await DrizzleClient.query.quests.findMany({ + orderBy: (quests, { asc }) => [asc(quests.id)], + }); } }; diff --git a/web/src/components/quest-form.tsx b/web/src/components/quest-form.tsx index 3cfbed1..9799a59 100644 --- a/web/src/components/quest-form.tsx +++ b/web/src/components/quest-form.tsx @@ -39,7 +39,7 @@ const TRIGGER_EVENTS = [ { label: "Trivia Win", value: "TRIVIA_WIN" }, ]; -export function QuestForm() { +export function QuestForm({ onSuccess }: { onSuccess?: () => void }) { const [isSubmitting, setIsSubmitting] = React.useState(false); const form = useForm({ resolver: zodResolver(questSchema), @@ -73,6 +73,7 @@ export function QuestForm() { description: `${data.name} has been added to the database.`, }); form.reset(); + onSuccess?.(); } catch (error) { console.error("Submission error:", error); toast.error("Failed to create quest", { diff --git a/web/src/components/quest-table.tsx b/web/src/components/quest-table.tsx new file mode 100644 index 0000000..42dced3 --- /dev/null +++ b/web/src/components/quest-table.tsx @@ -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 = { + 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 {text || "-"}; + } + + return ( + + + + + {text.slice(0, maxLength)}... + + + +

{text}

+
+
+
+ ); +} + +function QuestTableSkeleton() { + return ( +
+
+ + + + + + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + + + + + +
+ ))} +
+ ); +} + +function EmptyQuestState() { + return ( +
+
+ + + + + + + +
+

No quests available

+

+ There are no quests in the database yet. Create your first quest using the form below. +

+
+ ); +} + +export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) { + if (isLoading) { + return ( + + +
+ Quest Inventory + + Loading... + +
+
+ + + +
+ ); + } + + return ( + + +
+ Quest Inventory +
+ + {quests.length} quest{quests.length !== 1 ? "s" : ""} + + +
+
+
+ + {quests.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {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 ( + + + + + + + + + + ); + })} + +
+ ID + + Name + + Description + + Trigger Event + + Target + + XP Reward + + AU Reward +
+ #{quest.id} + + {quest.name} + + + + + {getTriggerEventLabel(quest.triggerEvent)} + + + {target} + + {rewards?.xp ? ( + + + {rewards.xp} + + ) : ( + - + )} + + {rewards?.balance ? ( + + 🪙 + {rewards.balance} + + ) : ( + - + )} +
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/AdminQuests.tsx b/web/src/pages/AdminQuests.tsx index a5330c2..ca93bbe 100644 --- a/web/src/pages/AdminQuests.tsx +++ b/web/src/pages/AdminQuests.tsx @@ -1,8 +1,70 @@ import React from "react"; import { QuestForm } from "../components/quest-form"; +import { QuestTable } from "../components/quest-table"; 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() { + const [quests, setQuests] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [refreshKey, setRefreshKey] = React.useState(0); + const [lastCreatedQuestId, setLastCreatedQuestId] = React.useState(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 (
+
+ +
+
- +
); diff --git a/web/src/server.ts b/web/src/server.ts index 70741a6..9da3982 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -197,6 +197,31 @@ export async function createWebServer(config: WebServerConfig = {}): Promise ({ + 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 if (url.pathname === "/api/settings") { try {