feat: Implement an admin quest management table, enhance toast notifications with descriptions, and add new agent documentation.

This commit is contained in:
syntaxbullet
2026-01-16 15:58:48 +01:00
parent 4ecbffd617
commit 58f261562a
12 changed files with 589 additions and 44 deletions

View File

@@ -22,6 +22,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",
"react-hook-form": "^7.70.0",
@@ -239,6 +240,8 @@
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],

View File

@@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",
"react-hook-form": "^7.70.0",

View File

@@ -51,7 +51,9 @@ export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) {
}
setEnabledState(state);
}).catch(err => {
toast.error("Failed to load commands");
toast.error("Failed to load commands", {
description: "Unable to fetch command list. Please try again."
});
console.error(err);
}).finally(() => {
setLoading(false);
@@ -94,11 +96,14 @@ export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) {
setEnabledState(prev => ({ ...prev, [commandName]: enabled }));
toast.success(`/${commandName} ${enabled ? "enabled" : "disabled"}`, {
description: `Command has been ${enabled ? "enabled" : "disabled"} successfully.`,
duration: 2000,
id: "command-toggle", // Replace previous toast instead of stacking
id: "command-toggle",
});
} catch (error) {
toast.error("Failed to toggle command");
toast.error("Failed to toggle command", {
description: "Unable to update command status. Please try again."
});
console.error(error);
} finally {
setSaving(null);

View File

@@ -64,12 +64,12 @@ function NavItemWithSubMenu({ item }: { item: NavItem }) {
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{item.title}
</div>
{item.subItems?.map((subItem) => (
{item.subItems?.map((subItem) => (
<DropdownMenuItem key={subItem.title} asChild>
<Link
to={subItem.url}
className={cn(
"cursor-pointer",
"cursor-pointer py-4 min-h-10",
subItem.isActive && "text-primary bg-primary/10"
)}
>

View File

@@ -10,6 +10,16 @@ import { Textarea } from "./ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { toast } from "sonner";
import { ScrollArea } from "./ui/scroll-area";
import { Star, Coins } from "lucide-react";
interface QuestListItem {
id: number;
name: string;
description: string | null;
triggerEvent: string;
requirements: { target?: number };
rewards: { xp?: number; balance?: number };
}
const questSchema = z.object({
name: z.string().min(3, "Name must be at least 3 characters"),
@@ -22,6 +32,12 @@ const questSchema = z.object({
type QuestFormValues = z.infer<typeof questSchema>;
interface QuestFormProps {
initialData?: QuestListItem;
onUpdate?: () => void;
onCancel?: () => void;
}
const TRIGGER_EVENTS = [
{ label: "XP Gain", value: "XP_GAIN" },
{ label: "Item Collect", value: "ITEM_COLLECT" },
@@ -39,25 +55,42 @@ const TRIGGER_EVENTS = [
{ label: "Trivia Win", value: "TRIVIA_WIN" },
];
export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
export function QuestForm({ initialData, onUpdate, onCancel }: QuestFormProps) {
const isEditMode = initialData !== undefined;
const [isSubmitting, setIsSubmitting] = React.useState(false);
const form = useForm<QuestFormValues>({
resolver: zodResolver(questSchema),
defaultValues: {
name: "",
description: "",
triggerEvent: "XP_GAIN",
target: 1,
xpReward: 100,
balanceReward: 500,
name: initialData?.name || "",
description: initialData?.description || "",
triggerEvent: initialData?.triggerEvent || "XP_GAIN",
target: (initialData?.requirements as { target?: number })?.target || 1,
xpReward: (initialData?.rewards as { xp?: number })?.xp || 100,
balanceReward: (initialData?.rewards as { balance?: number })?.balance || 500,
},
});
React.useEffect(() => {
if (initialData) {
form.reset({
name: initialData.name || "",
description: initialData.description || "",
triggerEvent: initialData.triggerEvent || "XP_GAIN",
target: (initialData.requirements as { target?: number })?.target || 1,
xpReward: (initialData.rewards as { xp?: number })?.xp || 100,
balanceReward: (initialData.rewards as { balance?: number })?.balance || 500,
});
}
}, [initialData, form]);
const onSubmit = async (data: QuestFormValues) => {
setIsSubmitting(true);
try {
const response = await fetch("/api/quests", {
method: "POST",
const url = isEditMode ? `/api/quests/${initialData.id}` : "/api/quests";
const method = isEditMode ? "PUT" : "POST";
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
},
@@ -66,17 +99,24 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to create quest");
throw new Error(errorData.error || (isEditMode ? "Failed to update quest" : "Failed to create quest"));
}
toast.success("Quest created successfully!", {
description: `${data.name} has been added to the database.`,
toast.success(isEditMode ? "Quest updated successfully!" : "Quest created successfully!", {
description: `${data.name} has been ${isEditMode ? "updated" : "added to the database"}.`,
});
form.reset();
onSuccess?.();
form.reset({
name: "",
description: "",
triggerEvent: "XP_GAIN",
target: 1,
xpReward: 100,
balanceReward: 500,
});
onUpdate?.();
} catch (error) {
console.error("Submission error:", error);
toast.error("Failed to create quest", {
toast.error(isEditMode ? "Failed to update quest" : "Failed to create quest", {
description: error instanceof Error ? error.message : "An unknown error occurred",
});
} finally {
@@ -85,12 +125,12 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
};
return (
<Card className="glass-card max-w-2xl mx-auto overflow-hidden">
<Card className="glass-card overflow-hidden">
<div className="h-1.5 bg-primary w-full" />
<CardHeader>
<CardTitle className="text-2xl font-bold text-primary">Create New Quest</CardTitle>
<CardTitle className="text-2xl font-bold text-primary">{isEditMode ? "Edit Quest" : "Create New Quest"}</CardTitle>
<CardDescription>
Configure a new quest for the Aurora RPG academy.
{isEditMode ? "Update the quest configuration." : "Configure a new quest for the Aurora RPG academy."}
</CardDescription>
</CardHeader>
<CardContent>
@@ -182,7 +222,10 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
name="xpReward"
render={({ field }) => (
<FormItem>
<FormLabel>XP Reward</FormLabel>
<FormLabel className="flex items-center gap-2">
<Star className="w-4 h-4 text-amber-400" />
XP Reward
</FormLabel>
<FormControl>
<Input
type="number"
@@ -201,7 +244,10 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
name="balanceReward"
render={({ field }) => (
<FormItem>
<FormLabel>AU Reward</FormLabel>
<FormLabel className="flex items-center gap-2">
<Coins className="w-4 h-4 text-amber-500" />
AU Reward
</FormLabel>
<FormControl>
<Input
type="number"
@@ -216,13 +262,33 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
/>
</div>
<Button
type="submit"
disabled={isSubmitting}
className="w-full bg-primary text-primary-foreground hover:glow-primary active-press py-6 text-lg font-bold"
>
{isSubmitting ? "Creating..." : "Create Quest"}
</Button>
{isEditMode ? (
<div className="flex gap-4">
<Button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-primary text-primary-foreground hover:glow-primary active-press py-6 text-lg font-bold"
>
{isSubmitting ? "Updating..." : "Update Quest"}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 py-6 text-lg font-bold"
>
Cancel
</Button>
</div>
) : (
<Button
type="submit"
disabled={isSubmitting}
className="w-full bg-primary text-primary-foreground hover:glow-primary active-press py-6 text-lg font-bold"
>
{isSubmitting ? "Creating..." : "Create Quest"}
</Button>
)}
</form>
</Form>
</CardContent>

View File

@@ -1,10 +1,11 @@
import React from "react";
import { toast } from "sonner";
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";
import { FileText, RefreshCw } from "lucide-react";
import { FileText, RefreshCw, Trash2, Pencil, Star, Coins } from "lucide-react";
interface QuestListItem {
id: number;
@@ -20,6 +21,8 @@ interface QuestTableProps {
isInitialLoading: boolean;
isRefreshing: boolean;
onRefresh?: () => void;
onDelete?: (id: number) => void;
onEdit?: (id: number) => void;
}
const TRIGGER_EVENT_LABELS: Record<string, string> = {
@@ -67,7 +70,7 @@ function TruncatedText({ text, maxLength = 100 }: { text: string; maxLength?: nu
function QuestTableSkeleton() {
return (
<div className="space-y-3 animate-pulse">
<div className="grid grid-cols-7 gap-4 px-4 py-2 text-sm font-medium text-muted-foreground">
<div className="grid grid-cols-8 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" />
@@ -77,7 +80,7 @@ function QuestTableSkeleton() {
<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">
<div key={i} className="grid grid-cols-8 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" />
@@ -85,6 +88,7 @@ function QuestTableSkeleton() {
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
@@ -105,7 +109,7 @@ function EmptyQuestState() {
);
}
function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
function QuestTableContent({ quests, onDelete, onEdit }: { quests: QuestListItem[]; onDelete?: (id: number) => void; onEdit?: (id: number) => void }) {
if (quests.length === 0) {
return <EmptyQuestState />;
}
@@ -136,6 +140,9 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
AU Reward
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-24">
Actions
</th>
</tr>
</thead>
<tbody>
@@ -170,7 +177,7 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
<td className="py-3 px-4 text-sm text-foreground">
{rewards?.xp ? (
<span className="flex items-center gap-1">
<span></span>
<Star className="w-4 h-4 text-amber-400" />
<span className="font-mono">{rewards.xp}</span>
</span>
) : (
@@ -180,13 +187,51 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
<td className="py-3 px-4 text-sm text-foreground">
{rewards?.balance ? (
<span className="flex items-center gap-1">
<span>🪙</span>
<Coins className="w-4 h-4 text-amber-500" />
<span className="font-mono">{rewards.balance}</span>
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-1">
<button
onClick={() => onEdit?.(quest.id)}
className="p-1.5 rounded-md hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
title="Edit quest"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => {
toast("Delete this quest?", {
description: "This action cannot be undone.",
action: {
label: "Delete",
onClick: () => onDelete?.(quest.id)
},
cancel: {
label: "Cancel",
onClick: () => {}
},
style: {
background: "var(--destructive)",
color: "var(--destructive-foreground)"
},
actionButtonStyle: {
background: "var(--destructive)",
color: "var(--destructive-foreground)"
}
});
}}
className="p-1.5 rounded-md hover:bg-muted/50 transition-colors text-muted-foreground hover:text-destructive"
title="Delete quest"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
@@ -196,7 +241,7 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
);
}
export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh }: QuestTableProps) {
export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh, onDelete, onEdit }: QuestTableProps) {
const showSkeleton = isInitialLoading && quests.length === 0;
return (
@@ -235,7 +280,7 @@ export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh }
{showSkeleton ? (
<QuestTableSkeleton />
) : (
<QuestTableContent quests={quests} />
<QuestTableContent quests={quests} onDelete={onDelete} onEdit={onEdit} />
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -137,7 +137,9 @@ export function useSettings() {
form.reset(config as any);
setMeta(metaData);
} catch (err) {
toast.error("Failed to load settings");
toast.error("Failed to load settings", {
description: "Unable to fetch bot configuration. Please try again."
});
console.error(err);
} finally {
setLoading(false);
@@ -165,7 +167,9 @@ export function useSettings() {
// Reload settings to ensure we have the latest state
await loadSettings();
} catch (error) {
toast.error("Failed to save settings");
toast.error("Failed to save settings", {
description: error instanceof Error ? error.message : "Unable to save changes. Please try again."
});
console.error(error);
} finally {
setIsSaving(false);

View File

@@ -18,6 +18,9 @@ export function AdminQuests() {
const [isInitialLoading, setIsInitialLoading] = React.useState(true);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [lastCreatedQuestId, setLastCreatedQuestId] = React.useState<number | null>(null);
const [editingQuest, setEditingQuest] = React.useState<QuestListItem | null>(null);
const [isFormModeEdit, setIsFormModeEdit] = React.useState(false);
const formRef = React.useRef<HTMLDivElement>(null);
const fetchQuests = React.useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -70,6 +73,52 @@ export function AdminQuests() {
});
};
const handleDeleteQuest = async (id: number) => {
try {
const response = await fetch(`/api/quests/${id}`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to delete quest");
}
setQuests((prev) => prev.filter((q) => q.id !== id));
toast.success("Quest deleted", {
description: `Quest #${id} has been successfully deleted.`,
});
} catch (error) {
console.error("Error deleting quest:", error);
toast.error("Failed to delete quest", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const handleEditQuest = (id: number) => {
const quest = quests.find(q => q.id === id);
if (quest) {
setEditingQuest(quest);
setIsFormModeEdit(true);
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const handleQuestUpdated = () => {
fetchQuests(true);
setEditingQuest(null);
setIsFormModeEdit(false);
toast.success("Quest list updated", {
description: "The quest inventory has been refreshed.",
});
};
const handleFormCancel = () => {
setEditingQuest(null);
setIsFormModeEdit(false);
};
return (
<main className="pt-8 px-8 pb-12 max-w-7xl mx-auto space-y-12">
<SectionHeader
@@ -84,11 +133,17 @@ export function AdminQuests() {
isInitialLoading={isInitialLoading}
isRefreshing={isRefreshing}
onRefresh={() => fetchQuests(true)}
onDelete={handleDeleteQuest}
onEdit={handleEditQuest}
/>
</div>
<div className="animate-in fade-in slide-up duration-700">
<QuestForm onSuccess={handleQuestCreated} />
<div className="animate-in fade-in slide-up duration-700" ref={formRef}>
<QuestForm
initialData={editingQuest || undefined}
onUpdate={handleQuestUpdated}
onCancel={handleFormCancel}
/>
</div>
</main>
);

View File

@@ -222,6 +222,67 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
}
}
if (url.pathname.startsWith("/api/quests/") && req.method === "DELETE") {
const id = parseInt(url.pathname.split("/").pop() || "0", 10);
if (!id) {
return Response.json({ error: "Invalid quest ID" }, { status: 400 });
}
try {
const { questService } = await import("@shared/modules/quest/quest.service");
const result = await questService.deleteQuest(id);
if (result.length === 0) {
return Response.json({ error: "Quest not found" }, { status: 404 });
}
return Response.json({ success: true, deleted: result[0].id });
} catch (error) {
logger.error("web", "Error deleting quest", error);
return Response.json(
{ error: "Failed to delete quest", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
if (url.pathname.startsWith("/api/quests/") && req.method === "PUT") {
const id = parseInt(url.pathname.split("/").pop() || "0", 10);
if (!id) {
return Response.json({ error: "Invalid quest ID" }, { status: 400 });
}
try {
const { questService } = await import("@shared/modules/quest/quest.service");
const data = await req.json();
const result = await questService.updateQuest(id, {
name: data.name,
description: data.description,
triggerEvent: data.triggerEvent,
requirements: { target: Number(data.target) || 1 },
rewards: {
xp: Number(data.xpReward) || 0,
balance: Number(data.balanceReward) || 0
}
});
if (result.length === 0) {
return Response.json({ error: "Quest not found" }, { status: 404 });
}
return Response.json({ success: true, quest: result[0] });
} catch (error) {
logger.error("web", "Error updating quest", error);
return Response.json(
{ error: "Failed to update quest", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// Settings Management
if (url.pathname === "/api/settings") {
try {