feat: Implement an admin quest management table, enhance toast notifications with descriptions, and add new agent documentation.
This commit is contained in:
238
AGENTS.md
Normal file
238
AGENTS.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# AGENTS.md - AI Coding Agent Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM.
|
||||||
|
|
||||||
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
bun --watch bot/index.ts # Run bot with hot reload
|
||||||
|
bun --hot web/src/index.ts # Run web dashboard with hot reload
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
bun test # Run all tests
|
||||||
|
bun test path/to/file.test.ts # Run single test file
|
||||||
|
bun test --watch # Watch mode
|
||||||
|
bun test shared/modules/economy # Run tests in directory
|
||||||
|
|
||||||
|
# Database
|
||||||
|
bun run generate # Generate Drizzle migrations (Docker)
|
||||||
|
bun run migrate # Run migrations (Docker)
|
||||||
|
bun run db:push # Push schema changes (Docker)
|
||||||
|
bun run db:push:local # Push schema changes (local)
|
||||||
|
bun run db:studio # Open Drizzle Studio
|
||||||
|
|
||||||
|
# Web Dashboard
|
||||||
|
cd web && bun run build # Build production web assets
|
||||||
|
cd web && bun run dev # Development server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/ # Discord bot
|
||||||
|
├── commands/ # Slash commands by category
|
||||||
|
├── events/ # Discord event handlers
|
||||||
|
├── lib/ # Bot core (BotClient, handlers, loaders)
|
||||||
|
├── modules/ # Feature modules (views, interactions)
|
||||||
|
└── graphics/ # Canvas image generation
|
||||||
|
|
||||||
|
shared/ # Shared between bot and web
|
||||||
|
├── db/ # Database schema and migrations
|
||||||
|
├── lib/ # Utils, config, errors, types
|
||||||
|
└── modules/ # Domain services (economy, user, etc.)
|
||||||
|
|
||||||
|
web/ # React dashboard
|
||||||
|
├── src/pages/ # React pages
|
||||||
|
├── src/components/ # UI components (ShadCN/Radix)
|
||||||
|
└── src/hooks/ # React hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import Conventions
|
||||||
|
|
||||||
|
Use path aliases defined in tsconfig.json:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// External packages first
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// Path aliases second
|
||||||
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { users } from "@db/schema";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
||||||
|
|
||||||
|
// Relative imports last
|
||||||
|
import { localHelper } from "./helper";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Aliases:**
|
||||||
|
- `@/*` - bot/
|
||||||
|
- `@shared/*` - shared/
|
||||||
|
- `@db/*` - shared/db/
|
||||||
|
- `@lib/*` - bot/lib/
|
||||||
|
- `@modules/*` - bot/modules/
|
||||||
|
- `@commands/*` - bot/commands/
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
| Element | Convention | Example |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||||
|
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||||
|
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||||
|
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||||
|
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||||
|
| Services | camelCase singleton | `economyService`, `userService` |
|
||||||
|
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||||
|
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||||
|
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Command Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const commandName = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("commandname")
|
||||||
|
.setDescription("Description"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Pattern (Singleton Object)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const serviceName = {
|
||||||
|
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
// Database operations
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module File Organization
|
||||||
|
|
||||||
|
- `*.view.ts` - Creates Discord embeds/components
|
||||||
|
- `*.interaction.ts` - Handles button/select/modal interactions
|
||||||
|
- `*.types.ts` - Module-specific TypeScript types
|
||||||
|
- `*.service.ts` - Business logic (in shared/modules/)
|
||||||
|
- `*.test.ts` - Test files (co-located with source)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Custom Error Classes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UserError, SystemError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
// User-facing errors (shown to user)
|
||||||
|
throw new UserError("You don't have enough coins!");
|
||||||
|
|
||||||
|
// System errors (logged, generic message shown)
|
||||||
|
throw new SystemError("Database connection failed");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard Error Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const result = await service.method();
|
||||||
|
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected error:", error);
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Patterns
|
||||||
|
|
||||||
|
### Transaction Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withTransaction } from "@/lib/db";
|
||||||
|
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
const user = await tx.query.users.findFirst({
|
||||||
|
where: eq(users.id, discordId)
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, discordId));
|
||||||
|
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}, existingTx); // Pass existing tx if in nested transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Notes
|
||||||
|
|
||||||
|
- Use `bigint` mode for Discord IDs and currency amounts
|
||||||
|
- Relations defined separately from table definitions
|
||||||
|
- Schema location: `shared/db/schema.ts`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test File Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// Mock modules BEFORE imports
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: { query: mockQuery }
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("serviceName", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFn.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle expected case", async () => {
|
||||||
|
// Arrange
|
||||||
|
mockFn.mockResolvedValue(testData);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.method(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime:** Bun 1.0+
|
||||||
|
- **Bot:** Discord.js 14.x
|
||||||
|
- **Web:** React 19 + Bun HTTP Server
|
||||||
|
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||||
|
- **UI:** Tailwind CSS v4 + ShadCN/Radix
|
||||||
|
- **Validation:** Zod
|
||||||
|
- **Testing:** Bun Test
|
||||||
|
- **Container:** Docker
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
| Purpose | File |
|
||||||
|
|---------|------|
|
||||||
|
| Bot entry | `bot/index.ts` |
|
||||||
|
| DB schema | `shared/db/schema.ts` |
|
||||||
|
| Error classes | `shared/lib/errors.ts` |
|
||||||
|
| Config loader | `shared/lib/config.ts` |
|
||||||
|
| Environment | `shared/lib/env.ts` |
|
||||||
|
| Embed helpers | `bot/lib/embeds.ts` |
|
||||||
|
| Command utils | `shared/lib/utils.ts` |
|
||||||
@@ -168,5 +168,34 @@ export const questService = {
|
|||||||
return await DrizzleClient.query.quests.findMany({
|
return await DrizzleClient.query.quests.findMany({
|
||||||
orderBy: (quests, { asc }) => [asc(quests.id)],
|
orderBy: (quests, { asc }) => [asc(quests.id)],
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteQuest(id: number, tx?: Transaction) {
|
||||||
|
return await withTransaction(async (txFn) => {
|
||||||
|
return await txFn.delete(quests)
|
||||||
|
.where(eq(quests.id, id))
|
||||||
|
.returning();
|
||||||
|
}, tx);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateQuest(id: number, 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.update(quests)
|
||||||
|
.set({
|
||||||
|
...(data.name !== undefined && { name: data.name }),
|
||||||
|
...(data.description !== undefined && { description: data.description }),
|
||||||
|
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
|
||||||
|
...(data.requirements !== undefined && { requirements: data.requirements }),
|
||||||
|
...(data.rewards !== undefined && { rewards: data.rewards }),
|
||||||
|
})
|
||||||
|
.where(eq(quests.id, id))
|
||||||
|
.returning();
|
||||||
|
}, tx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-hook-form": "^7.70.0",
|
"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=="],
|
"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": ["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=="],
|
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) {
|
|||||||
}
|
}
|
||||||
setEnabledState(state);
|
setEnabledState(state);
|
||||||
}).catch(err => {
|
}).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);
|
console.error(err);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -94,11 +96,14 @@ export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) {
|
|||||||
|
|
||||||
setEnabledState(prev => ({ ...prev, [commandName]: enabled }));
|
setEnabledState(prev => ({ ...prev, [commandName]: enabled }));
|
||||||
toast.success(`/${commandName} ${enabled ? "enabled" : "disabled"}`, {
|
toast.success(`/${commandName} ${enabled ? "enabled" : "disabled"}`, {
|
||||||
|
description: `Command has been ${enabled ? "enabled" : "disabled"} successfully.`,
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
id: "command-toggle", // Replace previous toast instead of stacking
|
id: "command-toggle",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
setSaving(null);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function NavItemWithSubMenu({ item }: { item: NavItem }) {
|
|||||||
<Link
|
<Link
|
||||||
to={subItem.url}
|
to={subItem.url}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer",
|
"cursor-pointer py-4 min-h-10",
|
||||||
subItem.isActive && "text-primary bg-primary/10"
|
subItem.isActive && "text-primary bg-primary/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ import { Textarea } from "./ui/textarea";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
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({
|
const questSchema = z.object({
|
||||||
name: z.string().min(3, "Name must be at least 3 characters"),
|
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>;
|
type QuestFormValues = z.infer<typeof questSchema>;
|
||||||
|
|
||||||
|
interface QuestFormProps {
|
||||||
|
initialData?: QuestListItem;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const TRIGGER_EVENTS = [
|
const TRIGGER_EVENTS = [
|
||||||
{ label: "XP Gain", value: "XP_GAIN" },
|
{ label: "XP Gain", value: "XP_GAIN" },
|
||||||
{ label: "Item Collect", value: "ITEM_COLLECT" },
|
{ label: "Item Collect", value: "ITEM_COLLECT" },
|
||||||
@@ -39,25 +55,42 @@ const TRIGGER_EVENTS = [
|
|||||||
{ label: "Trivia Win", value: "TRIVIA_WIN" },
|
{ 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 [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
const form = useForm<QuestFormValues>({
|
const form = useForm<QuestFormValues>({
|
||||||
resolver: zodResolver(questSchema),
|
resolver: zodResolver(questSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: initialData?.name || "",
|
||||||
description: "",
|
description: initialData?.description || "",
|
||||||
triggerEvent: "XP_GAIN",
|
triggerEvent: initialData?.triggerEvent || "XP_GAIN",
|
||||||
target: 1,
|
target: (initialData?.requirements as { target?: number })?.target || 1,
|
||||||
xpReward: 100,
|
xpReward: (initialData?.rewards as { xp?: number })?.xp || 100,
|
||||||
balanceReward: 500,
|
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) => {
|
const onSubmit = async (data: QuestFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/quests", {
|
const url = isEditMode ? `/api/quests/${initialData.id}` : "/api/quests";
|
||||||
method: "POST",
|
const method = isEditMode ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
@@ -66,17 +99,24 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
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!", {
|
toast.success(isEditMode ? "Quest updated successfully!" : "Quest created successfully!", {
|
||||||
description: `${data.name} has been added to the database.`,
|
description: `${data.name} has been ${isEditMode ? "updated" : "added to the database"}.`,
|
||||||
});
|
});
|
||||||
form.reset();
|
form.reset({
|
||||||
onSuccess?.();
|
name: "",
|
||||||
|
description: "",
|
||||||
|
triggerEvent: "XP_GAIN",
|
||||||
|
target: 1,
|
||||||
|
xpReward: 100,
|
||||||
|
balanceReward: 500,
|
||||||
|
});
|
||||||
|
onUpdate?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Submission error:", 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",
|
description: error instanceof Error ? error.message : "An unknown error occurred",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -85,12 +125,12 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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" />
|
<div className="h-1.5 bg-primary w-full" />
|
||||||
<CardHeader>
|
<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>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -182,7 +222,10 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
name="xpReward"
|
name="xpReward"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<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>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -201,7 +244,10 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
name="balanceReward"
|
name="balanceReward"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<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>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -216,6 +262,25 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@@ -223,6 +288,7 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
>
|
>
|
||||||
{isSubmitting ? "Creating..." : "Create Quest"}
|
{isSubmitting ? "Creating..." : "Create Quest"}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Skeleton } from "./ui/skeleton";
|
import { Skeleton } from "./ui/skeleton";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { FileText, RefreshCw } from "lucide-react";
|
import { FileText, RefreshCw, Trash2, Pencil, Star, Coins } from "lucide-react";
|
||||||
|
|
||||||
interface QuestListItem {
|
interface QuestListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,6 +21,8 @@ interface QuestTableProps {
|
|||||||
isInitialLoading: boolean;
|
isInitialLoading: boolean;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
|
onDelete?: (id: number) => void;
|
||||||
|
onEdit?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRIGGER_EVENT_LABELS: Record<string, string> = {
|
const TRIGGER_EVENT_LABELS: Record<string, string> = {
|
||||||
@@ -67,7 +70,7 @@ function TruncatedText({ text, maxLength = 100 }: { text: string; maxLength?: nu
|
|||||||
function QuestTableSkeleton() {
|
function QuestTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 animate-pulse">
|
<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-8" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
<Skeleton className="h-4 w-48" />
|
<Skeleton className="h-4 w-48" />
|
||||||
@@ -77,7 +80,7 @@ function QuestTableSkeleton() {
|
|||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{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-8" />
|
||||||
<Skeleton className="h-5 w-32" />
|
<Skeleton className="h-5 w-32" />
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
@@ -85,6 +88,7 @@ function QuestTableSkeleton() {
|
|||||||
<Skeleton className="h-5 w-16" />
|
<Skeleton className="h-5 w-16" />
|
||||||
<Skeleton className="h-5 w-24" />
|
<Skeleton className="h-5 w-24" />
|
||||||
<Skeleton className="h-5 w-24" />
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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) {
|
if (quests.length === 0) {
|
||||||
return <EmptyQuestState />;
|
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">
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
|
||||||
AU Reward
|
AU Reward
|
||||||
</th>
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-24">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -170,7 +177,7 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
|
|||||||
<td className="py-3 px-4 text-sm text-foreground">
|
<td className="py-3 px-4 text-sm text-foreground">
|
||||||
{rewards?.xp ? (
|
{rewards?.xp ? (
|
||||||
<span className="flex items-center gap-1">
|
<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 className="font-mono">{rewards.xp}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -180,13 +187,51 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
|
|||||||
<td className="py-3 px-4 text-sm text-foreground">
|
<td className="py-3 px-4 text-sm text-foreground">
|
||||||
{rewards?.balance ? (
|
{rewards?.balance ? (
|
||||||
<span className="flex items-center gap-1">
|
<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 className="font-mono">{rewards.balance}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
</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;
|
const showSkeleton = isInitialLoading && quests.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -235,7 +280,7 @@ export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh }
|
|||||||
{showSkeleton ? (
|
{showSkeleton ? (
|
||||||
<QuestTableSkeleton />
|
<QuestTableSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<QuestTableContent quests={quests} />
|
<QuestTableContent quests={quests} onDelete={onDelete} onEdit={onEdit} />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
38
web/src/components/ui/sonner.tsx
Normal file
38
web/src/components/ui/sonner.tsx
Normal 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 }
|
||||||
@@ -137,7 +137,9 @@ export function useSettings() {
|
|||||||
form.reset(config as any);
|
form.reset(config as any);
|
||||||
setMeta(metaData);
|
setMeta(metaData);
|
||||||
} catch (err) {
|
} 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);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -165,7 +167,9 @@ export function useSettings() {
|
|||||||
// Reload settings to ensure we have the latest state
|
// Reload settings to ensure we have the latest state
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
} catch (error) {
|
} 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);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export function AdminQuests() {
|
|||||||
const [isInitialLoading, setIsInitialLoading] = React.useState(true);
|
const [isInitialLoading, setIsInitialLoading] = React.useState(true);
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||||
const [lastCreatedQuestId, setLastCreatedQuestId] = React.useState<number | null>(null);
|
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) => {
|
const fetchQuests = React.useCallback(async (isRefresh = false) => {
|
||||||
if (isRefresh) {
|
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 (
|
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
|
||||||
@@ -84,11 +133,17 @@ export function AdminQuests() {
|
|||||||
isInitialLoading={isInitialLoading}
|
isInitialLoading={isInitialLoading}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
onRefresh={() => fetchQuests(true)}
|
onRefresh={() => fetchQuests(true)}
|
||||||
|
onDelete={handleDeleteQuest}
|
||||||
|
onEdit={handleEditQuest}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="animate-in fade-in slide-up duration-700">
|
<div className="animate-in fade-in slide-up duration-700" ref={formRef}>
|
||||||
<QuestForm onSuccess={handleQuestCreated} />
|
<QuestForm
|
||||||
|
initialData={editingQuest || undefined}
|
||||||
|
onUpdate={handleQuestUpdated}
|
||||||
|
onCancel={handleFormCancel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
// Settings Management
|
||||||
if (url.pathname === "/api/settings") {
|
if (url.pathname === "/api/settings") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user