feat: Add web admin page for quest management and refactor Discord bot's quest UI to use new components.

This commit is contained in:
syntaxbullet
2026-01-15 17:21:49 +01:00
parent 9e5c6b5ac3
commit 2f73f38877
12 changed files with 552 additions and 94 deletions

View File

@@ -1,8 +1,12 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@shared/modules/quest/quest.service";
import { createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
import { getQuestListEmbed, getAvailableQuestsEmbed, getQuestActionRows } from "@/modules/quest/quest.view";
import { createSuccessEmbed } from "@lib/embeds";
import {
getQuestListComponents,
getAvailableQuestsComponents,
getQuestActionRows
} from "@/modules/quest/quest.view";
export const quests = createCommand({
data: new SlashCommandBuilder()
@@ -17,15 +21,18 @@ export const quests = createCommand({
const userQuests = await questService.getUserQuests(userId);
const availableQuests = await questService.getAvailableQuests(userId);
const embed = viewType === 'active'
? getQuestListEmbed(userQuests)
: getAvailableQuestsEmbed(availableQuests);
const containers = viewType === 'active'
? getQuestListComponents(userQuests)
: getAvailableQuestsComponents(availableQuests);
const components = getQuestActionRows(viewType, availableQuests);
const actionRows = getQuestActionRows(viewType);
await interaction.editReply({
embeds: [embed],
components: components
content: null,
embeds: null as any,
components: [...containers, ...actionRows] as any,
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] }
});
};
@@ -33,8 +40,8 @@ export const quests = createCommand({
await updateView('active');
const collector = response.createMessageComponentCollector({
time: 60000,
componentType: undefined // Allow both buttons and select menu
time: 120000, // 2 minutes
componentType: undefined // Allow buttons
});
collector.on('collect', async (i) => {
@@ -47,8 +54,10 @@ export const quests = createCommand({
} else if (i.customId === "quest_view_available") {
await i.deferUpdate();
await updateView('available');
} else if (i.customId === "quest_accept_select") {
const questId = parseInt((i as any).values[0]);
} else if (i.customId.startsWith("quest_accept:")) {
const questIdStr = i.customId.split(":")[1];
if (!questIdStr) return;
const questId = parseInt(questIdStr);
await questService.assignQuest(userId, questId);
await i.reply({

View File

@@ -1,4 +1,4 @@
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
import { join } from "node:path";
import type { Command } from "@shared/lib/types";
import { env } from "@shared/lib/env";
@@ -74,6 +74,27 @@ export class Client extends DiscordClient {
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
this.maintenanceMode = enabled;
});
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
const { userId, quest, rewards } = data;
try {
const user = await this.users.fetch(userId);
if (!user) return;
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
const components = getQuestCompletionComponents(quest, rewards);
// Try to send to the user's DM
await user.send({
components: components as any,
flags: [MessageFlags.IsComponentsV2]
}).catch(async () => {
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
});
} catch (error) {
console.error("Failed to send quest completion notification:", error);
}
});
}
async loadCommands(reload: boolean = false) {
@@ -176,4 +197,4 @@ export class Client extends DiscordClient {
}
}
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });

View File

@@ -1,4 +1,13 @@
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
TextDisplayBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
MessageFlags
} from "discord.js";
/**
* Quest entry with quest details and progress
@@ -27,6 +36,13 @@ interface AvailableQuest {
requirements: any;
}
// Color palette for containers
const COLORS = {
ACTIVE: 0x3498db, // Blue - in progress
AVAILABLE: 0x2ecc71, // Green - available
COMPLETED: 0xf1c40f // Gold - completed
};
/**
* Formats quest rewards object into a human-readable string
*/
@@ -34,14 +50,7 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
const rewardStr: string[] = [];
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
return rewardStr.join(", ");
}
/**
* Returns the quest status display string
*/
function getQuestStatus(completedAt: Date | null): string {
return completedAt ? "✅ Completed" : "📝 In Progress";
return rewardStr.join(" ") || "None";
}
/**
@@ -55,108 +64,155 @@ function renderProgressBar(current: number, total: number, size: number = 10): s
const progressText = "▰".repeat(progress);
const emptyText = "▱".repeat(empty);
return `${progressText}${emptyText} ${Math.round(percentage * 100)}% (${current}/${total})`;
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
}
/**
* Creates an embed displaying a user's quest log
* Creates Components v2 containers for the quest list (active quests only)
*/
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle("📜 Quest Log")
.setDescription("Your active and completed quests.")
.setColor(0x3498db); // Blue
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
// Filter to only show in-progress quests (not completed)
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
if (userQuests.length === 0) {
embed.setDescription("You have no active quests. Check available quests!");
const container = new ContainerBuilder()
.setAccentColor(COLORS.ACTIVE)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
new TextDisplayBuilder().setContent("-# Your active quests")
);
if (activeQuests.length === 0) {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
);
return [container];
}
userQuests.forEach(entry => {
const status = getQuestStatus(entry.completedAt);
activeQuests.forEach((entry) => {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
const rewardsText = formatQuestRewards(rewards);
const requirements = entry.quest.requirements as { target?: number };
const target = requirements?.target || 1;
const progress = entry.progress || 0;
const progressBar = renderProgressBar(progress, target);
const progressBar = entry.completedAt ? "✅ Fully completed" : renderProgressBar(progress, target);
embed.addFields({
name: `${entry.quest.name} (${status})`,
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${progressBar}`,
inline: false
});
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
);
});
return embed;
return [container];
}
/**
* Creates an embed for available quests
* Creates Components v2 containers for available quests with inline accept buttons
*/
export function getAvailableQuestsEmbed(availableQuests: AvailableQuest[]): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle("🗺️ Available Quests")
.setDescription("Quests you can accept right now.")
.setColor(0x2ecc71); // Green
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
const container = new ContainerBuilder()
.setAccentColor(COLORS.AVAILABLE)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
new TextDisplayBuilder().setContent("-# Quests you can accept")
);
if (availableQuests.length === 0) {
embed.setDescription("There are no new quests available for you at the moment.");
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
);
return [container];
}
availableQuests.forEach(quest => {
// Limit to 10 quests (5 action rows max with 2 added for navigation)
const questsToShow = availableQuests.slice(0, 10);
questsToShow.forEach((quest) => {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
const rewards = quest.rewards as { xp?: number, balance?: number };
const rewardsText = formatQuestRewards(rewards);
const requirements = quest.requirements as { target?: number };
const target = requirements?.target || 1;
embed.addFields({
name: quest.name,
value: `${quest.description}\n**Goal:** Reach ${target} for this activity.\n**Rewards:** ${rewardsText}`,
inline: false
});
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**${quest.name}**`),
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
);
// Add accept button inline within the container
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`quest_accept:${quest.id}`)
.setLabel("Accept Quest")
.setStyle(ButtonStyle.Success)
.setEmoji("✅")
)
);
});
return embed;
return [container];
}
/**
* Returns action rows for the quest view
* Returns action rows for navigation only
*/
export function getQuestActionRows(viewType: 'active' | 'available', availableQuests: AvailableQuest[] = []): ActionRowBuilder<any>[] {
const rows: ActionRowBuilder<any>[] = [];
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
// Navigation row
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("quest_view_active")
.setLabel("Active Quests")
.setLabel("📜 Active")
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'active'),
new ButtonBuilder()
.setCustomId("quest_view_available")
.setLabel("Available Quests")
.setLabel("🗺️ Available")
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'available')
);
rows.push(navRow);
if (viewType === 'available' && availableQuests.length > 0) {
const selectMenu = new StringSelectMenuBuilder()
.setCustomId("quest_accept_select")
.setPlaceholder("Select a quest to accept")
.addOptions(
availableQuests.slice(0, 25).map(q =>
new StringSelectMenuOptionBuilder()
.setLabel(q.name)
.setDescription(q.description?.substring(0, 100) || "")
.setValue(q.id.toString())
return [navRow];
}
/**
* Creates Components v2 celebratory message for quest completion
*/
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
const rewardsText = formatQuestRewards({
xp: Number(rewards.xp),
balance: Number(rewards.balance)
});
const container = new ContainerBuilder()
.setAccentColor(COLORS.COMPLETED)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
);
rows.push(new ActionRowBuilder().addComponents(selectMenu));
}
return rows;
return [container];
}
/**
* Gets MessageFlags and allowedMentions for Components v2 messages
*/
export function getComponentsV2MessageFlags() {
return {
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] as const }
};
}

View File

@@ -17,5 +17,8 @@ export const EVENTS = {
RELOAD_COMMANDS: "actions:reload_commands",
CLEAR_CACHE: "actions:clear_cache",
MAINTENANCE_MODE: "actions:maintenance_mode",
},
QUEST: {
COMPLETED: "quest:completed",
}
} as const;

View File

@@ -1,4 +1,4 @@
import { userQuests } from "@db/schema";
import { userQuests, quests } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { UserError } from "@shared/lib/errors";
import { DrizzleClient } from "@shared/db/DrizzleClient";
@@ -7,6 +7,7 @@ import { levelingService } from "@shared/modules/leveling/leveling.service";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { TransactionType } from "@shared/lib/constants";
import { systemEvents, EVENTS } from "@shared/lib/events";
export const questService = {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
@@ -107,6 +108,14 @@ export const questService = {
results.xp = xp;
}
// Emit completion event for the bot to handle notifications
systemEvents.emit(EVENTS.QUEST.COMPLETED, {
userId,
questId,
quest: userQuest.quest,
rewards: results
});
return { success: true, rewards: results };
}, tx);
},
@@ -120,7 +129,7 @@ export const questService = {
});
},
getAvailableQuests: async (userId: string) => {
async getAvailableQuests(userId: string) {
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
where: eq(userQuests.userId, BigInt(userId)),
columns: {
@@ -133,5 +142,25 @@ export const questService = {
? notInArray(quests.id, userQuestIds)
: undefined
});
},
async createQuest(data: {
name: string;
description: string;
triggerEvent: string;
requirements: { target: number };
rewards: { xp: number; balance: number };
}, tx?: Transaction) {
return await withTransaction(async (txFn) => {
return await txFn.insert(quests)
.values({
name: data.name,
description: data.description,
triggerEvent: data.triggerEvent,
requirements: data.requirements,
rewards: data.rewards,
})
.returning();
}, tx);
}
};

View File

@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
import "./index.css";
import { Dashboard } from "./pages/Dashboard";
import { DesignSystem } from "./pages/DesignSystem";
import { AdminQuests } from "./pages/AdminQuests";
import { Home } from "./pages/Home";
import { Toaster } from "sonner";
@@ -12,6 +13,7 @@ export function App() {
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/design-system" element={<DesignSystem />} />
<Route path="/admin/quests" element={<AdminQuests />} />
<Route path="/" element={<Home />} />
</Routes>
</BrowserRouter>

View File

@@ -0,0 +1,230 @@
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./ui/card";
import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { toast } from "sonner";
import { ScrollArea } from "./ui/scroll-area";
const questSchema = z.object({
name: z.string().min(3, "Name must be at least 3 characters"),
description: z.string().optional(),
triggerEvent: z.string().min(1, "Trigger event is required"),
target: z.number().min(1, "Target must be at least 1"),
xpReward: z.number().min(0).optional(),
balanceReward: z.number().min(0).optional(),
});
type QuestFormValues = z.infer<typeof questSchema>;
const TRIGGER_EVENTS = [
{ label: "XP Gain", value: "XP_GAIN" },
{ label: "Item Collect", value: "ITEM_COLLECT" },
{ label: "Item Use", value: "ITEM_USE" },
{ label: "Daily Reward", value: "DAILY_REWARD" },
{ label: "Lootbox Currency Reward", value: "LOOTBOX" },
{ label: "Exam Reward", value: "EXAM_REWARD" },
{ label: "Purchase", value: "PURCHASE" },
{ label: "Transfer In", value: "TRANSFER_IN" },
{ label: "Transfer Out", value: "TRANSFER_OUT" },
{ label: "Trade In", value: "TRADE_IN" },
{ label: "Trade Out", value: "TRADE_OUT" },
{ label: "Quest Reward", value: "QUEST_REWARD" },
{ label: "Trivia Entry", value: "TRIVIA_ENTRY" },
{ label: "Trivia Win", value: "TRIVIA_WIN" },
];
export function QuestForm() {
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,
},
});
const onSubmit = async (data: QuestFormValues) => {
setIsSubmitting(true);
try {
const response = await fetch("/api/quests", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to create quest");
}
toast.success("Quest created successfully!", {
description: `${data.name} has been added to the database.`,
});
form.reset();
} catch (error) {
console.error("Submission error:", error);
toast.error("Failed to create quest", {
description: error instanceof Error ? error.message : "An unknown error occurred",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="glass-card max-w-2xl mx-auto overflow-hidden">
<div className="h-1.5 bg-primary w-full" />
<CardHeader>
<CardTitle className="text-2xl font-bold text-primary">Create New Quest</CardTitle>
<CardDescription>
Configure a new quest for the Aurora RPG academy.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Quest Name</FormLabel>
<FormControl>
<Input placeholder="Collector's Journey" {...field} className="bg-background/50" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="triggerEvent"
render={({ field }) => (
<FormItem>
<FormLabel>Trigger Event</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-background/50">
<SelectValue placeholder="Select an event" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-card border-border/50">
<ScrollArea className="h-48">
{TRIGGER_EVENTS.map((event) => (
<SelectItem key={event.value} value={event.value}>
{event.label}
</SelectItem>
))}
</ScrollArea>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Assigns a task to the student..."
{...field}
className="min-h-[100px] bg-background/50"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FormField
control={form.control}
name="target"
render={({ field }) => (
<FormItem>
<FormLabel>Target Value</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={e => field.onChange(parseInt(e.target.value))}
className="bg-background/50"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="xpReward"
render={({ field }) => (
<FormItem>
<FormLabel>XP Reward</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={e => field.onChange(parseInt(e.target.value))}
className="bg-background/50"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="balanceReward"
render={({ field }) => (
<FormItem>
<FormLabel>AU Reward</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={e => field.onChange(parseInt(e.target.value))}
className="bg-background/50"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</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>
</Card>
);
}

View File

@@ -0,0 +1,59 @@
import React from "react";
import { Link } from "react-router-dom";
import { QuestForm } from "../components/quest-form";
import { Badge } from "../components/ui/badge";
import { SectionHeader } from "../components/section-header";
import { SettingsDrawer } from "../components/settings-drawer";
import { ChevronLeft } from "lucide-react";
export function AdminQuests() {
return (
<div className="min-h-screen bg-aurora-page text-foreground font-outfit overflow-x-hidden">
{/* Navigation */}
<nav className="sticky top-0 z-50 glass-card border-b border-border/50 py-4 px-8 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-aurora sun-flare shadow-sm" />
<span className="text-xl font-bold tracking-tight text-primary">Aurora Admin</span>
</div>
<div className="flex items-center gap-6">
<Link to="/" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Home
</Link>
<Link to="/dashboard" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Dashboard
</Link>
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Design System
</Link>
<div className="h-4 w-px bg-border/50" />
<SettingsDrawer />
</div>
</nav>
<main className="pt-12 px-8 pb-12 max-w-7xl mx-auto space-y-12">
<div className="space-y-4">
<Link
to="/dashboard"
className="flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors w-fit"
>
<ChevronLeft className="w-4 h-4" />
<span className="text-sm font-medium">Back to Dashboard</span>
</Link>
<SectionHeader
badge="Quest Management"
title="Administrative Tools"
description="Create and manage quests for the Aurora RPG students."
/>
</div>
<div className="animate-in fade-in slide-up duration-700">
<QuestForm />
</div>
</main>
</div>
);
}
export default AdminQuests;

View File

@@ -58,6 +58,9 @@ export function Dashboard() {
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Design System
</Link>
<Link to="/admin/quests" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Admin
</Link>
<div className="h-4 w-px bg-border/50" />
<SettingsDrawer />
</div>

View File

@@ -12,6 +12,7 @@ import { StatCard } from "../components/stat-card";
import { LootdropCard } from "../components/lootdrop-card";
import { Activity, Coins, Flame, Trophy } from "lucide-react";
import { SettingsDrawer } from "../components/settings-drawer";
import { QuestForm } from "../components/quest-form";
import { RecentActivity } from "../components/recent-activity";
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
@@ -98,6 +99,9 @@ export function DesignSystem() {
<Link to="/dashboard" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Dashboard
</Link>
<Link to="/admin/quests" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Admin
</Link>
</div>
</nav>
@@ -464,6 +468,17 @@ export function DesignSystem() {
</div>
</section>
{/* Administrative Tools Showcase */}
<section className="space-y-6 animate-in slide-up delay-700">
<h2 className="text-3xl font-bold flex items-center justify-center md:justify-start gap-2">
<span className="w-8 h-8 rounded-full bg-primary inline-block" />
Administrative Tools
</h2>
<div className="grid grid-cols-1 gap-6 text-left">
<QuestForm />
</div>
</section>
{/* Typography */}
<section className="space-y-8 pb-12">
<h2 className="text-step-3 font-bold text-center">Fluid Typography</h2>

View File

@@ -31,6 +31,9 @@ export function Home() {
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Design System
</Link>
<Link to="/admin/quests" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Admin
</Link>
</div>
</nav>

View File

@@ -169,6 +169,34 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
}
}
// Quest Management
if (url.pathname === "/api/quests" && req.method === "POST") {
try {
const { questService } = await import("@shared/modules/quest/quest.service");
const data = await req.json();
// Basic validation could be added here or rely on service/DB
const result = await questService.createQuest({
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
}
});
return Response.json({ success: true, quest: result[0] });
} catch (error) {
logger.error("web", "Error creating quest", error);
return Response.json(
{ error: "Failed to create quest", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// Settings Management
if (url.pathname === "/api/settings") {
try {