29 Commits

Author SHA1 Message Date
syntaxbullet
73ad889018 docs: update documentation to reflect headless API-only web service
- AGENTS.md: Update project description from web dashboard to REST API

- README.md: Replace Web Dashboard section with REST API, update tech stack

- docs/main.md: Refactor Web Dashboard section to REST API documentation

- web/README.md: Rewrite from React setup to API endpoint documentation

All React/UI references removed - web is now API-only
2026-02-12 12:30:43 +01:00
syntaxbullet
9c7f1e4418 chore(deps): remove unused React/UI dependencies from headless web API
- Remove 31 unused packages: React, Tailwind, Radix UI, etc.

- Clean up web/tsconfig.json (remove JSX, DOM lib)

- Remove old web/dist/ build artifacts

Web dashboard is now API-only, no UI dependencies needed
2026-02-12 12:26:37 +01:00
syntaxbullet
efb50916b2 docs: update CI workflow and AGENTS.md for consolidated deps
Update references to removed web/package.json:
- CI workflow: Remove 'cd web && bun install' step
- AGENTS.md: Remove web-specific dev commands (cd web && bun run dev/build)
- AGENTS.md: Update schema location to reflect domain module split
- Add Docker commands as recommended local dev approach

All dependencies now installed from root package.json.
2026-02-12 12:21:37 +01:00
syntaxbullet
6abb52694e chore(web): remove package.json and bun.lock
Remove web/package.json and web/bun.lock now that all dependencies
are consolidated in root package.json. The web/node_modules directory
will be cleaned up separately (permission restrictions).

Web dashboard now uses dependencies from root node_modules.
2026-02-12 12:20:09 +01:00
syntaxbullet
76968e31a6 refactor(deps): merge web dependencies into root package.json
Move all web dashboard dependencies from web/package.json into root:
- React 19 + React Router 7
- Radix UI components (14 packages)
- Tailwind CSS v4 + bun-plugin-tailwind
- Recharts, React Hook Form, Zod validation
- Dev dependencies: @types/react, @types/react-dom, tailwindcss

This fixes a production issue where web dependencies weren't being
installed in Dockerfile.prod, even though bot/index.ts imports from
web/src/server at runtime.

VPS deployments using Dockerfile.prod will now have all required
dependencies in a single node_modules.
2026-02-12 12:19:51 +01:00
syntaxbullet
29bf0e6f1c refactor(docker): remove duplicate production stage from Dockerfile
Remove the 'production' stage from Dockerfile that was:
- Duplicating functionality already in Dockerfile.prod
- Incorrectly running 'bun run dev' instead of production command

VPS deployments continue to use Dockerfile.prod as the single
source of truth for production builds. Development Dockerfile
now only contains development stage.
2026-02-12 12:19:02 +01:00
syntaxbullet
8c306fbd23 refactor(inventory): flatten effects directory structure
Move effect handlers from effects/ subdirectory to flat structure:
- effects/handlers.ts → effect.handlers.ts
- effects/registry.ts → effect.registry.ts
- effects/types.ts → effect.types.ts

Update import path in inventory.service.ts from
'@/modules/inventory/effects/registry' to
'@/modules/inventory/effect.registry'.

This reduces directory nesting and follows the convention of
keeping module files flat unless there are 5+ files.
2026-02-12 12:15:17 +01:00
syntaxbullet
b0c3baf5b7 refactor(db): split schema into domain modules
Split the 276-line schema.ts into focused domain modules:
- users.ts: classes, users, userTimers (core identity)
- inventory.ts: items, inventory (item system)
- economy.ts: transactions, itemTransactions (currency flow)
- quests.ts: quests, userQuests (quest system)
- moderation.ts: moderationCases, lootdrops (moderation)

Original schema.ts now re-exports from schema/index.ts for backward
compatibility. All existing imports continue to work.
2026-02-12 12:14:15 +01:00
syntaxbullet
f575588b9a feat(db): export all schema types
Add missing type exports for Class, ItemTransaction, Quest,
UserQuest, UserTimer, and Lootdrop tables. All tables now
have consistent type exports available for import.
2026-02-12 12:12:49 +01:00
syntaxbullet
553b9b4952 feat: Implement a new API routing system by adding dedicated route files for users, transactions, assets, items, quests, and other game entities, and integrating them into the server. 2026-02-08 18:57:42 +01:00
syntaxbullet
073348fa55 feat: implement lootdrop management endpoints and fix class api types 2026-02-08 16:56:34 +01:00
syntaxbullet
4232674494 feat: implement user inventory management and class update endpoints 2026-02-08 16:55:04 +01:00
syntaxbullet
fbf1e52c28 test: add deepMerge mock to fix test isolation
Add deepMerge to @shared/lib/utils mocks in both test files to ensure
consistent behavior when tests run together.
2026-02-08 16:42:02 +01:00
syntaxbullet
20284dc57b build(docker): remove web frontend build dependencies
- Remove web package.json install steps from Dockerfiles
- Remove web/dist copy from production build
- Remove web_node_modules volume from docker-compose
2026-02-08 16:41:56 +01:00
syntaxbullet
36f9c76fa9 refactor(web): convert server to API-only mode
- Remove build process spawning for frontend bundler
- Remove SPA fallback and static file serving
- Return 404 for unknown routes instead of serving index.html
- Keep all REST API endpoints and WebSocket functionality
2026-02-08 16:41:47 +01:00
syntaxbullet
46e95ce7b3 refactor(web): remove frontend dashboard files
Delete all React components, pages, hooks, contexts, styles, and build scripts.
The web module now serves as an API-only server.
2026-02-08 16:41:40 +01:00
syntaxbullet
9acd3f3d76 docs: add API reference documentation 2026-02-08 16:41:31 +01:00
syntaxbullet
5e8683a19f feat: Implement structured lootbox results with image support and display referenced items in shop listings. 2026-02-08 16:07:13 +01:00
syntaxbullet
ee088ad84b feat: Increase maximum image upload size from 2MB to 15MB. 2026-02-06 13:48:43 +01:00
syntaxbullet
b18b5fab62 feat: Allow direct icon upload when updating an item in the item form. 2026-02-06 13:37:19 +01:00
syntaxbullet
0b56486ab2 fix(docker): add web network to studio to allow port exposure 2026-02-06 13:14:24 +01:00
syntaxbullet
11c589b01c chore: stop opening browser automatically when connecting to remote 2026-02-06 13:11:16 +01:00
syntaxbullet
e4169d9dd5 chore: add studio service to production compose 2026-02-06 13:10:01 +01:00
syntaxbullet
1929f0dd1f refactor: Abbreviate item rarity values from full names to single-letter codes across the application. 2026-02-06 13:00:41 +01:00
syntaxbullet
db4e7313c3 feat: Add support for local asset URLs for shop item icons and images, attaching them to Discord messages. 2026-02-06 12:52:15 +01:00
syntaxbullet
1ffe397fbb feat: Add image cropping functionality with a new component, dialog, and canvas utilities. 2026-02-06 12:45:09 +01:00
syntaxbullet
34958aa220 feat: implement comprehensive item management system with admin UI, API, and asset handling utilities. 2026-02-06 12:19:14 +01:00
syntaxbullet
109b36ffe2 chore: bump version, add deployment script 2026-02-05 13:05:07 +01:00
syntaxbullet
cd954afe36 chore: improve dev experience via docker override, and remove redundant commands. 2026-02-05 12:57:20 +01:00
124 changed files with 5114 additions and 8537 deletions

View File

@@ -43,9 +43,7 @@ jobs:
bun-version: latest
- name: Install Dependencies
run: |
bun install --frozen-lockfile
cd web && bun install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Create Config File
run: |

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ src/db/data
src/db/log
scratchpad/
tickets/
bot/assets/graphics/items

View File

@@ -2,17 +2,16 @@
## Project Overview
AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM.
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, 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
bun --watch bot/index.ts # Run bot + API server with hot reload
# Testing
bun test # Run all tests ( expect some tests to fail when running all at once like this due to the nature of the tests )
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
@@ -24,9 +23,10 @@ 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
# Docker (recommended for local dev)
docker compose up # Start bot, API, and database
docker compose up app # Start just the app (bot + API)
docker compose up db # Start just the database
```
## Project Structure
@@ -44,10 +44,8 @@ shared/ # Shared between bot and web
├── 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
web/ # API server
── src/routes/ # API route handlers
```
## Import Conventions
@@ -187,7 +185,7 @@ return await withTransaction(async (tx) => {
- Use `bigint` mode for Discord IDs and currency amounts
- Relations defined separately from table definitions
- Schema location: `shared/db/schema.ts`
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
## Testing
@@ -224,9 +222,9 @@ describe("serviceName", () => {
- **Runtime:** Bun 1.0+
- **Bot:** Discord.js 14.x
- **Web:** React 19 + Bun HTTP Server
- **Web:** Bun HTTP Server (REST API)
- **Database:** PostgreSQL 16+ with Drizzle ORM
- **UI:** Tailwind CSS v4 + ShadCN/Radix
- **UI:** Discord embeds and components
- **Validation:** Zod
- **Testing:** Bun Test
- **Container:** Docker

View File

@@ -16,11 +16,9 @@ FROM base AS deps
# Copy only package files first (better layer caching)
COPY package.json bun.lock ./
COPY web/package.json web/bun.lock ./web/
# Install all dependencies in one layer
RUN bun install --frozen-lockfile && \
cd web && bun install --frozen-lockfile
# Install dependencies
RUN bun install --frozen-lockfile
# ============================================
# Development stage - for local dev with volume mounts
@@ -29,25 +27,6 @@ FROM base AS development
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/web/node_modules ./web/node_modules
# Expose ports
EXPOSE 3000
# Default command
CMD ["bun", "run", "dev"]
# ============================================
# Production stage - full app with source code
# ============================================
FROM base AS production
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/web/node_modules ./web/node_modules
# Copy source code
COPY . .
# Expose ports
EXPOSE 3000

View File

@@ -11,16 +11,9 @@ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Install web project dependencies
COPY web/package.json web/bun.lock ./web/
RUN cd web && bun install --frozen-lockfile
# Copy source code
COPY . .
# Build web assets for production
RUN cd web && bun run build
# =============================================================================
# Stage 2: Production Runtime
# =============================================================================
@@ -34,8 +27,6 @@ WORKDIR /app
# Copy only what's needed for production
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
COPY --from=builder --chown=bun:bun /app/web/node_modules ./web/node_modules
COPY --from=builder --chown=bun:bun /app/web/dist ./web/dist
COPY --from=builder --chown=bun:bun /app/web/src ./web/src
COPY --from=builder --chown=bun:bun /app/bot ./bot
COPY --from=builder --chown=bun:bun /app/shared ./shared

View File

@@ -7,11 +7,9 @@
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
![React](https://img.shields.io/badge/React-19-61DAFB)
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
## ✨ Features
@@ -25,26 +23,26 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
* **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management.
### Web Dashboard
* **Live Analytics**: View real-time activity charts (commands, transactions).
* **Configuration Management**: Update bot settings without restarting.
### REST API
* **Live Analytics**: Real-time statistics endpoint (commands, transactions).
* **Configuration Management**: Update bot settings via API.
* **Database Inspection**: Integrated Drizzle Studio access.
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
* **WebSocket Support**: Real-time event streaming for live updates.
## 🏗️ Architecture
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
* **Unified Runtime**: Both the Discord Client and the REST API run within the same Bun process.
* **Shared State**: This allows the API to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
* **Simplified Deployment**: You only need to deploy a single Docker container.
## 🛠️ Tech Stack
* **Runtime**: [Bun](https://bun.sh/)
* **Bot Framework**: [Discord.js](https://discord.js.org/)
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
* **API Framework**: Bun HTTP Server (REST API)
* **UI**: Discord embeds and components
* **Database**: [PostgreSQL](https://www.postgresql.org/)
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
* **Validation**: [Zod](https://zod.dev/)
@@ -94,14 +92,14 @@ Aurora uses a **Single Process Monolith** architecture to maximize performance a
bun run db:push
```
### Running the Bot & Dashboard
### Running the Bot & API
**Development Mode** (with hot reload):
```bash
bun run dev
```
* Bot: Online in Discord
* Dashboard: http://localhost:3000
* API: http://localhost:3000
**Production Mode**:
Build and run with Docker (recommended):
@@ -111,7 +109,7 @@ docker compose up -d app
### 🔐 Accessing Production Services (SSH Tunnel)
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
For security, the Production Database and API are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
To access them from your local machine, use the included SSH tunnel script.
@@ -127,12 +125,12 @@ To access them from your local machine, use the included SSH tunnel script.
```
This will establish secure tunnels for:
* **Dashboard**: http://localhost:3000
* **API**: http://localhost:3000
* **Drizzle Studio**: http://localhost:4983
## 📜 Scripts
* `bun run dev`: Start the bot and dashboard in watch mode.
* `bun run dev`: Start the bot and API server in watch mode.
* `bun run remote`: Open SSH tunnel to production services.
* `bun run generate`: Generate Drizzle migrations.
* `bun run migrate`: Apply migrations (via Docker).
@@ -143,7 +141,7 @@ This will establish secure tunnels for:
```
├── bot # Discord Bot logic & entry point
├── web # React Web Dashboard (Frontend + Server)
├── web # REST API Server
├── shared # Shared code (Database, Config, Types)
├── drizzle # Drizzle migration files
├── scripts # Utility scripts

View File

View File

@@ -1,68 +0,0 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
import { config, saveConfig } from "@shared/lib/config";
import type { GameConfigType } from "@shared/lib/config";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
export const configCommand = createCommand({
data: new SlashCommandBuilder()
.setName("config")
.setDescription("Edit the bot configuration")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
console.log(`Config command executed by ${interaction.user.tag}`);
const replacer = (key: string, value: any) => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
const currentConfigJson = JSON.stringify(config, replacer, 4);
const modal = new ModalBuilder()
.setCustomId("config-modal")
.setTitle("Edit Configuration");
const jsonInput = new TextInputBuilder()
.setCustomId("json-input")
.setLabel("Configuration JSON")
.setStyle(TextInputStyle.Paragraph)
.setValue(currentConfigJson)
.setRequired(true);
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
modal.addComponents(actionRow);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 300000, // 5 minutes
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
});
const jsonString = submitted.fields.getTextInputValue("json-input");
try {
const newConfig = JSON.parse(jsonString);
saveConfig(newConfig as GameConfigType);
await submitted.reply({
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
});
} catch (parseError) {
await submitted.reply({
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
ephemeral: true
});
}
} catch (error) {
// Timeout or other error handling if needed, usually just ignore timeouts for modals
if (error instanceof Error && error.message.includes('time')) {
// specific timeout handling if desired
}
}
}
});

View File

@@ -1,94 +0,0 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
import { AuroraClient } from "@/lib/BotClient";
export const features = createCommand({
data: new SlashCommandBuilder()
.setName("features")
.setDescription("Manage bot features and commands")
.addSubcommand(sub =>
sub.setName("list")
.setDescription("List all commands and their status")
)
.addSubcommand(sub =>
sub.setName("toggle")
.setDescription("Enable or disable a command")
.addStringOption(option =>
option.setName("command")
.setDescription("The name of the command")
.setRequired(true)
)
.addBooleanOption(option =>
option.setName("enabled")
.setDescription("Whether the command should be enabled")
.setRequired(true)
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
const subcommand = interaction.options.getSubcommand();
if (subcommand === "list") {
const activeCommands = AuroraClient.commands;
const categories = new Map<string, string[]>();
// Group active commands
activeCommands.forEach(cmd => {
const cat = cmd.category || 'Uncategorized';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat)!.push(cmd.data.name);
});
// Config overrides
const overrides = Object.entries(config.commands)
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
const embed = createBaseEmbed("Command Features", undefined, "Blue");
// Add fields for each category
const sortedCategories = [...categories.keys()].sort();
for (const cat of sortedCategories) {
const cmds = categories.get(cat)!.sort();
const cmdList = cmds.map(name => {
const isOverride = config.commands[name] !== undefined;
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
}).join(", ");
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
}
if (overrides.length > 0) {
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
} else {
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
}
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
return;
}
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
} else if (subcommand === "toggle") {
const commandName = interaction.options.getString("command", true);
const enabled = interaction.options.getBoolean("enabled", true);
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
toggleCommand(commandName, enabled);
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
// Reload config from disk (which was updated by toggleCommand)
reloadConfig();
await AuroraClient.loadCommands(true);
await AuroraClient.deployCommands();
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
}
}
});

View File

@@ -1,20 +1,18 @@
import { createCommand } from "@shared/lib/utils";
import {
SlashCommandBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type BaseGuildTextChannel,
PermissionFlagsBits,
MessageFlags
} from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { items } from "@db/schema";
import { ilike, isNotNull, and } from "drizzle-orm";
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { getShopListingMessage } from "@/modules/economy/shop.view";
import { EffectType, LootType } from "@shared/lib/constants";
export const listing = createCommand({
data: new SlashCommandBuilder()
@@ -54,14 +52,42 @@ export const listing = createCommand({
return;
}
// Prepare context for lootboxes
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
const usageData = item.usageData as any;
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
if (lootboxEffect && lootboxEffect.pool) {
const itemIds = lootboxEffect.pool
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
.map((drop: any) => drop.itemId);
if (itemIds.length > 0) {
// Remove duplicates
const uniqueIds = [...new Set(itemIds)] as number[];
const referencedItems = await DrizzleClient.select({
id: items.id,
name: items.name,
rarity: items.rarity
}).from(items).where(inArray(items.id, uniqueIds));
for (const ref of referencedItems) {
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
}
}
}
const listingMessage = getShopListingMessage({
...item,
rarity: item.rarity || undefined,
formattedPrice: `${item.price} 🪙`,
price: item.price
});
}, context);
try {
await targetChannel.send(listingMessage);
await targetChannel.send(listingMessage as any);
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error: any) {
if (error instanceof UserError) {

View File

@@ -55,9 +55,9 @@ export const use = createCommand({
}
}
const embed = getItemUseResultEmbed(result.results, result.item);
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed] });
await interaction.editReply({ embeds: [embed], files });
} catch (error: any) {
if (error instanceof UserError) {

View File

@@ -23,7 +23,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
draft = {
name: "New Item",
description: "No description",
rarity: "Common",
rarity: "C",
type: ItemType.MATERIAL,
price: null,
iconUrl: "",

View File

@@ -87,7 +87,7 @@ export const getDetailsModal = (current: DraftItem) => {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
);
return modal;
};

View File

@@ -1,20 +1,208 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { createBaseEmbed } from "@/lib/embeds";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
Colors,
ContainerBuilder,
SectionBuilder,
TextDisplayBuilder,
MediaGalleryBuilder,
MediaGalleryItemBuilder,
ThumbnailBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
MessageFlags
} from "discord.js";
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
import { join } from "path";
import { existsSync } from "fs";
import { LootType, EffectType } from "@shared/lib/constants";
import type { LootTableItem } from "@shared/lib/types";
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
.setThumbnail(item.iconUrl || null)
.setImage(item.imageUrl || null)
.setFooter({ text: "Click the button below to purchase instantly." });
// Rarity Color Map
const RarityColors: Record<string, number> = {
"C": Colors.LightGrey,
"R": Colors.Blue,
"SR": Colors.Purple,
"SSR": Colors.Gold,
"CURRENCY": Colors.Green,
"XP": Colors.Aqua,
"NOTHING": Colors.DarkButNotBlack
};
const TitleMap: Record<string, string> = {
"C": "📦 Common Items",
"R": "📦 Rare Items",
"SR": "✨ Super Rare Items",
"SSR": "🌟 SSR Items",
"CURRENCY": "💰 Currency",
"XP": "🔮 Experience",
"NOTHING": "💨 Empty"
};
export function getShopListingMessage(
item: {
id: number;
name: string;
description: string | null;
formattedPrice: string;
iconUrl: string | null;
imageUrl: string | null;
price: number | bigint;
usageData?: any;
rarity?: string;
},
context?: { referencedItems: Map<number, { name: string; rarity: string }> }
) {
const files: AttachmentBuilder[] = [];
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
let displayImageUrl = resolveAssetUrl(item.imageUrl);
// Handle local icon
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
if (existsSync(iconPath)) {
const iconName = defaultName(item.iconUrl);
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
thumbnailUrl = `attachment://${iconName}`;
}
}
// Handle local image
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
displayImageUrl = thumbnailUrl;
} else {
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
if (existsSync(imagePath)) {
const imageName = defaultName(item.imageUrl);
if (!files.find(f => f.name === imageName)) {
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
}
displayImageUrl = `attachment://${imageName}`;
}
}
}
const containers: ContainerBuilder[] = [];
// 1. Main Container
const mainContainer = new ContainerBuilder()
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
// Header Section
const infoSection = new SectionBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`# ${item.name}`),
new TextDisplayBuilder().setContent(item.description || "_No description available._"),
new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`)
);
// Set Thumbnail Accessory if we have an icon
if (thumbnailUrl) {
infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
}
mainContainer.addSectionComponents(infoSection);
// Media Gallery for additional images (if multiple)
const mediaSources: string[] = [];
if (thumbnailUrl) mediaSources.push(thumbnailUrl);
if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl);
if (mediaSources.length > 1) {
mainContainer.addMediaGalleryComponents(
new MediaGalleryBuilder().addItems(
...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src))
)
);
}
// 2. Loot Table (if applicable)
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
const pool = lootboxEffect.pool as LootTableItem[];
const totalWeight = pool.reduce((sum, i) => sum + i.weight, 0);
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
const groups: Record<string, string[]> = {};
for (const drop of pool) {
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
let line = "";
let rarity = "C";
switch (drop.type as any) {
case LootType.CURRENCY:
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} - ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
line = `**${currAmount} 🪙** (${chance}%)`;
rarity = "CURRENCY";
break;
case LootType.XP:
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} - ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
line = `**${xpAmount} XP** (${chance}%)`;
rarity = "XP";
break;
case LootType.ITEM:
const referencedItems = context?.referencedItems;
if (drop.itemId && referencedItems?.has(drop.itemId)) {
const i = referencedItems.get(drop.itemId)!;
line = `**${i.name}** x${drop.amount || 1} (${chance}%)`;
rarity = i.rarity;
} else {
line = `**Unknown Item** (${chance}%)`;
rarity = "C";
}
break;
case LootType.NOTHING:
line = `**Nothing** (${chance}%)`;
rarity = "NOTHING";
break;
}
if (line) {
if (!groups[rarity]) groups[rarity] = [];
groups[rarity]!.push(line);
}
}
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
for (const rarity of order) {
if (groups[rarity] && groups[rarity]!.length > 0) {
mainContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
);
}
}
}
// Purchase Row
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setLabel(`Buy for ${item.price} 🪙`)
.setLabel(`Purchase for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
mainContainer.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
);
return { embeds: [embed], components: [row] };
containers.push(mainContainer);
return {
components: containers as any,
files,
flags: MessageFlags.IsComponentsV2
};
}
function defaultName(path: string): string {
return path.split("/").pop() || "image.png";
}

View File

@@ -1,7 +1,7 @@
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { economyService } from "@shared/modules/economy/economy.service";
import { userTimers } from "@db/schema";
import type { EffectHandler } from "./types";
import type { EffectHandler } from "./effect.types";
import type { LootTableItem } from "@shared/lib/types";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { inventory, items } from "@db/schema";
@@ -86,7 +86,11 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
// Process Winner
if (winner.type === LootType.NOTHING) {
return winner.message || "You found nothing inside.";
return {
type: 'LOOTBOX_RESULT',
rewardType: 'NOTHING',
message: winner.message || "You found nothing inside."
};
}
if (winner.type === LootType.CURRENCY) {
@@ -96,7 +100,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
}
if (amount > 0) {
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
return winner.message || `You found ${amount} 🪙!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'CURRENCY',
amount: amount,
message: winner.message || `You found ${amount} 🪙!`
};
}
}
@@ -107,7 +116,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
}
if (amount > 0) {
await levelingService.addXp(userId, BigInt(amount), txFn);
return winner.message || `You gained ${amount} XP!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'XP',
amount: amount,
message: winner.message || `You gained ${amount} XP!`
};
}
}
@@ -123,7 +137,18 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
});
if (item) {
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'ITEM',
amount: Number(quantity),
item: {
name: item.name,
rarity: item.rarity,
description: item.description,
image: item.imageUrl || item.iconUrl
},
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
};
}
} catch (e) {
console.error("Failed to fetch item name for lootbox message", e);

View File

@@ -6,8 +6,8 @@ import {
handleTempRole,
handleColorRole,
handleLootbox
} from "./handlers";
import type { EffectHandler } from "./types";
} from "./effect.handlers";
import type { EffectHandler } from "./effect.types";
export const effectHandlers: Record<string, EffectHandler> = {
'ADD_XP': handleAddXp,

View File

@@ -1,4 +1,3 @@
import type { Transaction } from "@shared/lib/types";
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;

View File

@@ -1,6 +1,9 @@
import { EmbedBuilder } from "discord.js";
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
import type { ItemUsageData } from "@shared/lib/types";
import { EffectType } from "@shared/lib/constants";
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
import { join } from "path";
import { existsSync } from "fs";
/**
* Inventory entry with item details
@@ -31,24 +34,107 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
/**
* Creates an embed showing the results of using an item
*/
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
const description = results.map(r => `${r}`).join("\n");
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
const embed = new EmbedBuilder();
const files: AttachmentBuilder[] = [];
const otherMessages: string[] = [];
let lootResult: any = null;
// Check if it was a lootbox
for (const res of results) {
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
lootResult = res;
} else {
otherMessages.push(typeof res === 'string' ? `${res}` : `${JSON.stringify(res)}`);
}
}
// Default Configuration
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
embed.setTimestamp();
const embed = new EmbedBuilder()
.setDescription(description)
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
if (lootResult) {
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
if (isLootbox && item) {
embed.setTitle(`🎁 ${item.name} Opened!`);
if (item.iconUrl) {
embed.setThumbnail(item.iconUrl);
if (lootResult.rewardType === 'ITEM' && lootResult.item) {
const i = lootResult.item;
const amountStr = lootResult.amount > 1 ? `x${lootResult.amount}` : '';
// Rarity Colors
const rarityColors: Record<string, number> = {
'C': 0x95A5A6, // Gray
'R': 0x3498DB, // Blue
'SR': 0x9B59B6, // Purple
'SSR': 0xF1C40F // Gold
};
const rarityKey = i.rarity || 'C';
if (rarityKey in rarityColors) {
embed.setColor(rarityColors[rarityKey] ?? 0x95A5A6);
} else {
embed.setColor(0x95A5A6);
}
if (i.image) {
if (isLocalAssetUrl(i.image)) {
const imagePath = join(process.cwd(), "bot/assets/graphics", i.image.replace(/^\/?assets\//, ""));
if (existsSync(imagePath)) {
const imageName = defaultName(i.image);
if (!files.find(f => f.name === imageName)) {
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
}
embed.setImage(`attachment://${imageName}`);
}
} else {
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
const imgUrl = resolveAssetUrl(i.image);
if (imgUrl) embed.setImage(imgUrl);
}
}
return embed;
embed.setDescription(`**You found ${i.name} ${amountStr}!**\n${i.description || '_'}`);
embed.addFields({ name: 'Rarity', value: rarityKey, inline: true });
} else if (lootResult.rewardType === 'CURRENCY') {
embed.setColor(0xF1C40F);
embed.setDescription(`**You found ${lootResult.amount.toLocaleString()} 🪙 AU!**`);
} else if (lootResult.rewardType === 'XP') {
embed.setColor(0x2ECC71); // Green
embed.setDescription(`**You gained ${lootResult.amount.toLocaleString()} XP!**`);
} else {
// Nothing or Message
embed.setDescription(lootResult.message);
embed.setColor(0x95A5A6); // Gray
}
} else {
// Standard item usage
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
if (isLootbox && item && item.iconUrl) {
if (isLocalAssetUrl(item.iconUrl)) {
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
if (existsSync(iconPath)) {
const iconName = defaultName(item.iconUrl);
if (!files.find(f => f.name === iconName)) {
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
}
embed.setThumbnail(`attachment://${iconName}`);
}
} else {
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
}
}
}
if (otherMessages.length > 0 && lootResult) {
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
}
return { embed, files };
}
function defaultName(path: string): string {
return path.split("/").pop() || "image.png";
}

View File

@@ -5,19 +5,19 @@
"": {
"name": "app",
"dependencies": {
"@napi-rs/canvas": "^0.1.84",
"@napi-rs/canvas": "^0.1.89",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"zod": "^4.1.13",
"postgres": "^3.4.8",
"zod": "^4.3.6",
},
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.31.7",
"drizzle-kit": "^0.31.8",
},
"peerDependencies": {
"typescript": "^5",
"typescript": "^5.9.3",
},
},
},

View File

@@ -0,0 +1,10 @@
services:
db:
volumes:
# Override the bind mount with a named volume
# Docker handles permissions automatically for named volumes
- db_data:/var/lib/postgresql/data
volumes:
db_data:
name: aurora_db_data

View File

@@ -73,6 +73,38 @@ services:
max-size: "10m"
max-file: "3"
studio:
container_name: aurora_studio
image: aurora-app:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:4983:4983"
environment:
- NODE_ENV=production
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- DB_PORT=5432
- DB_HOST=db
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
networks:
- internal
- web
command: bun run db:studio
healthcheck:
test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 512M
networks:
internal:
driver: bridge

View File

@@ -36,7 +36,6 @@ services:
- .:/app
# Use named volumes for node_modules (prevents host overwrite + caches deps)
- app_node_modules:/app/node_modules
- web_node_modules:/app/web/node_modules
environment:
- HOST=0.0.0.0
- DB_USER=${DB_USER}
@@ -92,5 +91,3 @@ volumes:
# Named volumes for node_modules caching
app_node_modules:
name: aurora_app_node_modules
web_node_modules:
name: aurora_web_node_modules

492
docs/api.md Normal file
View File

@@ -0,0 +1,492 @@
# Aurora API Reference
REST API server for Aurora bot management. Base URL: `http://localhost:3000`
## Common Response Formats
**Success Responses:**
- Single resource: `{ ...resource }` or `{ success: true, resource: {...} }`
- List operations: `{ items: [...], total: number }`
- Mutations: `{ success: true, resource: {...} }`
**Error Responses:**
```json
{
"error": "Brief error message",
"details": "Optional detailed error information"
}
```
**HTTP Status Codes:**
| Code | Description |
|------|-------------|
| 200 | Success |
| 201 | Created |
| 204 | No Content (successful DELETE) |
| 400 | Bad Request (validation error) |
| 404 | Not Found |
| 409 | Conflict (e.g., duplicate name) |
| 429 | Too Many Requests |
| 500 | Internal Server Error |
---
## Health
### `GET /api/health`
Returns server health status.
**Response:** `{ "status": "ok", "timestamp": 1234567890 }`
---
## Items
### `GET /api/items`
List all items with optional filtering.
| Query Param | Type | Description |
|-------------|------|-------------|
| `search` | string | Filter by name/description |
| `type` | string | Filter by item type |
| `rarity` | string | Filter by rarity (C, R, SR, SSR) |
| `limit` | number | Max results (default: 100) |
| `offset` | number | Pagination offset |
**Response:** `{ "items": [...], "total": number }`
### `GET /api/items/:id`
Get single item by ID.
**Response:**
```json
{
"id": 1,
"name": "Health Potion",
"description": "Restores HP",
"type": "CONSUMABLE",
"rarity": "C",
"price": "100",
"iconUrl": "/assets/items/1.png",
"imageUrl": "/assets/items/1.png",
"usageData": { "consume": true, "effects": [] }
}
```
### `POST /api/items`
Create new item. Supports JSON or multipart/form-data with image.
**Body (JSON):**
```json
{
"name": "Health Potion",
"description": "Restores HP",
"type": "CONSUMABLE",
"rarity": "C",
"price": "100",
"iconUrl": "/assets/items/placeholder.png",
"imageUrl": "/assets/items/placeholder.png",
"usageData": { "consume": true, "effects": [] }
}
```
**Body (Multipart):**
- `data`: JSON string with item fields
- `image`: Image file (PNG, JPEG, WebP, GIF, max 15MB)
### `PUT /api/items/:id`
Update existing item.
### `DELETE /api/items/:id`
Delete item and associated asset.
### `POST /api/items/:id/icon`
Upload/replace item image. Accepts multipart/form-data with `image` field.
---
## Users
### `GET /api/users`
List all users with optional filtering and sorting.
| Query Param | Type | Description |
|-------------|------|-------------|
| `search` | string | Filter by username (partial match) |
| `sortBy` | string | Sort field: `balance`, `level`, `xp`, `username` (default: `balance`) |
| `sortOrder` | string | Sort order: `asc`, `desc` (default: `desc`) |
| `limit` | number | Max results (default: 50) |
| `offset` | number | Pagination offset |
**Response:** `{ "users": [...], "total": number }`
### `GET /api/users/:id`
Get single user by Discord ID.
**Response:**
```json
{
"id": "123456789012345678",
"username": "Player1",
"balance": "1000",
"xp": "500",
"level": 5,
"dailyStreak": 3,
"isActive": true,
"classId": "1",
"class": { "id": "1", "name": "Warrior", "balance": "5000" },
"settings": {},
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-15T12:00:00Z"
}
```
### `PUT /api/users/:id`
Update user fields.
**Body:**
```json
{
"username": "NewName",
"balance": "2000",
"xp": "750",
"level": 10,
"dailyStreak": 5,
"classId": "1",
"isActive": true,
"settings": {}
}
```
### `GET /api/users/:id/inventory`
Get user's inventory with item details.
**Response:**
```json
{
"inventory": [
{
"userId": "123456789012345678",
"itemId": 1,
"quantity": "5",
"item": { "id": 1, "name": "Health Potion", ... }
}
]
}
```
### `POST /api/users/:id/inventory`
Add item to user inventory.
**Body:**
```json
{
"itemId": 1,
"quantity": "5"
}
```
### `DELETE /api/users/:id/inventory/:itemId`
Remove item from user inventory. Use query param `amount` to specify quantity (default: 1).
| Query Param | Type | Description |
|-------------|------|-------------|
| `amount` | number | Amount to remove (default: 1) |
```
---
## Classes
### `GET /api/classes`
List all classes.
**Response:**
```json
{
"classes": [
{ "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
]
}
```
### `POST /api/classes`
Create new class.
**Body:**
```json
{
"id": "2",
"name": "Mage",
"balance": "0",
"roleId": "987654321"
}
```
### `PUT /api/classes/:id`
Update class.
**Body:**
```json
{
"name": "Updated Name",
"balance": "10000",
"roleId": "111222333"
}
```
### `DELETE /api/classes/:id`
Delete class.
---
## Moderation
### `GET /api/moderation`
List moderation cases with optional filtering.
| Query Param | Type | Description |
|-------------|------|-------------|
| `userId` | string | Filter by target user ID |
| `moderatorId` | string | Filter by moderator ID |
| `type` | string | Filter by case type: `warn`, `timeout`, `kick`, `ban`, `note`, `prune` |
| `active` | boolean | Filter by active status |
| `limit` | number | Max results (default: 50) |
| `offset` | number | Pagination offset |
**Response:**
```json
{
"cases": [
{
"id": "1",
"caseId": "CASE-0001",
"type": "warn",
"userId": "123456789",
"username": "User1",
"moderatorId": "987654321",
"moderatorName": "Mod1",
"reason": "Spam",
"metadata": {},
"active": true,
"createdAt": "2024-01-15T12:00:00Z",
"resolvedAt": null,
"resolvedBy": null,
"resolvedReason": null
}
]
}
```
### `GET /api/moderation/:caseId`
Get single case by case ID (e.g., `CASE-0001`).
### `POST /api/moderation`
Create new moderation case.
**Body:**
```json
{
"type": "warn",
"userId": "123456789",
"username": "User1",
"moderatorId": "987654321",
"moderatorName": "Mod1",
"reason": "Rule violation",
"metadata": { "duration": "24h" }
}
```
### `PUT /api/moderation/:caseId/clear`
Clear/resolve a moderation case.
**Body:**
```json
{
"clearedBy": "987654321",
"clearedByName": "Mod1",
"reason": "Appeal accepted"
}
```
---
## Transactions
### `GET /api/transactions`
List economy transactions.
| Query Param | Type | Description |
|-------------|------|-------------|
| `userId` | string | Filter by user ID |
| `type` | string | Filter by transaction type |
| `limit` | number | Max results (default: 50) |
| `offset` | number | Pagination offset |
**Response:**
```json
{
"transactions": [
{
"id": "1",
"userId": "123456789",
"relatedUserId": null,
"amount": "100",
"type": "DAILY_REWARD",
"description": "Daily reward (Streak: 3)",
"createdAt": "2024-01-15T12:00:00Z"
}
]
}
```
**Transaction Types:**
- `DAILY_REWARD` - Daily claim reward
- `TRANSFER_IN` - Received from another user
- `TRANSFER_OUT` - Sent to another user
- `LOOTDROP_CLAIM` - Claimed lootdrop
- `SHOP_BUY` - Item purchase
- `QUEST_REWARD` - Quest completion reward
---
---
## Lootdrops
### `GET /api/lootdrops`
List lootdrops (default limit 50, sorted by newest).
| Query Param | Type | Description |
|-------------|------|-------------|
| `limit` | number | Max results (default: 50) |
**Response:** `{ "lootdrops": [...] }`
### `POST /api/lootdrops`
Spawn a lootdrop in a channel.
**Body:**
```json
{
"channelId": "1234567890",
"amount": 100,
"currency": "Gold"
}
```
### `DELETE /api/lootdrops/:messageId`
Cancel and delete a lootdrop.
---
## Quests
### `GET /api/quests`
List all quests.
**Response:**
```json
{
"success": true,
"data": [
{
"id": 1,
"name": "Daily Login",
"description": "Login once",
"triggerEvent": "login",
"requirements": { "target": 1 },
"rewards": { "xp": 50, "balance": 100 }
}
]
}
```
### `POST /api/quests`
Create new quest.
**Body:**
```json
{
"name": "Daily Login",
"description": "Login once",
"triggerEvent": "login",
"target": 1,
"xpReward": 50,
"balanceReward": 100
}
```
### `PUT /api/quests/:id`
Update quest.
### `DELETE /api/quests/:id`
Delete quest.
---
## Settings
### `GET /api/settings`
Get current bot configuration.
### `POST /api/settings`
Update configuration (partial merge supported).
### `GET /api/settings/meta`
Get Discord metadata (roles, channels, commands).
**Response:**
```json
{
"roles": [{ "id": "123", "name": "Admin", "color": "#FF0000" }],
"channels": [{ "id": "456", "name": "general", "type": 0 }],
"commands": [{ "name": "daily", "category": "economy" }]
}
```
---
## Admin Actions
### `POST /api/actions/reload-commands`
Reload bot slash commands.
### `POST /api/actions/clear-cache`
Clear internal caches.
### `POST /api/actions/maintenance-mode`
Toggle maintenance mode.
**Body:** `{ "enabled": true, "reason": "Updating..." }`
---
## Stats
### `GET /api/stats`
Get full dashboard statistics.
### `GET /api/stats/activity`
Get activity aggregation (cached 5 min).
---
## Assets
### `GET /assets/items/:filename`
Serve item images. Cached 24 hours.
---
## WebSocket
### `ws://localhost:3000/ws`
Real-time dashboard updates.
**Messages:**
- `STATS_UPDATE` - Periodic stats broadcast (every 5s when clients connected)
- `NEW_EVENT` - Real-time system events
- `PING/PONG` - Heartbeat
**Limits:** Max 10 concurrent connections, 16KB max payload, 60s idle timeout.

View File

@@ -4,7 +4,7 @@ A comprehensive, feature-rich Discord RPG bot built with modern technologies usi
## Architecture Overview
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and web dashboard in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and REST API in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
## Monorepo Structure
@@ -15,12 +15,8 @@ aurora-bot-discord/
│ ├── events/ # Discord event handlers
│ ├── lib/ # Bot core logic (BotClient, utilities)
│ └── index.ts # Bot entry point
├── web/ # React web dashboard
── src/ # React components and pages
│ │ ├── pages/ # Dashboard pages (Admin, Settings, Home)
│ │ ├── components/ # Reusable UI components
│ │ └── server.ts # Web server with API endpoints
│ └── build.ts # Vite build configuration
├── web/ # REST API server
── src/routes/ # API route handlers
├── shared/ # Shared code between bot and web
│ ├── db/ # Database schema and Drizzle ORM
│ ├── lib/ # Utilities, config, logger, events
@@ -52,28 +48,26 @@ The bot is built with Discord.js v14 and handles all Discord-related functionali
- `ready.ts`: Bot ready events
- `guildMemberAdd.ts`: New member handling
### 2. Web Dashboard (`web/`)
### 2. REST API (`web/`)
A React 19 + Bun web application for bot administration and monitoring.
A headless REST API built with Bun's native HTTP server for bot administration and data access.
**Key Pages:**
**Key Endpoints:**
- **Home** (`/`): Dashboard overview with live statistics
- **Admin Overview** (`/admin/overview`): Real-time bot metrics
- **Admin Quests** (`/admin/quests`): Quest management interface
- **Settings** (`/settings/*`): Configuration pages for:
- General settings
- Economy settings
- Systems settings
- Roles settings
- **Stats** (`/api/stats`): Real-time bot metrics and statistics
- **Settings** (`/api/settings`): Configuration management endpoints
- **Users** (`/api/users`): User data and profiles
- **Items** (`/api/items`): Item catalog and management
- **Quests** (`/api/quests`): Quest data and progress
- **Economy** (`/api/transactions`): Economy and transaction data
**Web Server Features:**
**API Features:**
- Built with Bun's native HTTP server
- WebSocket support for real-time updates
- REST API endpoints for dashboard data
- SPA fallback for client-side routing
- Bun dev server with hot module replacement
- WebSocket support for real-time updates (`/ws`)
- REST API endpoints for all bot data
- Real-time event streaming via WebSocket
- Zod validation for all requests
### 3. Shared Core (`shared/`)
@@ -123,15 +117,15 @@ Shared code accessible by both bot and web applications.
### For Server Administrators
1. **Bot Configuration**: Adjust economy rates, enable/disable features via dashboard
1. **Bot Configuration**: Adjust economy rates, enable/disable features via API
2. **Moderation Tools**:
- Warn, note, and track moderation cases
- Mass prune inactive members
- Role management
3. **Quest Management**: Create and manage server-specific quests
4. **Monitoring**:
- Real-time dashboard with live statistics
- Activity charts and event logs
- Real-time statistics via REST API
- Activity data and event logs
- Economy leaderboards
### For Developers
@@ -148,10 +142,10 @@ Shared code accessible by both bot and web applications.
| ---------------- | --------------------------------- |
| Runtime | Bun 1.0+ |
| Bot Framework | Discord.js 14.x |
| Web Framework | React 19 + Bun |
| Web Framework | Bun HTTP Server (REST API) |
| Database | PostgreSQL 17 |
| ORM | Drizzle ORM |
| Styling | Tailwind CSS v4 + ShadCN/Radix UI |
| UI | Discord embeds and components |
| Validation | Zod |
| Containerization | Docker |
@@ -165,4 +159,4 @@ bun run migrate
docker compose up
```
The bot and dashboard process run on port 3000 and are accessible at `http://localhost:3000`.
The bot and API server run on port 3000 and are accessible at `http://localhost:3000`.

View File

@@ -1,6 +1,6 @@
{
"name": "app",
"version": "1.1.3",
"version": "1.1.4-pre",
"module": "bot/index.ts",
"type": "module",
"private": true,

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env bun
/**
* Item Asset Migration Script
*
* Downloads images from existing Discord CDN URLs and saves them locally.
* Updates database records to use local asset paths.
*
* Usage:
* bun run scripts/migrate-item-assets.ts # Dry run (no changes)
* bun run scripts/migrate-item-assets.ts --execute # Actually perform migration
*/
import { resolve, join } from "path";
import { mkdir } from "node:fs/promises";
// Initialize database connection
const { DrizzleClient } = await import("../shared/db/DrizzleClient");
const { items } = await import("../shared/db/schema");
const ASSETS_DIR = resolve(import.meta.dir, "../bot/assets/graphics/items");
const DRY_RUN = !process.argv.includes("--execute");
interface MigrationResult {
itemId: number;
itemName: string;
originalUrl: string;
newPath: string;
status: "success" | "skipped" | "failed";
error?: string;
}
/**
* Check if a URL is an external URL (not a local asset path)
*/
function isExternalUrl(url: string | null): boolean {
if (!url) return false;
return url.startsWith("http://") || url.startsWith("https://");
}
/**
* Check if a URL is likely a Discord CDN URL
*/
function isDiscordCdnUrl(url: string): boolean {
return url.includes("cdn.discordapp.com") ||
url.includes("media.discordapp.net") ||
url.includes("discord.gg");
}
/**
* Download an image from a URL and save it locally
*/
async function downloadImage(url: string, destPath: string): Promise<void> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.startsWith("image/")) {
throw new Error(`Invalid content type: ${contentType}`);
}
const buffer = await response.arrayBuffer();
await Bun.write(destPath, buffer);
}
/**
* Migrate a single item's images
*/
async function migrateItem(item: {
id: number;
name: string;
iconUrl: string | null;
imageUrl: string | null;
}): Promise<MigrationResult> {
const result: MigrationResult = {
itemId: item.id,
itemName: item.name,
originalUrl: item.iconUrl || item.imageUrl || "",
newPath: `/assets/items/${item.id}.png`,
status: "skipped"
};
// Check if either URL needs migration
const hasExternalIcon = isExternalUrl(item.iconUrl);
const hasExternalImage = isExternalUrl(item.imageUrl);
if (!hasExternalIcon && !hasExternalImage) {
result.status = "skipped";
return result;
}
// Prefer iconUrl, fall back to imageUrl
const urlToDownload = item.iconUrl || item.imageUrl;
if (!urlToDownload || !isExternalUrl(urlToDownload)) {
result.status = "skipped";
return result;
}
result.originalUrl = urlToDownload;
const destPath = join(ASSETS_DIR, `${item.id}.png`);
if (DRY_RUN) {
console.log(` [DRY RUN] Would download: ${urlToDownload}`);
console.log(` -> ${destPath}`);
result.status = "success";
return result;
}
try {
// Download the image
await downloadImage(urlToDownload, destPath);
// Update database record
const { eq } = await import("drizzle-orm");
await DrizzleClient
.update(items)
.set({
iconUrl: `/assets/items/${item.id}.png`,
imageUrl: `/assets/items/${item.id}.png`,
})
.where(eq(items.id, item.id));
result.status = "success";
console.log(` ✅ Migrated: ${item.name} (ID: ${item.id})`);
} catch (error) {
result.status = "failed";
result.error = error instanceof Error ? error.message : String(error);
console.log(` ❌ Failed: ${item.name} (ID: ${item.id}) - ${result.error}`);
}
return result;
}
/**
* Main migration function
*/
async function main() {
console.log("═══════════════════════════════════════════════════════════════");
console.log(" Item Asset Migration Script");
console.log("═══════════════════════════════════════════════════════════════");
console.log();
if (DRY_RUN) {
console.log(" ⚠️ DRY RUN MODE - No changes will be made");
console.log(" Run with --execute to perform actual migration");
console.log();
}
// Ensure assets directory exists
await mkdir(ASSETS_DIR, { recursive: true });
console.log(` 📁 Assets directory: ${ASSETS_DIR}`);
console.log();
// Fetch all items
const allItems = await DrizzleClient.select({
id: items.id,
name: items.name,
iconUrl: items.iconUrl,
imageUrl: items.imageUrl,
}).from(items);
console.log(` 📦 Found ${allItems.length} total items`);
// Filter items that need migration
const itemsToMigrate = allItems.filter(item =>
isExternalUrl(item.iconUrl) || isExternalUrl(item.imageUrl)
);
console.log(` 🔄 ${itemsToMigrate.length} items have external URLs`);
console.log();
if (itemsToMigrate.length === 0) {
console.log(" ✨ No items need migration!");
return;
}
// Categorize by URL type
const discordCdnItems = itemsToMigrate.filter(item =>
isDiscordCdnUrl(item.iconUrl || "") || isDiscordCdnUrl(item.imageUrl || "")
);
const otherExternalItems = itemsToMigrate.filter(item =>
!isDiscordCdnUrl(item.iconUrl || "") && !isDiscordCdnUrl(item.imageUrl || "")
);
console.log(` 📊 Breakdown:`);
console.log(` - Discord CDN URLs: ${discordCdnItems.length}`);
console.log(` - Other external URLs: ${otherExternalItems.length}`);
console.log();
// Process migrations
console.log(" Starting migration...");
console.log();
const results: MigrationResult[] = [];
for (const item of itemsToMigrate) {
const result = await migrateItem(item);
results.push(result);
}
// Summary
console.log();
console.log("═══════════════════════════════════════════════════════════════");
console.log(" Migration Summary");
console.log("═══════════════════════════════════════════════════════════════");
const successful = results.filter(r => r.status === "success").length;
const skipped = results.filter(r => r.status === "skipped").length;
const failed = results.filter(r => r.status === "failed").length;
console.log(` ✅ Successful: ${successful}`);
console.log(` ⏭️ Skipped: ${skipped}`);
console.log(` ❌ Failed: ${failed}`);
console.log();
if (failed > 0) {
console.log(" Failed items:");
for (const result of results.filter(r => r.status === "failed")) {
console.log(` - ${result.itemName}: ${result.error}`);
}
}
if (DRY_RUN) {
console.log();
console.log(" ⚠️ This was a dry run. Run with --execute to apply changes.");
}
// Exit with error code if any failures
process.exit(failed > 0 ? 1 : 0);
}
// Run
main().catch(error => {
console.error("Migration failed:", error);
process.exit(1);
});

View File

@@ -1,270 +1,3 @@
import {
pgTable,
bigint,
varchar,
boolean,
jsonb,
timestamp,
serial,
text,
integer,
primaryKey,
index,
bigserial,
check
} from 'drizzle-orm/pg-core';
import { relations, sql, type InferSelectModel } from 'drizzle-orm';
export type User = InferSelectModel<typeof users>;
export type Transaction = InferSelectModel<typeof transactions>;
export type ModerationCase = InferSelectModel<typeof moderationCases>;
export type Item = InferSelectModel<typeof items>;
export type Inventory = InferSelectModel<typeof inventory>;
// --- TABLES ---
// 1. Classes
export const classes = pgTable('classes', {
id: bigint('id', { mode: 'bigint' }).primaryKey(),
name: varchar('name', { length: 255 }).unique().notNull(),
balance: bigint('balance', { mode: 'bigint' }).default(0n),
roleId: varchar('role_id', { length: 255 }),
});
// 2. Users
export const users = pgTable('users', {
id: bigint('id', { mode: 'bigint' }).primaryKey(),
classId: bigint('class_id', { mode: 'bigint' }).references(() => classes.id),
username: varchar('username', { length: 255 }).unique().notNull(),
isActive: boolean('is_active').default(true),
// Economy
balance: bigint('balance', { mode: 'bigint' }).default(0n),
xp: bigint('xp', { mode: 'bigint' }).default(0n),
level: integer('level').default(1),
dailyStreak: integer('daily_streak').default(0),
// Metadata
settings: jsonb('settings').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (table) => [
index('users_username_idx').on(table.username),
index('users_balance_idx').on(table.balance),
index('users_level_xp_idx').on(table.level, table.xp),
]);
// 3. Items
export const items = pgTable('items', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).unique().notNull(),
description: text('description'),
rarity: varchar('rarity', { length: 20 }).default('Common'),
// Economy & Visuals
type: varchar('type', { length: 50 }).notNull().default('MATERIAL'),
// Examples: 'CONSUMABLE', 'EQUIPMENT', 'MATERIAL'
usageData: jsonb('usage_data').default({}),
price: bigint('price', { mode: 'bigint' }),
iconUrl: text('icon_url').notNull(),
imageUrl: text('image_url').notNull(),
});
// 4. Inventory (Join Table)
export const inventory = pgTable('inventory', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
itemId: integer('item_id')
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
quantity: bigint('quantity', { mode: 'bigint' }).default(1n),
}, (table) => [
primaryKey({ columns: [table.userId, table.itemId] }),
check('quantity_check', sql`${table.quantity} > 0`)
]);
// 5. Transactions
export const transactions = pgTable('transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }),
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'set null' }),
amount: bigint('amount', { mode: 'bigint' }).notNull(),
type: varchar('type', { length: 50 }).notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => [
index('transactions_created_at_idx').on(table.createdAt),
]);
export const itemTransactions = pgTable('item_transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'set null' }), // who they got it from/gave it to
itemId: integer('item_id')
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
quantity: bigint('quantity', { mode: 'bigint' }).notNull(), // positive = gain, negative = loss
type: varchar('type', { length: 50 }).notNull(), // e.g., 'TRADE', 'SHOP_BUY', 'DROP'
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
// 6. Quests
export const quests = pgTable('quests', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
triggerEvent: varchar('trigger_event', { length: 50 }).notNull(),
requirements: jsonb('requirements').notNull().default({}),
rewards: jsonb('rewards').notNull().default({}),
});
// 7. User Quests (Join Table)
export const userQuests = pgTable('user_quests', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
questId: integer('quest_id')
.references(() => quests.id, { onDelete: 'cascade' }).notNull(),
progress: integer('progress').default(0),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => [
primaryKey({ columns: [table.userId, table.questId] })
]);
// 8. User Timers (Generic: Cooldowns, Effects, Access)
export const userTimers = pgTable('user_timers', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
type: varchar('type', { length: 50 }).notNull(), // 'COOLDOWN', 'EFFECT', 'ACCESS'
key: varchar('key', { length: 100 }).notNull(), // 'daily', 'chn_12345', 'xp_boost'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
}, (table) => [
primaryKey({ columns: [table.userId, table.type, table.key] }),
index('user_timers_expires_at_idx').on(table.expiresAt),
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
]);
// 9. Lootdrops
export const lootdrops = pgTable('lootdrops', {
messageId: varchar('message_id', { length: 255 }).primaryKey(),
channelId: varchar('channel_id', { length: 255 }).notNull(),
rewardAmount: integer('reward_amount').notNull(),
currency: varchar('currency', { length: 50 }).notNull(),
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
});
// 10. Moderation Cases
export const moderationCases = pgTable('moderation_cases', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
username: varchar('username', { length: 255 }).notNull(),
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
reason: text('reason').notNull(),
metadata: jsonb('metadata').default({}),
active: boolean('active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
resolvedReason: text('resolved_reason'),
}, (table) => [
index('moderation_cases_user_id_idx').on(table.userId),
index('moderation_cases_case_id_idx').on(table.caseId),
]);
export const classesRelations = relations(classes, ({ many }) => ({
users: many(users),
}));
export const usersRelations = relations(users, ({ one, many }) => ({
class: one(classes, {
fields: [users.classId],
references: [classes.id],
}),
inventory: many(inventory),
transactions: many(transactions),
quests: many(userQuests),
timers: many(userTimers),
}));
export const itemsRelations = relations(items, ({ many }) => ({
inventoryEntries: many(inventory),
}));
export const inventoryRelations = relations(inventory, ({ one }) => ({
user: one(users, {
fields: [inventory.userId],
references: [users.id],
}),
item: one(items, {
fields: [inventory.itemId],
references: [items.id],
}),
}));
export const transactionsRelations = relations(transactions, ({ one }) => ({
user: one(users, {
fields: [transactions.userId],
references: [users.id],
}),
}));
export const questsRelations = relations(quests, ({ many }) => ({
userEntries: many(userQuests),
}));
export const userQuestsRelations = relations(userQuests, ({ one }) => ({
user: one(users, {
fields: [userQuests.userId],
references: [users.id],
}),
quest: one(quests, {
fields: [userQuests.questId],
references: [quests.id],
}),
}));
export const userTimersRelations = relations(userTimers, ({ one }) => ({
user: one(users, {
fields: [userTimers.userId],
references: [users.id],
}),
}));
export const itemTransactionsRelations = relations(itemTransactions, ({ one }) => ({
user: one(users, {
fields: [itemTransactions.userId],
references: [users.id],
}),
relatedUser: one(users, {
fields: [itemTransactions.relatedUserId],
references: [users.id],
}),
item: one(items, {
fields: [itemTransactions.itemId],
references: [items.id],
}),
}));
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
user: one(users, {
fields: [moderationCases.userId],
references: [users.id],
}),
moderator: one(users, {
fields: [moderationCases.moderatorId],
references: [users.id],
}),
resolver: one(users, {
fields: [moderationCases.resolvedBy],
references: [users.id],
}),
}));
// Re-export all schema definitions from domain modules
// This file is kept for backward compatibility
export * from './schema/index';

View File

@@ -0,0 +1,69 @@
import {
pgTable,
bigint,
varchar,
text,
timestamp,
bigserial,
index,
integer,
} from 'drizzle-orm/pg-core';
import { relations, type InferSelectModel } from 'drizzle-orm';
import { users } from './users';
import { items } from './inventory';
// --- TYPES ---
export type Transaction = InferSelectModel<typeof transactions>;
export type ItemTransaction = InferSelectModel<typeof itemTransactions>;
// --- TABLES ---
export const transactions = pgTable('transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }),
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'set null' }),
amount: bigint('amount', { mode: 'bigint' }).notNull(),
type: varchar('type', { length: 50 }).notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => [
index('transactions_created_at_idx').on(table.createdAt),
]);
export const itemTransactions = pgTable('item_transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'set null' }),
itemId: integer('item_id')
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
quantity: bigint('quantity', { mode: 'bigint' }).notNull(),
type: varchar('type', { length: 50 }).notNull(), // e.g., 'TRADE', 'SHOP_BUY', 'DROP'
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
// --- RELATIONS ---
export const transactionsRelations = relations(transactions, ({ one }) => ({
user: one(users, {
fields: [transactions.userId],
references: [users.id],
}),
}));
export const itemTransactionsRelations = relations(itemTransactions, ({ one }) => ({
user: one(users, {
fields: [itemTransactions.userId],
references: [users.id],
}),
relatedUser: one(users, {
fields: [itemTransactions.relatedUserId],
references: [users.id],
}),
item: one(items, {
fields: [itemTransactions.itemId],
references: [items.id],
}),
}));

View File

@@ -0,0 +1,6 @@
// Domain modules
export * from './users';
export * from './inventory';
export * from './economy';
export * from './quests';
export * from './moderation';

View File

@@ -0,0 +1,57 @@
import {
pgTable,
bigint,
varchar,
serial,
text,
integer,
jsonb,
primaryKey,
check,
} from 'drizzle-orm/pg-core';
import { relations, sql, type InferSelectModel } from 'drizzle-orm';
import { users } from './users';
// --- TYPES ---
export type Item = InferSelectModel<typeof items>;
export type Inventory = InferSelectModel<typeof inventory>;
// --- TABLES ---
export const items = pgTable('items', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).unique().notNull(),
description: text('description'),
rarity: varchar('rarity', { length: 20 }).default('C'),
type: varchar('type', { length: 50 }).notNull().default('MATERIAL'),
usageData: jsonb('usage_data').default({}),
price: bigint('price', { mode: 'bigint' }),
iconUrl: text('icon_url').notNull(),
imageUrl: text('image_url').notNull(),
});
export const inventory = pgTable('inventory', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
itemId: integer('item_id')
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
quantity: bigint('quantity', { mode: 'bigint' }).default(1n),
}, (table) => [
primaryKey({ columns: [table.userId, table.itemId] }),
check('quantity_check', sql`${table.quantity} > 0`)
]);
// --- RELATIONS ---
export const itemsRelations = relations(items, ({ many }) => ({
inventoryEntries: many(inventory),
}));
export const inventoryRelations = relations(inventory, ({ one }) => ({
user: one(users, {
fields: [inventory.userId],
references: [users.id],
}),
item: one(items, {
fields: [inventory.itemId],
references: [items.id],
}),
}));

View File

@@ -0,0 +1,65 @@
import {
pgTable,
bigint,
varchar,
text,
jsonb,
timestamp,
boolean,
bigserial,
integer,
index,
} from 'drizzle-orm/pg-core';
import { relations, type InferSelectModel } from 'drizzle-orm';
import { users } from './users';
// --- TYPES ---
export type ModerationCase = InferSelectModel<typeof moderationCases>;
export type Lootdrop = InferSelectModel<typeof lootdrops>;
// --- TABLES ---
export const moderationCases = pgTable('moderation_cases', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
username: varchar('username', { length: 255 }).notNull(),
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
reason: text('reason').notNull(),
metadata: jsonb('metadata').default({}),
active: boolean('active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
resolvedReason: text('resolved_reason'),
}, (table) => [
index('moderation_cases_user_id_idx').on(table.userId),
index('moderation_cases_case_id_idx').on(table.caseId),
]);
export const lootdrops = pgTable('lootdrops', {
messageId: varchar('message_id', { length: 255 }).primaryKey(),
channelId: varchar('channel_id', { length: 255 }).notNull(),
rewardAmount: integer('reward_amount').notNull(),
currency: varchar('currency', { length: 50 }).notNull(),
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
});
// --- RELATIONS ---
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
user: one(users, {
fields: [moderationCases.userId],
references: [users.id],
}),
moderator: one(users, {
fields: [moderationCases.moderatorId],
references: [users.id],
}),
resolver: one(users, {
fields: [moderationCases.resolvedBy],
references: [users.id],
}),
}));

View File

@@ -0,0 +1,54 @@
import {
pgTable,
bigint,
varchar,
serial,
text,
jsonb,
timestamp,
integer,
primaryKey,
} from 'drizzle-orm/pg-core';
import { relations, type InferSelectModel } from 'drizzle-orm';
import { users } from './users';
// --- TYPES ---
export type Quest = InferSelectModel<typeof quests>;
export type UserQuest = InferSelectModel<typeof userQuests>;
// --- TABLES ---
export const quests = pgTable('quests', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
triggerEvent: varchar('trigger_event', { length: 50 }).notNull(),
requirements: jsonb('requirements').notNull().default({}),
rewards: jsonb('rewards').notNull().default({}),
});
export const userQuests = pgTable('user_quests', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
questId: integer('quest_id')
.references(() => quests.id, { onDelete: 'cascade' }).notNull(),
progress: integer('progress').default(0),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => [
primaryKey({ columns: [table.userId, table.questId] })
]);
// --- RELATIONS ---
export const questsRelations = relations(quests, ({ many }) => ({
userEntries: many(userQuests),
}));
export const userQuestsRelations = relations(userQuests, ({ one }) => ({
user: one(users, {
fields: [userQuests.userId],
references: [users.id],
}),
quest: one(quests, {
fields: [userQuests.questId],
references: [quests.id],
}),
}));

80
shared/db/schema/users.ts Normal file
View File

@@ -0,0 +1,80 @@
import {
pgTable,
bigint,
varchar,
boolean,
jsonb,
timestamp,
integer,
primaryKey,
index,
} from 'drizzle-orm/pg-core';
import { relations, type InferSelectModel } from 'drizzle-orm';
// --- TYPES ---
export type Class = InferSelectModel<typeof classes>;
export type User = InferSelectModel<typeof users>;
export type UserTimer = InferSelectModel<typeof userTimers>;
// --- TABLES ---
export const classes = pgTable('classes', {
id: bigint('id', { mode: 'bigint' }).primaryKey(),
name: varchar('name', { length: 255 }).unique().notNull(),
balance: bigint('balance', { mode: 'bigint' }).default(0n),
roleId: varchar('role_id', { length: 255 }),
});
export const users = pgTable('users', {
id: bigint('id', { mode: 'bigint' }).primaryKey(),
classId: bigint('class_id', { mode: 'bigint' }).references(() => classes.id),
username: varchar('username', { length: 255 }).unique().notNull(),
isActive: boolean('is_active').default(true),
// Economy
balance: bigint('balance', { mode: 'bigint' }).default(0n),
xp: bigint('xp', { mode: 'bigint' }).default(0n),
level: integer('level').default(1),
dailyStreak: integer('daily_streak').default(0),
// Metadata
settings: jsonb('settings').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (table) => [
index('users_username_idx').on(table.username),
index('users_balance_idx').on(table.balance),
index('users_level_xp_idx').on(table.level, table.xp),
]);
export const userTimers = pgTable('user_timers', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
type: varchar('type', { length: 50 }).notNull(), // 'COOLDOWN', 'EFFECT', 'ACCESS'
key: varchar('key', { length: 100 }).notNull(), // 'daily', 'chn_12345', 'xp_boost'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
metadata: jsonb('metadata').default({}),
}, (table) => [
primaryKey({ columns: [table.userId, table.type, table.key] }),
index('user_timers_expires_at_idx').on(table.expiresAt),
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
]);
// --- RELATIONS ---
export const classesRelations = relations(classes, ({ many }) => ({
users: many(users),
}));
export const usersRelations = relations(users, ({ one, many }) => ({
class: one(classes, {
fields: [users.classId],
references: [classes.id],
}),
timers: many(userTimers),
}));
export const userTimersRelations = relations(userTimers, ({ one }) => ({
user: one(users, {
fields: [userTimers.userId],
references: [users.id],
}),
}));

102
shared/lib/assets.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Asset URL Resolution Utilities
* Provides helpers for constructing full asset URLs from item IDs or relative paths.
* Works for both local development and production environments.
*/
import { env } from "./env";
/**
* Get the base URL for assets.
* In production, this should be the public dashboard URL.
* In development, it defaults to localhost with the configured port.
*/
function getAssetsBaseUrl(): string {
// Check for explicitly configured asset URL first
if (process.env.ASSETS_BASE_URL) {
return process.env.ASSETS_BASE_URL.replace(/\/$/, ""); // Remove trailing slash
}
// In production, construct from HOST/PORT or use a default
if (process.env.NODE_ENV === "production") {
// If WEB_URL is set, use it (future-proofing)
if (process.env.WEB_URL) {
return process.env.WEB_URL.replace(/\/$/, "");
}
// Fallback: use the configured host and port
const host = env.HOST === "0.0.0.0" ? "localhost" : env.HOST;
return `http://${host}:${env.PORT}`;
}
// Development: use localhost with the configured port
return `http://localhost:${env.PORT}`;
}
/**
* Get the full URL for an item's icon image.
* @param itemId - The item's database ID
* @returns Full URL to the item's icon image
*
* @example
* getItemIconUrl(42) // => "http://localhost:3000/assets/items/42.png"
*/
export function getItemIconUrl(itemId: number): string {
return `${getAssetsBaseUrl()}/assets/items/${itemId}.png`;
}
/**
* Get the full URL for any asset given its relative path.
* @param relativePath - Path relative to the assets root (e.g., "items/42.png")
* @returns Full URL to the asset
*
* @example
* getAssetUrl("items/42.png") // => "http://localhost:3000/assets/items/42.png"
*/
export function getAssetUrl(relativePath: string): string {
const cleanPath = relativePath.replace(/^\/+/, ""); // Remove leading slashes
return `${getAssetsBaseUrl()}/assets/${cleanPath}`;
}
/**
* Check if a URL is a local asset URL (relative path starting with /assets/).
* @param url - The URL to check
* @returns True if it's a local asset reference
*/
export function isLocalAssetUrl(url: string | null | undefined): boolean {
if (!url) return false;
return url.startsWith("/assets/") || url.startsWith("assets/");
}
/**
* Convert a relative asset path to a full URL.
* If the URL is already absolute (http/https), returns it unchanged.
* If it's a relative asset path, constructs the full URL.
*
* @param url - The URL to resolve (relative or absolute)
* @returns The resolved full URL, or null if input was null/undefined
*/
export function resolveAssetUrl(url: string | null | undefined): string | null {
if (!url) return null;
// Already absolute
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
// Relative asset path
if (isLocalAssetUrl(url)) {
const cleanPath = url.replace(/^\/+assets\//, "").replace(/^assets\//, "");
return getAssetUrl(cleanPath);
}
// Unknown format, return as-is
return url;
}
/**
* Get the placeholder image URL for items without an uploaded image.
* @returns Full URL to the placeholder image
*/
export function getPlaceholderIconUrl(): string {
return `${getAssetsBaseUrl()}/assets/items/placeholder.png`;
}

View File

@@ -8,6 +8,9 @@ const envSchema = z.object({
PORT: z.coerce.number().default(3000),
HOST: z.string().default("127.0.0.1"),
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters").optional(),
// Asset URL configuration (for production with custom domains)
ASSETS_BASE_URL: z.string().url().optional(),
WEB_URL: z.string().url().optional(),
});
const parsedEnv = envSchema.safeParse(process.env);

View File

@@ -93,11 +93,11 @@ class LootdropService {
}
}
private async spawnLootdrop(channel: TextChannel) {
public async spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = Math.floor(Math.random() * (max - min + 1)) + min;
const currency = config.lootdrop.reward.currency;
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
const currency = overrideCurrency ?? config.lootdrop.reward.currency;
const { content, files, components } = await getLootdropMessage(reward, currency);
@@ -205,6 +205,36 @@ class LootdropService {
this.channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
public async deleteLootdrop(messageId: string): Promise<boolean> {
try {
// First fetch it to get channel info so we can delete the message
const drop = await DrizzleClient.query.lootdrops.findFirst({
where: eq(lootdrops.messageId, messageId)
});
if (!drop) return false;
// Delete from DB
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
// Try to delete from Discord
try {
const { AuroraClient } = await import("../../../bot/lib/BotClient");
const channel = await AuroraClient.channels.fetch(drop.channelId) as TextChannel;
if (channel) {
const message = await channel.messages.fetch(messageId);
if (message) await message.delete();
}
} catch (e) {
console.warn("Could not delete lootdrop message from Discord:", e);
}
return true;
} catch (error) {
console.error("Error deleting lootdrop:", error);
return false;
}
}
}
export const lootdropService = new LootdropService();

View File

@@ -168,10 +168,10 @@ export const inventoryService = {
throw new UserError("This item cannot be used.");
}
const results: string[] = [];
const results: any[] = [];
// 2. Apply Effects
const { effectHandlers } = await import("@/modules/inventory/effects/registry");
const { effectHandlers } = await import("@/modules/inventory/effect.registry");
for (const effect of usageData.effects) {
const handler = effectHandlers[effect.type];

View File

@@ -0,0 +1,215 @@
/**
* Items Service
* Handles CRUD operations for game items.
* Used by both bot commands and web dashboard.
*/
import { items } from "@db/schema";
import { eq, ilike, and, or, count, sql } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@shared/lib/types";
import type { ItemType } from "@shared/lib/constants";
// --- DTOs ---
export interface CreateItemDTO {
name: string;
description?: string | null;
rarity?: 'C' | 'R' | 'SR' | 'SSR';
type: 'MATERIAL' | 'CONSUMABLE' | 'EQUIPMENT' | 'QUEST';
price?: bigint | null;
iconUrl: string;
imageUrl: string;
usageData?: ItemUsageData | null;
}
export interface UpdateItemDTO {
name?: string;
description?: string | null;
rarity?: 'C' | 'R' | 'SR' | 'SSR';
type?: 'MATERIAL' | 'CONSUMABLE' | 'EQUIPMENT' | 'QUEST';
price?: bigint | null;
iconUrl?: string;
imageUrl?: string;
usageData?: ItemUsageData | null;
}
export interface ItemFilters {
search?: string;
type?: string;
rarity?: string;
limit?: number;
offset?: number;
}
// --- Service ---
export const itemsService = {
/**
* Get all items with optional filtering and pagination.
*/
async getAllItems(filters: ItemFilters = {}) {
const { search, type, rarity, limit = 100, offset = 0 } = filters;
// Build conditions array
const conditions = [];
if (search) {
conditions.push(
or(
ilike(items.name, `%${search}%`),
ilike(items.description, `%${search}%`)
)
);
}
if (type) {
conditions.push(eq(items.type, type));
}
if (rarity) {
conditions.push(eq(items.rarity, rarity));
}
// Execute query with conditions
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [itemsList, totalResult] = await Promise.all([
DrizzleClient
.select()
.from(items)
.where(whereClause)
.limit(limit)
.offset(offset)
.orderBy(items.id),
DrizzleClient
.select({ count: count() })
.from(items)
.where(whereClause)
]);
return {
items: itemsList,
total: totalResult[0]?.count ?? 0
};
},
/**
* Get a single item by ID.
*/
async getItemById(id: number) {
return await DrizzleClient.query.items.findFirst({
where: eq(items.id, id)
});
},
/**
* Get item by name (for uniqueness checks).
*/
async getItemByName(name: string) {
return await DrizzleClient.query.items.findFirst({
where: eq(items.name, name)
});
},
/**
* Create a new item.
*/
async createItem(data: CreateItemDTO, tx?: Transaction) {
return await withTransaction(async (txFn) => {
const [item] = await txFn.insert(items)
.values({
name: data.name,
description: data.description ?? null,
rarity: data.rarity ?? 'C',
type: data.type,
price: data.price ?? null,
iconUrl: data.iconUrl,
imageUrl: data.imageUrl,
usageData: data.usageData ?? {},
})
.returning();
return item;
}, tx);
},
/**
* Update an existing item.
*/
async updateItem(id: number, data: UpdateItemDTO, tx?: Transaction) {
return await withTransaction(async (txFn) => {
// Build update object dynamically to support partial updates
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.rarity !== undefined) updateData.rarity = data.rarity;
if (data.type !== undefined) updateData.type = data.type;
if (data.price !== undefined) updateData.price = data.price;
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
if (data.usageData !== undefined) updateData.usageData = data.usageData;
if (Object.keys(updateData).length === 0) {
// Nothing to update, just return the existing item
return await txFn.query.items.findFirst({
where: eq(items.id, id)
});
}
const [updatedItem] = await txFn
.update(items)
.set(updateData)
.where(eq(items.id, id))
.returning();
return updatedItem;
}, tx);
},
/**
* Delete an item by ID.
*/
async deleteItem(id: number, tx?: Transaction) {
return await withTransaction(async (txFn) => {
const [deletedItem] = await txFn
.delete(items)
.where(eq(items.id, id))
.returning();
return deletedItem;
}, tx);
},
/**
* Check if an item name is already taken (for validation).
* Optionally exclude a specific ID (for updates).
*/
async isNameTaken(name: string, excludeId?: number) {
const existing = await DrizzleClient.query.items.findFirst({
where: excludeId
? and(eq(items.name, name), sql`${items.id} != ${excludeId}`)
: eq(items.name, name)
});
return !!existing;
},
/**
* Get items for autocomplete (search by name, limited results).
*/
async getItemsAutocomplete(query: string, limit: number = 25) {
return await DrizzleClient
.select({
id: items.id,
name: items.name,
rarity: items.rarity,
iconUrl: items.iconUrl,
})
.from(items)
.where(ilike(items.name, `%${query}%`))
.limit(limit);
}
};

View File

@@ -0,0 +1,23 @@
import { z } from "zod";
export const CreateQuestSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
triggerEvent: z.string().min(1),
target: z.coerce.number().min(1),
xpReward: z.coerce.number().min(0),
balanceReward: z.coerce.number().min(0),
});
export type CreateQuestInput = z.infer<typeof CreateQuestSchema>;
export const UpdateQuestSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional(),
triggerEvent: z.string().min(1).optional(),
target: z.coerce.number().min(1).optional(),
xpReward: z.coerce.number().min(0).optional(),
balanceReward: z.coerce.number().min(0).optional(),
});
export type UpdateQuestInput = z.infer<typeof UpdateQuestSchema>;

32
shared/scripts/deploy-remote.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -e
# Load environment variables
if [ -f .env ]; then
set -a
source .env
set +a
fi
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
echo "Error: VPS_HOST and VPS_USER must be set in .env"
echo "Please add them to your .env file:"
echo "VPS_USER=your-username"
echo "VPS_HOST=your-ip-address"
exit 1
fi
# Default remote directory to ~/Aurora if not specified
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
echo -e "\033[1;33m🚀 Deploying to $VPS_USER@$VPS_HOST:$REMOTE_DIR...\033[0m"
# Execute commands on remote server
ssh -t "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && \
echo '⬇️ Pulling latest changes...' && \
git pull && \
echo '🏗️ Building production containers...' && \
docker compose -f docker-compose.prod.yml build && \
echo '🚀 Starting services...' && \
docker compose -f docker-compose.prod.yml up -d && \
echo '✅ Deployment complete!'"

View File

@@ -50,9 +50,6 @@ else
SSH_CMD="ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
fi
# Open browser in background
open_browser &
# Start both tunnels
# -N means "Do not execute a remote command". -L is for local port forwarding.
$SSH_CMD -N \

View File

@@ -1,21 +1,30 @@
# Aurora Web
# Aurora Web API
To install dependencies:
The web API provides a REST interface and WebSocket support for accessing Aurora bot data and configuration.
## API Endpoints
- `GET /api/stats` - Real-time bot statistics
- `GET /api/settings` - Bot configuration
- `GET /api/users` - User data
- `GET /api/items` - Item catalog
- `GET /api/quests` - Quest information
- `GET /api/transactions` - Economy data
- `GET /api/health` - Health check
## WebSocket
Connect to `/ws` for real-time updates:
- Stats broadcasts every 5 seconds
- Event notifications via system bus
- PING/PONG heartbeat support
## Development
The API runs automatically when you start the bot:
```bash
bun install
bun run dev
```
To start a development server:
```bash
bun dev
```
To run for production:
```bash
bun start
```
This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
The API will be available at `http://localhost:3000`

View File

@@ -1,245 +0,0 @@
#!/usr/bin/env bun
import plugin from "bun-plugin-tailwind";
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
🏗️ Bun Build Script
Usage: bun run build.ts [options]
Common Options:
--outdir <path> Output directory (default: "dist")
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
--sourcemap <type> Sourcemap type: none|linked|inline|external
--target <target> Build target: browser|bun|node
--format <format> Output format: esm|cjs|iife
--splitting Enable code splitting
--packages <type> Package handling: bundle|external
--public-path <path> Public path for assets
--env <mode> Environment handling: inline|disable|prefix*
--conditions <list> Package.json export conditions (comma separated)
--external <list> External packages (comma separated)
--banner <text> Add banner text to output
--footer <text> Add footer text to output
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
--help, -h Show this help message
Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`);
process.exit(0);
}
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1]?.toUpperCase() || "");
const parseValue = (value: string): any => {
if (value === "true") return true;
if (value === "false") return false;
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
if (value.includes(",")) return value.split(",").map(v => v.trim());
return value;
};
function parseArgs(): Partial<Bun.BuildConfig> {
const config: Partial<Bun.BuildConfig> = {};
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) continue;
if (arg.startsWith("--no-")) {
const key = toCamelCase(arg.slice(5));
// @ts-ignore
config[key] = false;
continue;
}
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
const key = toCamelCase(arg.slice(2));
// @ts-ignore
config[key] = true;
continue;
}
let key: string;
let value: string;
if (arg.includes("=")) {
[key, value] = arg.slice(2).split("=", 2) as [string, string];
} else {
key = arg.slice(2);
value = args[++i] ?? "";
}
key = toCamelCase(key);
if (key.includes(".")) {
const [parentKey, childKey] = key.split(".");
// @ts-ignore
config[parentKey] = config[parentKey] || {};
// @ts-ignore
config[parentKey][childKey] = parseValue(value);
} else {
// @ts-ignore
config[key] = parseValue(value);
}
}
return config;
}
const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
}
const start = performance.now();
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
.map(a => path.resolve("src", a))
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const build = async () => {
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
publicPath: "/", // Use absolute paths for SPA routing compatibility
define: {
"process.env.NODE_ENV": JSON.stringify((cliConfig as any).watch ? "development" : "production"),
},
...cliConfig,
});
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
return result;
};
const result = await build();
const end = performance.now();
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
if ((cliConfig as any).watch) {
console.log("👀 Watching for changes...\n");
// Polling-based file watcher for Docker compatibility
// Docker volumes don't propagate filesystem events (inotify) reliably
const srcDir = path.join(process.cwd(), "src");
const POLL_INTERVAL_MS = 1000;
let lastMtimes = new Map<string, number>();
let isRebuilding = false;
// Collect all file mtimes in src directory
const collectMtimes = async (): Promise<Map<string, number>> => {
const mtimes = new Map<string, number>();
const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,css,html}");
for await (const file of glob.scan({ cwd: srcDir, absolute: true })) {
try {
const stat = await Bun.file(file).stat();
if (stat) {
mtimes.set(file, stat.mtime.getTime());
}
} catch {
// File may have been deleted, skip
}
}
return mtimes;
};
// Initial collection
lastMtimes = await collectMtimes();
// Polling loop
const poll = async () => {
if (isRebuilding) return;
const currentMtimes = await collectMtimes();
const changedFiles: string[] = [];
// Check for new or modified files
for (const [file, mtime] of currentMtimes) {
const lastMtime = lastMtimes.get(file);
if (lastMtime === undefined || lastMtime < mtime) {
changedFiles.push(path.relative(srcDir, file));
}
}
// Check for deleted files
for (const file of lastMtimes.keys()) {
if (!currentMtimes.has(file)) {
changedFiles.push(path.relative(srcDir, file) + " (deleted)");
}
}
if (changedFiles.length > 0) {
isRebuilding = true;
console.log(`\n🔄 Changes detected:`);
changedFiles.forEach(f => console.log(`${f}`));
console.log("");
try {
const rebuildStart = performance.now();
await build();
const rebuildEnd = performance.now();
console.log(`\n✅ Rebuild completed in ${(rebuildEnd - rebuildStart).toFixed(2)}ms\n`);
} catch (err) {
console.error("❌ Rebuild failed:", err);
}
lastMtimes = currentMtimes;
isRebuilding = false;
}
};
const interval = setInterval(poll, POLL_INTERVAL_MS);
// Handle manual exit
process.on("SIGINT", () => {
clearInterval(interval);
console.log("\n👋 Stopping build watcher...");
process.exit(0);
});
// Keep process alive
process.stdin.resume();
}

View File

@@ -1,319 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"bun-plugin-tailwind": "^0.1.2",
"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",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.3.5",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.4.0",
},
},
},
"packages": {
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8GvNtMo0NINM7Emk9cNAviCG3teEgr3BUX9be0+GD029zIagx2Sf54jMui1Eu1IpFm7nWHODuLEefGOQNaJ0gQ=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-r33eHQOHAwkuiBJIwmkXIyqONQOQMnd1GMTpDzaxx9vf9+svby80LZO9Hcm1ns6KT/TBRFyODC/0loA7FAaffg=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-p5q3rJk48qhLuLBOFehVc+kqCE03YrswTc6NCxbwsxiwfySXwcAvpF2KWKF/ZZObvvR8hCCvqe1F81b2p5r2dg=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-zkcHPI23QxJ1TdqafhgkXt1NOEN8o5C460sVeNnrhfJ43LwZgtfcvcQE39x/pBedu67fatY8CU0iY00nOh46ZQ=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-HKBeUlJdNduRkzJKZ5DXM+pPqntfC50/Hu2X65jVX0Y7hu/6IC8RaUTqpr8FtCZqqmc9wDK0OTL+Mbi9UQIKYQ=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-n7zhKTSDZS0yOYg5Rq8easZu5Y/o47sv0c7yGr2ciFdcie9uYV55fZ7QMqhWMGK33ezCSikh5EDkUMCIvfWpjA=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FeCQyBU62DMuB0nn01vPnf3McXrKOsrK9p7sHaBFYycw0mmoU8kCq/WkBkGMnLuvQljJSyen8QBTx+fXdNupWg=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XkCCHkByYn8BIDvoxnny898znju4xnW2kvFE8FT5+0Y62cWdcBGMZ9RdsEUTeRz16k8hHtJpaSfLcEmNTFIwRQ=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TJiYC7KCr0XxFTsxgwQOeE7dncrEL/RSyL0EzSL3xRkrxJMWBCvCSjQn7LV1i6T7hFst0+3KoN3VWvD5BinqHA=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T3xkODItb/0ftQPFsZDc7EAX2D6A4TEazQ2YZyofZToO8Q7y8YT8ooWdhd0BQiTCd66uEvgE1DCZetynwg2IoA=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rtVQB9/1XK8FWJgFtsOthbPifRMYypgJwxu+pK3NHx8WvFKmq7HcPDqNr8xLzGULjQEO7eAo2aOZfONOwYz+5g=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"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=="],
"react-hook-form": ["react-hook-form@7.70.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-router": ["react-router@7.12.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="],
"react-router-dom": ["react-router-dom@7.12.0", "", { "dependencies": { "react-router": "7.12.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"recharts": ["recharts@3.6.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@reduxjs/toolkit/immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
}
}

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,46 +0,0 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "NODE_ENV=production bun src/index.ts",
"build": "bun run build.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"bun-plugin-tailwind": "^0.1.2",
"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",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.4.0"
}
}

View File

@@ -1,52 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import "./index.css";
import { DesignSystem } from "./pages/DesignSystem";
import { AdminQuests } from "./pages/AdminQuests";
import { AdminOverview } from "./pages/admin/Overview";
import { AdminItems } from "./pages/admin/Items";
import { Home } from "./pages/Home";
import { Toaster } from "sonner";
import { NavigationProvider } from "./contexts/navigation-context";
import { MainLayout } from "./components/layout/main-layout";
import { SettingsLayout } from "./pages/settings/SettingsLayout";
import { GeneralSettings } from "./pages/settings/General";
import { EconomySettings } from "./pages/settings/Economy";
import { SystemsSettings } from "./pages/settings/Systems";
import { RolesSettings } from "./pages/settings/Roles";
export function App() {
return (
<BrowserRouter>
<NavigationProvider>
<Toaster richColors position="top-right" theme="dark" />
<MainLayout>
<Routes>
<Route path="/design-system" element={<DesignSystem />} />
<Route path="/admin" element={<Navigate to="/admin/overview" replace />} />
<Route path="/admin/overview" element={<AdminOverview />} />
<Route path="/admin/quests" element={<AdminQuests />} />
<Route path="/admin/items" element={<AdminItems />} />
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/settings/general" replace />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="economy" element={<EconomySettings />} />
<Route path="systems" element={<SystemsSettings />} />
<Route path="roles" element={<RolesSettings />} />
</Route>
<Route path="/" element={<Home />} />
</Routes>
</MainLayout>
</NavigationProvider>
</BrowserRouter>
);
}
export default App;

View File

@@ -1,164 +0,0 @@
import React, { useEffect, useState } from "react";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Activity } from "lucide-react";
import { cn } from "../lib/utils";
import type { ActivityData } from "@shared/modules/dashboard/dashboard.types";
interface ActivityChartProps {
className?: string;
data?: ActivityData[];
}
export function ActivityChart({ className, data: providedData }: ActivityChartProps) {
const [data, setData] = useState<any[]>([]); // using any[] for the displayTime extension
const [isLoading, setIsLoading] = useState(!providedData);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (providedData) {
// Process provided data
const formatted = providedData.map((item) => ({
...item,
displayTime: new Date(item.hour).getHours().toString().padStart(2, '0') + ':00',
}));
setData(formatted);
return;
}
let mounted = true;
async function fetchActivity() {
try {
const response = await fetch("/api/stats/activity");
if (!response.ok) throw new Error("Failed to fetch activity data");
const result = await response.json();
if (mounted) {
// Normalize data: ensure we have 24 hours format
// The API returns { hour: ISOString, commands: number, transactions: number }
// We want to format hour to readable time
const formatted = result.map((item: ActivityData) => ({
...item,
displayTime: new Date(item.hour).getHours().toString().padStart(2, '0') + ':00',
}));
// Sort by time just in case, though API should handle it
setData(formatted);
// Only set loading to false on the first load to avoid flickering
setIsLoading(false);
}
} catch (err) {
if (mounted) {
console.error(err);
setError("Failed to load activity data");
setIsLoading(false);
}
}
}
fetchActivity();
// Refresh every 60 seconds
const interval = setInterval(fetchActivity, 60000);
return () => {
mounted = false;
clearInterval(interval);
};
}, [providedData]);
if (error) {
return (
<Card className={cn("glass-card", className)}>
<CardContent className="flex items-center justify-center h-[300px] text-destructive">
{error}
</CardContent>
</Card>
);
}
return (
<Card className={cn("glass-card overflow-hidden", className)}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
<CardTitle>24h Activity</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="h-[250px] w-full">
{isLoading ? (
<div className="h-full w-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{
top: 10,
right: 10,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorCommands" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--primary)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--primary)" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorTx" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--secondary)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--secondary)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" />
<XAxis
dataKey="displayTime"
stroke="var(--muted-foreground)"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
borderColor: "var(--border)",
borderRadius: "calc(var(--radius) + 2px)",
color: "var(--foreground)"
}}
itemStyle={{ color: "var(--foreground)" }}
/>
<Area
type="monotone"
dataKey="commands"
name="Commands"
stroke="var(--primary)"
fillOpacity={1}
fill="url(#colorCommands)"
/>
<Area
type="monotone"
dataKey="transactions"
name="Transactions"
stroke="var(--secondary)"
fillOpacity={1}
fill="url(#colorTx)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,235 +0,0 @@
import React, { useEffect, useState, useMemo } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "./ui/sheet";
import { ScrollArea } from "./ui/scroll-area";
import { Switch } from "./ui/switch";
import { Badge } from "./ui/badge";
import { Loader2, Terminal, Sparkles, Coins, Shield, Backpack, TrendingUp, MessageSquare, User } from "lucide-react";
import { cn } from "../lib/utils";
import { toast } from "sonner";
interface Command {
name: string;
category: string;
}
interface CommandsDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Category metadata for visual styling
const CATEGORY_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
admin: { label: "Admin", color: "bg-red-500/20 text-red-400 border-red-500/30", icon: Shield },
economy: { label: "Economy", color: "bg-amber-500/20 text-amber-400 border-amber-500/30", icon: Coins },
leveling: { label: "Leveling", color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", icon: TrendingUp },
inventory: { label: "Inventory", color: "bg-blue-500/20 text-blue-400 border-blue-500/30", icon: Backpack },
quest: { label: "Quests", color: "bg-purple-500/20 text-purple-400 border-purple-500/30", icon: Sparkles },
feedback: { label: "Feedback", color: "bg-cyan-500/20 text-cyan-400 border-cyan-500/30", icon: MessageSquare },
user: { label: "User", color: "bg-pink-500/20 text-pink-400 border-pink-500/30", icon: User },
uncategorized: { label: "Other", color: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30", icon: Terminal },
};
export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) {
const [commands, setCommands] = useState<Command[]>([]);
const [enabledState, setEnabledState] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
// Fetch commands and their enabled state
useEffect(() => {
if (open) {
setLoading(true);
Promise.all([
fetch("/api/settings/meta").then(res => res.json()),
fetch("/api/settings").then(res => res.json()),
]).then(([meta, config]) => {
setCommands(meta.commands || []);
// Build enabled state from config.commands (undefined = enabled by default)
const state: Record<string, boolean> = {};
for (const cmd of meta.commands || []) {
state[cmd.name] = config.commands?.[cmd.name] !== false;
}
setEnabledState(state);
}).catch(err => {
toast.error("Failed to load commands", {
description: "Unable to fetch command list. Please try again."
});
console.error(err);
}).finally(() => {
setLoading(false);
});
}
}, [open]);
// Group commands by category
const groupedCommands = useMemo(() => {
const groups: Record<string, Command[]> = {};
for (const cmd of commands) {
const cat = cmd.category || "uncategorized";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(cmd);
}
// Sort categories: admin first, then alphabetically
const sortedCategories = Object.keys(groups).sort((a, b) => {
if (a === "admin") return -1;
if (b === "admin") return 1;
return a.localeCompare(b);
});
return sortedCategories.map(cat => ({ category: cat, commands: groups[cat]! }));
}, [commands]);
// Toggle command enabled state
const toggleCommand = async (commandName: string, enabled: boolean) => {
setSaving(commandName);
try {
const response = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
commands: {
[commandName]: enabled,
},
}),
});
if (!response.ok) throw new Error("Failed to save");
setEnabledState(prev => ({ ...prev, [commandName]: enabled }));
toast.success(`/${commandName} ${enabled ? "enabled" : "disabled"}`, {
description: `Command has been ${enabled ? "enabled" : "disabled"} successfully.`,
duration: 2000,
id: "command-toggle",
});
} catch (error) {
toast.error("Failed to toggle command", {
description: "Unable to update command status. Please try again."
});
console.error(error);
} finally {
setSaving(null);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
<SheetContent side="right" className="w-[800px] sm:max-w-[800px] p-0 flex flex-col gap-0 border-l border-border/50 glass-card bg-background/95 text-foreground">
<SheetHeader className="p-6 border-b border-border/50">
<SheetTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
Command Manager
</SheetTitle>
<SheetDescription>
Enable or disable commands. Changes take effect immediately.
</SheetDescription>
</SheetHeader>
{loading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea className="h-full">
<div className="space-y-6 p-6 pb-8">
{groupedCommands.map(({ category, commands: cmds }) => {
const config = (CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.uncategorized)!;
const IconComponent = config.icon;
return (
<div key={category} className="space-y-3">
{/* Category Header */}
<div className="flex items-center gap-2">
<IconComponent className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
{config.label}
</h3>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{cmds.length}
</Badge>
</div>
{/* Commands Grid */}
<div className="grid grid-cols-2 gap-3">
{cmds.map(cmd => {
const isEnabled = enabledState[cmd.name] !== false;
const isSaving = saving === cmd.name;
return (
<div
key={cmd.name}
className={cn(
"group relative rounded-lg overflow-hidden transition-all duration-300",
"bg-gradient-to-r from-card/80 to-card/40",
"border border-border/20 hover:border-border/40",
"hover:shadow-lg hover:shadow-primary/5",
"hover:translate-x-1",
!isEnabled && "opacity-40 grayscale",
isSaving && "animate-pulse"
)}
>
{/* Category color accent bar */}
<div className={cn(
"absolute left-0 top-0 bottom-0 w-1 transition-all duration-300",
config.color.split(' ')[0],
"group-hover:w-1.5"
)} />
<div className="p-3 pl-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Icon with glow effect */}
<div className={cn(
"w-9 h-9 rounded-lg flex items-center justify-center",
"bg-gradient-to-br",
config.color,
"shadow-sm",
isEnabled && "group-hover:shadow-md group-hover:scale-105",
"transition-all duration-300"
)}>
<IconComponent className="w-4 h-4" />
</div>
<div className="flex flex-col">
<span className={cn(
"font-mono text-sm font-semibold tracking-tight",
"transition-colors duration-300",
isEnabled ? "text-foreground" : "text-muted-foreground"
)}>
/{cmd.name}
</span>
<span className="text-[10px] text-muted-foreground/70 uppercase tracking-wider">
{category}
</span>
</div>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => toggleCommand(cmd.name, checked)}
disabled={isSaving}
className={cn(
"transition-opacity duration-300",
!isEnabled && "opacity-60"
)}
/>
</div>
</div>
);
})}
</div>
</div>
);
})}
{groupedCommands.length === 0 && (
<div className="text-center text-muted-foreground py-8">
No commands found.
</div>
)}
</div>
</ScrollArea>
</div>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -1,48 +0,0 @@
import React, { type ReactNode } from "react";
import { cn } from "../lib/utils";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Badge } from "./ui/badge";
interface FeatureCardProps {
title: string;
category: string;
description?: string;
icon?: ReactNode;
children?: ReactNode;
className?: string;
delay?: number; // Animation delay in ms or generic unit
}
export function FeatureCard({
title,
category,
description,
icon,
children,
className,
}: FeatureCardProps) {
return (
<Card className={cn(
"glass-card border-none hover-lift transition-all animate-in slide-up group overflow-hidden",
className
)}>
{icon && (
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
{icon}
</div>
)}
<CardHeader>
<Badge variant="glass" className="w-fit mb-2">{category}</Badge>
<CardTitle className="text-xl text-primary">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{description && (
<p className="text-muted-foreground text-step--1">
{description}
</p>
)}
{children}
</CardContent>
</Card>
);
}

View File

@@ -1,30 +0,0 @@
import React, { type ReactNode } from "react";
import { cn } from "../lib/utils";
interface InfoCardProps {
icon: ReactNode;
title: string;
description: string;
iconWrapperClassName?: string;
className?: string;
}
export function InfoCard({
icon,
title,
description,
iconWrapperClassName,
className,
}: InfoCardProps) {
return (
<div className={cn("space-y-4 p-6 glass-card rounded-2xl hover:bg-white/5 transition-colors", className)}>
<div className={cn("w-12 h-12 rounded-xl flex items-center justify-center mb-4", iconWrapperClassName)}>
{icon}
</div>
<h3 className="text-xl font-bold text-primary">{title}</h3>
<p className="text-muted-foreground text-step--1">
{description}
</p>
</div>
);
}

View File

@@ -1,220 +0,0 @@
import { Link } from "react-router-dom"
import { ChevronRight } from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar"
import { useNavigation, type NavItem } from "@/contexts/navigation-context"
import { cn } from "@/lib/utils"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useSocket } from "@/hooks/use-socket"
function NavItemWithSubMenu({ item }: { item: NavItem }) {
const { state } = useSidebar()
const isCollapsed = state === "collapsed"
// When collapsed, show a dropdown menu on hover/click
if (isCollapsed) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
className={cn(
"transition-all duration-200 ease-in-out font-medium py-4 min-h-10",
item.isActive
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
)}
>
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
sideOffset={8}
className="min-w-[180px] bg-background/95 backdrop-blur-xl border-border/50"
>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{item.title}
</div>
{item.subItems?.map((subItem) => (
<DropdownMenuItem key={subItem.title} asChild className="group/dropitem">
<Link
to={subItem.url}
className={cn(
"cursor-pointer py-4 min-h-10 flex items-center gap-2",
subItem.isActive
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-inherit"
)}
>
<subItem.icon className={cn("size-4", subItem.isActive ? "text-primary" : "text-muted-foreground group-hover/dropitem:text-inherit")} />
{subItem.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
// When expanded, show collapsible sub-menu
return (
<Collapsible defaultOpen={item.isActive} className="group/collapsible">
<SidebarMenuItem className="flex flex-col">
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
className={cn(
"transition-all duration-200 ease-in-out font-medium py-4 min-h-10",
item.isActive
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
)}
>
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
<span className={cn("group-data-[collapsible=icon]:hidden", item.isActive && "text-primary")}>
{item.title}
</span>
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<SidebarMenuSub>
{item.subItems?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={subItem.isActive}
className={cn(
"transition-all duration-200 py-4 min-h-10 group/subitem",
subItem.isActive
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-inherit"
)}
>
<Link to={subItem.url}>
<subItem.icon className={cn("size-4", subItem.isActive ? "text-primary" : "text-muted-foreground group-hover/subitem:text-inherit")} />
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
}
function NavItemLink({ item }: { item: NavItem }) {
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={item.isActive}
tooltip={item.title}
className={cn(
"transition-all duration-200 ease-in-out font-medium",
item.isActive
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
)}
>
<Link to={item.url} className="flex items-center gap-3 py-4 min-h-10 group-data-[collapsible=icon]:justify-center">
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
<span className={cn("group-data-[collapsible=icon]:hidden", item.isActive && "text-primary")}>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
export function AppSidebar() {
const { navItems } = useNavigation()
const { stats } = useSocket()
return (
<Sidebar collapsible="icon" className="border-r border-border/50 bg-background/60 backdrop-blur-xl supports-backdrop-filter:bg-background/60">
<SidebarHeader className="pb-4 pt-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild className="hover:bg-primary/10 transition-colors">
<Link to="/">
{stats?.bot?.avatarUrl ? (
<img
src={stats.bot.avatarUrl}
alt={stats.bot.name}
className="size-10 rounded-full group-data-[collapsible=icon]:size-8 object-cover shadow-lg"
/>
) : (
<div className="flex aspect-square size-10 items-center justify-center rounded-full bg-aurora sun-flare shadow-lg group-data-[collapsible=icon]:size-8">
<span className="sr-only">Aurora</span>
</div>
)}
<div className="grid flex-1 text-left text-sm leading-tight ml-2 group-data-[collapsible=icon]:hidden">
<span className="truncate font-bold text-primary text-base">Aurora</span>
<span className="truncate text-xs text-muted-foreground font-medium">
{stats?.bot?.status || "Online"}
</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="px-2 group-data-[collapsible=icon]:px-0">
<SidebarGroup>
<SidebarGroupLabel className="text-muted-foreground/70 uppercase tracking-wider text-xs font-bold mb-2 px-2 group-data-[collapsible=icon]:hidden">
Menu
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-2 group-data-[collapsible=icon]:items-center">
{navItems.map((item) => (
item.subItems ? (
<NavItemWithSubMenu key={item.title} item={item} />
) : (
<NavItemLink key={item.title} item={item} />
)
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}

View File

@@ -1,56 +0,0 @@
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "./app-sidebar"
import { MobileNav } from "@/components/navigation/mobile-nav"
import { useIsMobile } from "@/hooks/use-mobile"
import { Separator } from "@/components/ui/separator"
import { useNavigation } from "@/contexts/navigation-context"
interface MainLayoutProps {
children: React.ReactNode
}
export function MainLayout({ children }: MainLayoutProps) {
const isMobile = useIsMobile()
const { breadcrumbs, currentTitle } = useNavigation()
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
{/* Header with breadcrumbs */}
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/50 bg-background/60 backdrop-blur-xl supports-backdrop-filter:bg-background/60 transition-all duration-300 ease-in-out">
<div className="flex items-center gap-2 px-4 w-full">
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-primary transition-colors" />
<Separator orientation="vertical" className="mr-2 h-4 bg-border/50" />
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm bg-muted/30 px-3 py-1.5 rounded-full border border-border/30">
{breadcrumbs.length === 0 ? (
<span className="text-sm font-medium text-primary px-1">{currentTitle}</span>
) : (
breadcrumbs.map((crumb, index) => (
<span key={crumb.url} className="flex items-center gap-1">
{index > 0 && (
<span className="text-muted-foreground/50">/</span>
)}
{index === breadcrumbs.length - 1 ? (
<span className="text-sm font-medium text-primary px-1">{crumb.title}</span>
) : (
<span className="text-sm text-muted-foreground hover:text-foreground transition-colors px-1">{crumb.title}</span>
)}
</span>
))
)}
</nav>
</div>
</header>
{/* Main content */}
<div className="flex-1 overflow-auto">
{children}
</div>
{/* Mobile bottom navigation */}
{isMobile && <MobileNav />}
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -1,170 +0,0 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Trophy, Coins, Award, Crown, Target } from "lucide-react";
import { cn } from "../lib/utils";
import { Skeleton } from "./ui/skeleton";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
interface LocalUser {
username: string;
value: string | number;
}
export interface LeaderboardData {
topLevels: { username: string; level: number }[];
topWealth: { username: string; balance: string }[];
topNetWorth: { username: string; netWorth: string }[];
}
interface LeaderboardCardProps {
data?: LeaderboardData;
isLoading?: boolean;
className?: string;
}
export function LeaderboardCard({ data, isLoading, className }: LeaderboardCardProps) {
const [view, setView] = useState<"wealth" | "levels" | "networth">("wealth");
if (isLoading) {
return (
<Card className={cn("glass-card border-none bg-card/40", className)}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Top Players</CardTitle>
<Trophy className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-1 flex-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
const currentList = view === "wealth" ? data?.topWealth : view === "networth" ? data?.topNetWorth : data?.topLevels;
const getTitle = () => {
switch (view) {
case "wealth": return "Richest Users";
case "networth": return "Highest Net Worth";
case "levels": return "Top Levels";
}
}
return (
<Card className={cn("glass-card border-none transition-all duration-300", className)}>
<CardHeader className="pb-2">
<div className="flex flex-wrap items-center justify-between gap-4">
<CardTitle className="text-sm font-medium flex items-center gap-2 whitespace-nowrap">
{getTitle()}
</CardTitle>
<div className="flex bg-muted/50 rounded-lg p-0.5">
<Button
variant="ghost"
size="sm"
className={cn(
"h-6 px-2 text-xs rounded-md transition-all",
view === "wealth" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setView("wealth")}
>
<Coins className="w-3 h-3 mr-1" />
Wealth
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"h-6 px-2 text-xs rounded-md transition-all",
view === "levels" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setView("levels")}
>
<Award className="w-3 h-3 mr-1" />
Levels
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"h-6 px-2 text-xs rounded-md transition-all",
view === "networth" ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setView("networth")}
>
<Target className="w-3 h-3 mr-1" />
Net Worth
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4 animate-in fade-in slide-up duration-300 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar" key={view}>
{currentList?.map((user, index) => {
const isTop = index === 0;
const RankIcon = index === 0 ? Crown : index === 1 ? Trophy : Award;
const rankColor = index === 0 ? "text-yellow-500" : index === 1 ? "text-slate-400" : "text-orange-500";
const bgColor = index === 0 ? "bg-yellow-500/10 border-yellow-500/20" : index === 1 ? "bg-slate-400/10 border-slate-400/20" : "bg-orange-500/10 border-orange-500/20";
// Type guard or simple check because structure differs slightly or we can normalize
let valueDisplay = "";
if (view === "wealth") {
valueDisplay = `${Number((user as any).balance).toLocaleString()} AU`;
} else if (view === "networth") {
valueDisplay = `${Number((user as any).netWorth).toLocaleString()} AU`;
} else {
valueDisplay = `Lvl ${(user as any).level}`;
}
return (
<div key={user.username} className={cn(
"flex items-center gap-3 p-2 rounded-lg border transition-colors",
"hover:bg-muted/50 border-transparent hover:border-border/50",
isTop && "bg-primary/5 border-primary/10"
)}>
<div className={cn(
"w-8 h-8 flex items-center justify-center rounded-full border text-xs font-bold",
bgColor, rankColor
)}>
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate flex items-center gap-1.5">
{user.username}
{isTop && <Crown className="w-3 h-3 text-yellow-500 fill-yellow-500" />}
</p>
</div>
<div className="text-right">
<span className={cn(
"text-xs font-bold font-mono",
view === "wealth" ? "text-emerald-500" : view === "networth" ? "text-purple-500" : "text-blue-500"
)}>
{valueDisplay}
</span>
</div>
</div>
);
})}
{(!currentList || currentList.length === 0) && (
<div className="text-center py-4 text-muted-foreground text-sm">
No data available
</div>
)}
</div>
</CardContent>
</Card >
);
}

View File

@@ -1,128 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Progress } from "./ui/progress";
import { Gift, Clock, Sparkles, Zap, Timer } from "lucide-react";
import { cn } from "../lib/utils";
import { Skeleton } from "./ui/skeleton";
export interface LootdropData {
rewardAmount: number;
currency: string;
createdAt: string;
expiresAt: string | null;
}
export interface LootdropState {
monitoredChannels: number;
hottestChannel: {
id: string;
messages: number;
progress: number;
cooldown: boolean;
} | null;
config: {
requiredMessages: number;
dropChance: number;
};
}
interface LootdropCardProps {
drop?: LootdropData | null;
state?: LootdropState;
isLoading?: boolean;
className?: string;
}
export function LootdropCard({ drop, state, isLoading, className }: LootdropCardProps) {
if (isLoading) {
return (
<Card className={cn("glass-card border-none bg-card/40", className)}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Lootdrop Status</CardTitle>
<Gift className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-8 w-[120px]" />
<Skeleton className="h-4 w-[80px]" />
</div>
</CardContent>
</Card>
);
}
const isActive = !!drop;
const progress = state?.hottestChannel?.progress || 0;
const isCooldown = state?.hottestChannel?.cooldown || false;
return (
<Card className={cn(
"glass-card border-none transition-all duration-500 overflow-hidden relative",
isActive ? "bg-primary/5 border-primary/20 hover-glow ring-1 ring-primary/20" : "bg-card/40",
className
)}>
{/* Ambient Background Effect */}
{isActive && (
<div className="absolute -right-4 -top-4 w-24 h-24 bg-primary/20 blur-3xl rounded-full pointer-events-none animate-pulse" />
)}
<CardHeader className="flex flex-row items-center justify-between pb-2 relative z-10">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{isActive ? "Active Lootdrop" : "Lootdrop Potential"}
{isActive && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
)}
</CardTitle>
<Gift className={cn("h-4 w-4 transition-colors", isActive ? "text-primary " : "text-muted-foreground")} />
</CardHeader>
<CardContent className="relative z-10">
{isActive ? (
<div className="space-y-3 animate-in fade-in slide-up">
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-primary flex items-center gap-2">
<Sparkles className="w-5 h-5 text-yellow-500 fill-yellow-500 animate-pulse" />
{drop.rewardAmount.toLocaleString()} {drop.currency}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>Dropped {new Date(drop.createdAt).toLocaleTimeString()}</span>
</div>
</div>
) : (
<div className="space-y-4">
{isCooldown ? (
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground space-y-1">
<Timer className="w-6 h-6 text-yellow-500 opacity-80" />
<p className="text-sm font-medium text-yellow-500/80">Cooling Down...</p>
<p className="text-xs opacity-50">Channels are recovering.</p>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Zap className={cn("w-3 h-3", progress > 80 ? "text-yellow-500" : "text-muted-foreground")} />
<span>Next Drop Chance</span>
</div>
<span className="font-mono">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-1.5" indicatorClassName={cn(progress > 80 ? "bg-yellow-500" : "bg-primary")} />
{state?.hottestChannel ? (
<p className="text-[10px] text-muted-foreground text-right opacity-70">
{state.hottestChannel.messages} / {state.config.requiredMessages} msgs
</p>
) : (
<p className="text-[10px] text-muted-foreground text-center opacity-50 pt-1">
No recent activity
</p>
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,41 +0,0 @@
import { Link } from "react-router-dom"
import { useNavigation } from "@/contexts/navigation-context"
import { cn } from "@/lib/utils"
export function MobileNav() {
const { navItems } = useNavigation()
return (
<nav className="fixed bottom-4 left-4 right-4 z-50 rounded-2xl border border-border/40 bg-background/60 backdrop-blur-xl supports-backdrop-filter:bg-background/60 md:hidden shadow-lg shadow-black/5">
<div className="flex h-16 items-center justify-around px-2">
{navItems.map((item) => (
<Link
key={item.title}
to={item.url}
className={cn(
"flex flex-col items-center justify-center gap-1 rounded-xl px-4 py-2 text-xs font-medium transition-all duration-200",
"min-w-[48px] min-h-[48px]",
item.isActive
? "text-primary bg-primary/10 shadow-[inset_0_2px_4px_rgba(0,0,0,0.05)]"
: "text-muted-foreground/80 hover:text-foreground hover:bg-white/5"
)}
>
<item.icon className={cn(
"size-5 transition-transform duration-200",
item.isActive && "scale-110 fill-primary/20"
)} />
<span className={cn(
"truncate max-w-[60px] text-[10px]",
item.isActive && "font-bold"
)}>
{item.title}
</span>
{item.isActive && (
<span className="absolute bottom-1 h-0.5 w-4 rounded-full bg-primary/50 blur-[1px]" />
)}
</Link>
))}
</div>
</nav>
)
}

View File

@@ -1,297 +0,0 @@
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";
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"),
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>;
interface QuestFormProps {
initialData?: QuestListItem;
onUpdate?: () => void;
onCancel?: () => void;
}
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({ initialData, onUpdate, onCancel }: QuestFormProps) {
const isEditMode = initialData !== undefined;
const [isSubmitting, setIsSubmitting] = React.useState(false);
const form = useForm<QuestFormValues>({
resolver: zodResolver(questSchema),
defaultValues: {
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 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",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || (isEditMode ? "Failed to update quest" : "Failed to create quest"));
}
toast.success(isEditMode ? "Quest updated successfully!" : "Quest created successfully!", {
description: `${data.name} has been ${isEditMode ? "updated" : "added to the database"}.`,
});
form.reset({
name: "",
description: "",
triggerEvent: "XP_GAIN",
target: 1,
xpReward: 100,
balanceReward: 500,
});
onUpdate?.();
} catch (error) {
console.error("Submission error:", error);
toast.error(isEditMode ? "Failed to update quest" : "Failed to create quest", {
description: error instanceof Error ? error.message : "An unknown error occurred",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="glass-card overflow-hidden">
<div className="h-1.5 bg-primary w-full" />
<CardHeader>
<CardTitle className="text-2xl font-bold text-primary">{isEditMode ? "Edit Quest" : "Create New Quest"}</CardTitle>
<CardDescription>
{isEditMode ? "Update the quest configuration." : "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 className="flex items-center gap-2">
<Star className="w-4 h-4 text-amber-400" />
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 className="flex items-center gap-2">
<Coins className="w-4 h-4 text-amber-500" />
AU Reward
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={e => field.onChange(parseInt(e.target.value))}
className="bg-background/50"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</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
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

@@ -1,288 +0,0 @@
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, Trash2, Pencil, Star, Coins } from "lucide-react";
interface QuestListItem {
id: number;
name: string;
description: string | null;
triggerEvent: string;
requirements: { target?: number };
rewards: { xp?: number; balance?: number };
}
interface QuestTableProps {
quests: QuestListItem[];
isInitialLoading: boolean;
isRefreshing: boolean;
onRefresh?: () => void;
onDelete?: (id: number) => void;
onEdit?: (id: number) => void;
}
const TRIGGER_EVENT_LABELS: Record<string, string> = {
XP_GAIN: "XP Gain",
ITEM_COLLECT: "Item Collect",
ITEM_USE: "Item Use",
DAILY_REWARD: "Daily Reward",
LOOTBOX: "Lootbox Currency Reward",
EXAM_REWARD: "Exam Reward",
PURCHASE: "Purchase",
TRANSFER_IN: "Transfer In",
TRANSFER_OUT: "Transfer Out",
TRADE_IN: "Trade In",
TRADE_OUT: "Trade Out",
QUEST_REWARD: "Quest Reward",
TRIVIA_ENTRY: "Trivia Entry",
TRIVIA_WIN: "Trivia Win",
};
function getTriggerEventLabel(triggerEvent: string): string {
return TRIGGER_EVENT_LABELS[triggerEvent] || triggerEvent;
}
function TruncatedText({ text, maxLength = 100 }: { text: string; maxLength?: number }) {
if (!text || text.length <= maxLength) {
return <span>{text || "-"}</span>;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help border-b border-dashed border-muted-foreground/50">
{text.slice(0, maxLength)}...
</span>
</TooltipTrigger>
<TooltipContent className="max-w-md">
<p>{text}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function QuestTableSkeleton() {
return (
<div className="space-y-3 animate-pulse">
<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" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="grid grid-cols-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" />
<Skeleton className="h-5 w-24" />
<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>
);
}
function EmptyQuestState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center animate-in fade-in duration-500">
<div className="rounded-full bg-muted/50 p-4 mb-4">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground">No quests available</h3>
<p className="text-muted-foreground mt-1 max-w-sm">
There are no quests in the database yet. Create your first quest using the form below.
</p>
</div>
);
}
function QuestTableContent({ quests, onDelete, onEdit }: { quests: QuestListItem[]; onDelete?: (id: number) => void; onEdit?: (id: number) => void }) {
if (quests.length === 0) {
return <EmptyQuestState />;
}
return (
<div className="overflow-x-auto animate-in fade-in slide-in-from-bottom-2 duration-300">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-16">
ID
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-40">
Name
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-64">
Description
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-36">
Trigger Event
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-20">
Target
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
XP Reward
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
AU Reward
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-24">
Actions
</th>
</tr>
</thead>
<tbody>
{quests.map((quest) => {
const requirements = quest.requirements as { target?: number };
const rewards = quest.rewards as { xp?: number; balance?: number };
const target = requirements?.target || 1;
return (
<tr
key={quest.id}
id={`quest-row-${quest.id}`}
className="border-b border-border/30 hover:bg-muted/20 transition-colors animate-in fade-in slide-in-from-left-2 duration-300"
>
<td className="py-3 px-4 text-sm text-muted-foreground font-mono">
#{quest.id}
</td>
<td className="py-3 px-4 text-sm font-medium text-foreground">
{quest.name}
</td>
<td className="py-3 px-4 text-sm text-muted-foreground">
<TruncatedText text={quest.description || ""} maxLength={50} />
</td>
<td className="py-3 px-4">
<Badge variant="outline" className="text-xs border-border/50">
{getTriggerEventLabel(quest.triggerEvent)}
</Badge>
</td>
<td className="py-3 px-4 text-sm text-foreground font-mono">
{target}
</td>
<td className="py-3 px-4 text-sm text-foreground">
{rewards?.xp ? (
<span className="flex items-center gap-1">
<Star className="w-4 h-4 text-amber-400" />
<span className="font-mono">{rewards.xp}</span>
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="py-3 px-4 text-sm text-foreground">
{rewards?.balance ? (
<span className="flex items-center gap-1">
<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>
);
})}
</tbody>
</table>
</div>
);
}
export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh, onDelete, onEdit }: QuestTableProps) {
const showSkeleton = isInitialLoading && quests.length === 0;
return (
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
<div className="flex items-center gap-3">
{showSkeleton ? (
<Badge variant="secondary" className="animate-pulse">
Loading...
</Badge>
) : (
<Badge variant="outline" className="border-border/50">
{quests.length} quest{quests.length !== 1 ? "s" : ""}
</Badge>
)}
<button
onClick={onRefresh}
disabled={isRefreshing}
className={cn(
"p-2 rounded-md hover:bg-muted/50 transition-colors",
isRefreshing && "cursor-wait"
)}
title="Refresh quests"
>
<RefreshCw className={cn(
"w-[18px] h-[18px] text-muted-foreground transition-transform",
isRefreshing && "animate-spin"
)} />
</button>
</div>
</div>
</CardHeader>
<CardContent>
{showSkeleton ? (
<QuestTableSkeleton />
) : (
<QuestTableContent quests={quests} onDelete={onDelete} onEdit={onEdit} />
)}
</CardContent>
</Card>
);
}

View File

@@ -1,98 +0,0 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Badge } from "./ui/badge";
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
import { cn } from "../lib/utils";
import { Skeleton } from "./ui/skeleton";
function timeAgo(dateInput: Date | string) {
const date = new Date(dateInput);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return date.toLocaleDateString();
}
interface RecentActivityProps {
events: RecentEvent[];
isLoading?: boolean;
className?: string;
}
export function RecentActivity({ events, isLoading, className }: RecentActivityProps) {
return (
<Card className={cn("glass-card border-none bg-card/40 h-full", className)}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-lg font-medium">
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
Live Activity
</span>
{!isLoading && events.length > 0 && (
<Badge variant="glass" className="text-[10px] font-mono">
{events.length} EVENTS
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4 pt-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground space-y-2">
<div className="text-4xl">😴</div>
<p>No recent activity</p>
</div>
) : (
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 -mr-2 custom-scrollbar">
{events.map((event, i) => (
<div
key={i}
className="group flex items-start gap-3 p-3 rounded-xl bg-background/30 hover:bg-background/50 border border-transparent hover:border-border/50 transition-all duration-300"
>
<div className="text-2xl p-2 rounded-lg bg-background/50 group-hover:scale-110 transition-transform">
{event.icon || "📝"}
</div>
<div className="flex-1 min-w-0 py-1">
<p className="text-sm font-medium leading-none truncate mb-1.5 text-foreground/90">
{event.message}
</p>
<div className="flex items-center gap-2">
<Badge
variant={
event.type === 'error' ? 'destructive' :
event.type === 'warn' ? 'destructive' :
event.type === 'success' ? 'aurora' : 'secondary'
}
className="text-[10px] h-4 px-1.5"
>
{event.type}
</Badge>
<span className="text-[10px] text-muted-foreground font-mono">
{timeAgo(event.timestamp)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,39 +0,0 @@
import React from "react";
import { cn } from "../lib/utils";
import { Badge } from "./ui/badge";
interface SectionHeaderProps {
badge: string;
title: string;
description?: string;
align?: "center" | "left" | "right";
className?: string;
}
export function SectionHeader({
badge,
title,
description,
align = "center",
className,
}: SectionHeaderProps) {
const alignClasses = {
center: "text-center mx-auto",
left: "text-left mr-auto", // reset margin if needed
right: "text-right ml-auto",
};
return (
<div className={cn("space-y-4 mb-16", alignClasses[align], className)}>
<Badge variant="glass" className="py-1.5 px-4">{badge}</Badge>
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tight">
{title}
</h2>
{description && (
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
{description}
</p>
)}
</div>
);
}

View File

@@ -1,77 +0,0 @@
import React, { type ReactNode } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
import { type LucideIcon, ChevronRight } from "lucide-react";
import { cn } from "../lib/utils";
interface StatCardProps {
title: string;
value: ReactNode;
subtitle?: ReactNode;
icon: LucideIcon;
isLoading?: boolean;
className?: string;
valueClassName?: string;
iconClassName?: string;
onClick?: () => void;
}
export function StatCard({
title,
value,
subtitle,
icon: Icon,
isLoading = false,
className,
valueClassName,
iconClassName,
onClick,
}: StatCardProps) {
return (
<Card
className={cn(
"glass-card border-none bg-card/40 hover-glow group transition-all duration-300",
onClick && "cursor-pointer hover:bg-card/60 hover:scale-[1.02] active:scale-[0.98]",
className
)}
onClick={onClick}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative overflow-hidden">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
{title}
</CardTitle>
<div className="flex items-center gap-2">
{onClick && (
<span className="text-[10px] font-bold uppercase tracking-widest text-primary opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300 flex items-center gap-1">
Manage <ChevronRight className="w-3 h-3" />
</span>
)}
<div className="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center ring-1 ring-primary/20">
<Icon className={cn(
"h-4 w-4 transition-all duration-300 text-primary",
onClick && "group-hover:scale-110",
iconClassName
)} />
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-[60px]" />
<Skeleton className="h-3 w-[100px]" />
</div>
) : (
<>
<div className={cn("text-2xl font-bold", valueClassName)}>{value}</div>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">
{subtitle}
</p>
)}
</>
)}
</CardContent>
</Card >
);
}

View File

@@ -1,41 +0,0 @@
import React from "react";
import { cn } from "../lib/utils";
import { Card } from "./ui/card";
interface TestimonialCardProps {
quote: string;
author: string;
role: string;
avatarGradient: string;
className?: string;
}
export function TestimonialCard({
quote,
author,
role,
avatarGradient,
className,
}: TestimonialCardProps) {
return (
<Card className={cn("glass-card border-none p-6 space-y-4", className)}>
<div className="flex gap-1 text-yellow-500">
{[1, 2, 3, 4, 5].map((_, i) => (
<svg key={i} xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="w-4 h-4">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
))}
</div>
<p className="text-muted-foreground italic">
"{quote}"
</p>
<div className="flex items-center gap-3 pt-2">
<div className={cn("w-10 h-10 rounded-full animate-gradient", avatarGradient)} />
<div>
<p className="font-bold text-sm text-primary">{author}</p>
<p className="text-xs text-muted-foreground">{role}</p>
</div>
</div>
</Card>
);
}

View File

@@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -1,37 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:opacity-90 hover-scale shadow-sm",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:opacity-80 hover-scale",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 hover-scale",
outline: "text-foreground border-border hover:bg-accent hover:text-accent-foreground transition-colors",
aurora: "border-transparent bg-aurora text-primary-foreground shadow-sm hover-scale",
glass: "glass-card border-border/50 text-foreground hover:bg-accent/50 backdrop-blur-md",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -1,64 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 hover-glow active-press shadow-md",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 active-press shadow-sm",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 active-press",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 active-press shadow-sm",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 active-press",
link: "text-primary underline-offset-4 hover:underline",
aurora: "bg-aurora text-primary-foreground shadow-sm hover:opacity-90 hover-glow active-press",
glass: "glass-card border-border/50 text-foreground hover:bg-accent/50 hover-lift active-press",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"glass-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm transition-all",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,34 +0,0 @@
"use client"
import * as React from "react"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -1,255 +0,0 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,165 +0,0 @@
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/20 border-input/50 h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base text-foreground shadow-xs transition-all outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm backdrop-blur-sm",
"focus-visible:border-primary/50 focus-visible:bg-input/40 focus-visible:ring-2 focus-visible:ring-primary/20",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -1,22 +0,0 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,24 +0,0 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Progress = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value?: number | null, indicatorClassName?: string }
>(({ className, value, indicatorClassName, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary/20",
className
)}
{...props}
>
<div
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</div>
))
Progress.displayName = "Progress"
export { Progress }

View File

@@ -1,58 +0,0 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,188 +0,0 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base text-foreground shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,26 +0,0 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,139 +0,0 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -1,725 +0,0 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "64px"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-aurora-page text-foreground font-outfit overflow-x-hidden relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative flex group-data-[collapsible=icon]:justify-center", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-10! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"ring-sidebar-ring active:bg-sidebar-accent active:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,38 +0,0 @@
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

@@ -1,29 +0,0 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -1,64 +0,0 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base text-foreground shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,147 +0,0 @@
import * as React from "react"
import { useLocation, type Location } from "react-router-dom"
import { Home, Palette, ShieldCheck, Settings, LayoutDashboard, Trophy, SlidersHorizontal, Coins, Cog, UserCog, Package, type LucideIcon } from "lucide-react"
export interface NavSubItem {
title: string
url: string
icon: LucideIcon
isActive?: boolean
}
export interface NavItem {
title: string
url: string
icon: LucideIcon
isActive?: boolean
subItems?: NavSubItem[]
}
export interface Breadcrumb {
title: string
url: string
}
interface NavigationContextProps {
navItems: NavItem[]
breadcrumbs: Breadcrumb[]
currentPath: string
currentTitle: string
}
const NavigationContext = React.createContext<NavigationContextProps | null>(null)
interface NavConfigItem extends Omit<NavItem, "isActive" | "subItems"> {
subItems?: Omit<NavSubItem, "isActive">[]
}
const NAV_CONFIG: NavConfigItem[] = [
{ title: "Home", url: "/", icon: Home },
{ title: "Design System", url: "/design-system", icon: Palette },
{
title: "Admin",
url: "/admin",
icon: ShieldCheck,
subItems: [
{ title: "Overview", url: "/admin/overview", icon: LayoutDashboard },
{ title: "Quests", url: "/admin/quests", icon: Trophy },
{ title: "Items", url: "/admin/items", icon: Package },
]
},
{
title: "Settings",
url: "/settings",
icon: Settings,
subItems: [
{ title: "General", url: "/settings/general", icon: SlidersHorizontal },
{ title: "Economy", url: "/settings/economy", icon: Coins },
{ title: "Systems", url: "/settings/systems", icon: Cog },
{ title: "Roles", url: "/settings/roles", icon: UserCog },
]
},
]
function generateBreadcrumbs(location: Location): Breadcrumb[] {
const pathParts = location.pathname.split("/").filter(Boolean)
const breadcrumbs: Breadcrumb[] = []
let currentPath = ""
for (const part of pathParts) {
currentPath += `/${part}`
// Capitalize and clean up the part for display
const title = part
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
breadcrumbs.push({ title, url: currentPath })
}
return breadcrumbs
}
function getPageTitle(pathname: string): string {
// Check top-level items first
for (const item of NAV_CONFIG) {
if (item.url === pathname) return item.title
// Check sub-items
if (item.subItems) {
const subItem = item.subItems.find((sub) => sub.url === pathname)
if (subItem) return subItem.title
}
}
// Handle nested routes
const parts = pathname.split("/").filter(Boolean)
const lastPart = parts[parts.length - 1]
if (lastPart) {
return lastPart
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
return "Aurora"
}
export function NavigationProvider({ children }: { children: React.ReactNode }) {
const location = useLocation()
const value = React.useMemo<NavigationContextProps>(() => {
const navItems = NAV_CONFIG.map((item) => {
const isParentActive = item.subItems
? location.pathname.startsWith(item.url)
: location.pathname === item.url
return {
...item,
isActive: isParentActive,
subItems: item.subItems?.map((subItem) => ({
...subItem,
isActive: location.pathname === subItem.url,
})),
}
})
return {
navItems,
breadcrumbs: generateBreadcrumbs(location),
currentPath: location.pathname,
currentTitle: getPageTitle(location.pathname),
}
}, [location.pathname])
return (
<NavigationContext.Provider value={value}>
{children}
</NavigationContext.Provider>
)
}
export function useNavigation() {
const context = React.useContext(NavigationContext)
if (!context) {
throw new Error("useNavigation must be used within a NavigationProvider")
}
return context
}

View File

@@ -1,26 +0,0 @@
/**
* This file is the entry point for the React app, it sets up the root
* element and renders the App component to the DOM.
*
* It is included in `src/index.html`.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<App />
</StrictMode>
);
if (import.meta.hot) {
// With hot module reloading, `import.meta.hot.data` is persisted.
const root = (import.meta.hot.data.root ??= createRoot(elem));
root.render(app);
} else {
// The hot module reloading API is not available in production.
createRoot(elem).render(app);
}

View File

@@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,187 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
// Sentinel value for "none" selection
export const NONE_VALUE = "__none__";
// Schema definition matching backend config
const bigIntStringSchema = z.coerce.string()
.refine((val) => /^\d+$/.test(val), { message: "Must be a valid integer" });
export const formSchema = z.object({
leveling: z.object({
base: z.number(),
exponent: z.number(),
chat: z.object({
cooldownMs: z.number(),
minXp: z.number(),
maxXp: z.number(),
})
}),
economy: z.object({
daily: z.object({
amount: bigIntStringSchema,
streakBonus: bigIntStringSchema,
weeklyBonus: bigIntStringSchema,
cooldownMs: z.number(),
}),
transfers: z.object({
allowSelfTransfer: z.boolean(),
minAmount: bigIntStringSchema,
}),
exam: z.object({
multMin: z.number(),
multMax: z.number(),
})
}),
inventory: z.object({
maxStackSize: bigIntStringSchema,
maxSlots: z.number(),
}),
commands: z.record(z.string(), z.boolean()).optional(),
lootdrop: z.object({
activityWindowMs: z.number(),
minMessages: z.number(),
spawnChance: z.number(),
cooldownMs: z.number(),
reward: z.object({
min: z.number(),
max: z.number(),
currency: z.string(),
})
}),
studentRole: z.string().optional(),
visitorRole: z.string().optional(),
colorRoles: z.array(z.string()).default([]),
welcomeChannelId: z.string().optional(),
welcomeMessage: z.string().optional(),
feedbackChannelId: z.string().optional(),
terminal: z.object({
channelId: z.string(),
messageId: z.string()
}).optional(),
moderation: z.object({
prune: z.object({
maxAmount: z.number(),
confirmThreshold: z.number(),
batchSize: z.number(),
batchDelayMs: z.number(),
}),
cases: z.object({
dmOnWarn: z.boolean(),
logChannelId: z.string().optional(),
autoTimeoutThreshold: z.number().optional()
})
}),
trivia: z.object({
entryFee: bigIntStringSchema,
rewardMultiplier: z.number(),
timeoutSeconds: z.number(),
cooldownMs: z.number(),
categories: z.array(z.number()).default([]),
difficulty: z.enum(['easy', 'medium', 'hard', 'random']),
}).optional(),
system: z.record(z.string(), z.any()).optional(),
});
export type FormValues = z.infer<typeof formSchema>;
export interface ConfigMeta {
roles: { id: string, name: string, color: string }[];
channels: { id: string, name: string, type: number }[];
commands: { name: string, category: string }[];
}
export const toSelectValue = (v: string | undefined | null) => v || NONE_VALUE;
export const fromSelectValue = (v: string) => v === NONE_VALUE ? "" : v;
export function useSettings() {
const [meta, setMeta] = useState<ConfigMeta | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as any,
defaultValues: {
economy: {
daily: { amount: "0", streakBonus: "0", weeklyBonus: "0", cooldownMs: 0 },
transfers: { minAmount: "0", allowSelfTransfer: false },
exam: { multMin: 1, multMax: 1 }
},
leveling: { base: 100, exponent: 1.5, chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 } },
inventory: { maxStackSize: "1", maxSlots: 10 },
moderation: {
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
cases: { dmOnWarn: true }
},
lootdrop: {
spawnChance: 0.05,
minMessages: 10,
cooldownMs: 300000,
activityWindowMs: 600000,
reward: { min: 100, max: 500, currency: "AU" }
}
}
});
const loadSettings = useCallback(async () => {
setLoading(true);
try {
const [config, metaData] = await Promise.all([
fetch("/api/settings").then(res => res.json()),
fetch("/api/settings/meta").then(res => res.json())
]);
form.reset(config as any);
setMeta(metaData);
} catch (err) {
toast.error("Failed to load settings", {
description: "Unable to fetch bot configuration. Please try again."
});
console.error(err);
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const saveSettings = async (data: FormValues) => {
setIsSaving(true);
try {
const response = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error("Failed to save");
toast.success("Settings saved successfully", {
description: "Bot configuration has been updated and reloaded."
});
// Reload settings to ensure we have the latest state
await loadSettings();
} catch (error) {
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);
}
};
return {
form,
meta,
loading,
isSaving,
saveSettings,
loadSettings
};
}

View File

@@ -1,61 +0,0 @@
import { useEffect, useState, useRef } from "react";
import type { DashboardStats } from "@shared/modules/dashboard/dashboard.types";
export function useSocket() {
const [isConnected, setIsConnected] = useState(false);
const [stats, setStats] = useState<DashboardStats | null>(null);
const socketRef = useRef<WebSocket | null>(null);
useEffect(() => {
// Determine WS protocol based on current page schema
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
const wsUrl = `${protocol}//${host}/ws`;
function connect() {
const ws = new WebSocket(wsUrl);
socketRef.current = ws;
ws.onopen = () => {
console.log("Connected to dashboard websocket");
setIsConnected(true);
};
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === "STATS_UPDATE") {
setStats(payload.data);
}
} catch (err) {
console.error("Failed to parse WS message", err);
}
};
ws.onclose = () => {
console.log("Disconnected from dashboard websocket");
setIsConnected(false);
// Simple reconnect logic
setTimeout(connect, 3000);
};
ws.onerror = (err) => {
console.error("WebSocket error:", err);
ws.close();
};
}
connect();
return () => {
if (socketRef.current) {
// Prevent reconnect on unmount
socketRef.current.onclose = null;
socketRef.current.close();
}
};
}, []);
return { isConnected, stats };
}

View File

@@ -1 +0,0 @@
@import "../styles/globals.css";

View File

@@ -1,18 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aurora Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

View File

@@ -1,18 +0,0 @@
/**
* Web server entry point.
*
* This file can be run directly for standalone development:
* bun --hot src/index.ts
*
* Or the server can be started in-process by importing from ./server.ts
*/
import { createWebServer } from "./server";
// Auto-start when run directly
const instance = await createWebServer({
port: Number(process.env.WEB_PORT) || 3000,
hostname: process.env.WEB_HOST || "localhost",
});
console.log(`🌐 Web server is running at ${instance.url}`);

View File

@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,152 +0,0 @@
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<QuestListItem[]>([]);
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) {
setIsRefreshing(true);
} else {
setIsInitialLoading(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 {
setIsInitialLoading(false);
setIsRefreshing(false);
}
}, []);
React.useEffect(() => {
fetchQuests(false);
}, [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(true);
toast.success("Quest list updated", {
description: "The quest inventory has been refreshed.",
});
};
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
badge="Quest Management"
title="Quests"
description="Create and manage quests for the Aurora RPG students."
/>
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
<QuestTable
quests={quests}
isInitialLoading={isInitialLoading}
isRefreshing={isRefreshing}
onRefresh={() => fetchQuests(true)}
onDelete={handleDeleteQuest}
onEdit={handleEditQuest}
/>
</div>
<div className="animate-in fade-in slide-up duration-700" ref={formRef}>
<QuestForm
initialData={editingQuest || undefined}
onUpdate={handleQuestUpdated}
onCancel={handleFormCancel}
/>
</div>
</main>
);
}
export default AdminQuests;

View File

@@ -1,382 +0,0 @@
import React from "react";
import { Badge } from "../components/ui/badge";
import { Card, CardHeader, CardTitle, CardContent, CardDescription, CardFooter } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Switch } from "../components/ui/switch";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../components/ui/tooltip";
import { FeatureCard } from "../components/feature-card";
import { InfoCard } from "../components/info-card";
import { SectionHeader } from "../components/section-header";
import { TestimonialCard } from "../components/testimonial-card";
import { StatCard } from "../components/stat-card";
import { LootdropCard } from "../components/lootdrop-card";
import { LeaderboardCard, type LeaderboardData } from "../components/leaderboard-card";
import { ActivityChart } from "../components/activity-chart";
import { RecentActivity } from "../components/recent-activity";
import { QuestForm } from "../components/quest-form";
import { Activity, Coins, Flame, Trophy, Check, User, Mail, Shield, Bell } from "lucide-react";
import { type RecentEvent, type ActivityData } from "@shared/modules/dashboard/dashboard.types";
// Mock Data
const mockEvents: RecentEvent[] = [
{ type: 'success', message: 'User leveled up to 5', timestamp: new Date(Date.now() - 1000 * 60 * 5), icon: '⬆️' },
{ type: 'info', message: 'New user joined', timestamp: new Date(Date.now() - 1000 * 60 * 15), icon: '👋' },
{ type: 'warn', message: 'Failed login attempt', timestamp: new Date(Date.now() - 1000 * 60 * 60), icon: '⚠️' }
];
const mockActivityData: ActivityData[] = Array.from({ length: 24 }).map((_, i) => {
const d = new Date();
d.setHours(d.getHours() - (23 - i));
d.setMinutes(0, 0, 0);
return {
hour: d.toISOString(),
commands: Math.floor(Math.random() * 100) + 20,
transactions: Math.floor(Math.random() * 60) + 10
};
});
const mockLeaderboardData: LeaderboardData = {
topLevels: [
{ username: "StellarMage", level: 99 },
{ username: "MoonWalker", level: 85 },
{ username: "SunChaser", level: 72 },
],
topWealth: [
{ username: "GoldHoarder", balance: "1000000" },
{ username: "MerchantKing", balance: "750000" },
{ username: "LuckyLooter", balance: "500000" },
],
topNetWorth: [
{ username: "MerchantKing", netWorth: "1500000" },
{ username: "GoldHoarder", netWorth: "1250000" },
{ username: "LuckyLooter", netWorth: "850000" },
]
};
export function DesignSystem() {
return (
<div className="pt-8 px-8 max-w-7xl mx-auto space-y-8 text-center md:text-left pb-24">
{/* Header Section */}
<header className="space-y-4 animate-in fade-in">
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-4">
<div className="space-y-2">
<Badge variant="aurora" className="mb-2">v2.0.0-solaris</Badge>
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tight text-primary glow-text">
Aurora Design System
</h1>
<p className="text-xl text-muted-foreground max-w-2xl">
The Solaris design language. A cohesive collection of celestial components,
glassmorphic surfaces, and radiant interactions.
</p>
</div>
<div className="hidden md:block">
<div className="size-32 rounded-full bg-aurora opacity-20 blur-3xl animate-pulse" />
</div>
</div>
</header>
<Tabs defaultValue="foundations" className="space-y-8 animate-in slide-up delay-100">
<div className="flex items-center justify-center md:justify-start">
<TabsList className="grid w-full max-w-md grid-cols-3">
<TabsTrigger value="foundations">Foundations</TabsTrigger>
<TabsTrigger value="components">Components</TabsTrigger>
<TabsTrigger value="patterns">Patterns</TabsTrigger>
</TabsList>
</div>
{/* FOUNDATIONS TAB */}
<TabsContent value="foundations" className="space-y-12">
{/* Color Palette */}
<section className="space-y-6">
<div className="flex items-center gap-4">
<div className="h-px bg-border flex-1" />
<h2 className="text-2xl font-bold text-foreground">Color Palette</h2>
<div className="h-px bg-border flex-1" />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
<ColorSwatch label="Primary" color="bg-primary" text="text-primary-foreground" />
<ColorSwatch label="Secondary" color="bg-secondary" text="text-secondary-foreground" />
<ColorSwatch label="Background" color="bg-background" border />
<ColorSwatch label="Card" color="bg-card" border />
<ColorSwatch label="Accent" color="bg-accent" />
<ColorSwatch label="Muted" color="bg-muted" />
<ColorSwatch label="Destructive" color="bg-destructive" text="text-white" />
</div>
</section>
{/* Gradients & Special Effects */}
<section className="space-y-6">
<div className="flex items-center gap-4">
<div className="h-px bg-border flex-1" />
<h2 className="text-2xl font-bold text-foreground">Gradients & Effects</h2>
<div className="h-px bg-border flex-1" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 text-center">
<div className="space-y-4">
<h3 className="text-xl font-medium text-muted-foreground">The Solaris Gradient</h3>
<div className="h-32 w-full rounded-xl bg-aurora-page sun-flare flex items-center justify-center border border-border hover-glow transition-all">
<span className="text-primary font-bold text-2xl">Celestial Void</span>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-medium text-muted-foreground">Glassmorphism</h3>
<div className="h-32 w-full rounded-xl glass-card flex items-center justify-center p-6 bg-[url('https://images.unsplash.com/photo-1534796636912-3b95b3ab5986?auto=format&fit=crop&q=80&w=2342')] bg-cover bg-center overflow-hidden">
<div className="glass-card p-4 rounded-lg text-center w-full hover-lift transition-all backdrop-blur-md">
<span className="font-bold">Frosted Celestial Glass</span>
</div>
</div>
</div>
</div>
</section>
{/* Typography */}
<section className="space-y-8">
<div className="flex items-center gap-4">
<div className="h-px bg-border flex-1" />
<h2 className="text-2xl font-bold text-foreground">Typography</h2>
<div className="h-px bg-border flex-1" />
</div>
<div className="space-y-2 border border-border/50 rounded-xl p-8 bg-card/50">
<TypographyRow step="-2" className="text-step--2" label="Step -2 (Small Print)" />
<TypographyRow step="-1" className="text-step--1" label="Step -1 (Small)" />
<TypographyRow step="0" className="text-step-0" label="Step 0 (Base / Body)" />
<TypographyRow step="1" className="text-step-1" label="Step 1 (H4 / Subhead)" />
<TypographyRow step="2" className="text-step-2" label="Step 2 (H3 / Section)" />
<TypographyRow step="3" className="text-step-3 text-primary" label="Step 3 (H2 / Header)" />
<TypographyRow step="4" className="text-step-4 text-primary" label="Step 4 (H1 / Title)" />
<TypographyRow step="5" className="text-step-5 text-primary font-black" label="Step 5 (Display)" />
</div>
</section>
</TabsContent>
{/* COMPONENTS TAB */}
<TabsContent value="components" className="space-y-12">
{/* Buttons & Badges */}
<section className="space-y-6">
<SectionTitle title="Buttons & Badges" />
<Card className="p-8">
<div className="space-y-8">
<div className="space-y-4">
<Label>Buttons</Label>
<div className="flex flex-wrap gap-4">
<Button variant="default">Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="link">Link</Button>
<Button variant="aurora">Aurora</Button>
<Button variant="glass">Glass</Button>
</div>
</div>
<div className="space-y-4">
<Label>Badges</Label>
<div className="flex flex-wrap gap-4">
<Badge>Primary</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="aurora">Aurora</Badge>
<Badge variant="glass">Glass</Badge>
</div>
</div>
</div>
</Card>
</section>
{/* Form Controls */}
<section className="space-y-6">
<SectionTitle title="Form Controls" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="p-6 space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input id="email" placeholder="enter@email.com" type="email" />
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea id="bio" placeholder="Tell us about yourself..." />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="notifications">Enable Notifications</Label>
<Switch id="notifications" />
</div>
</Card>
<Card className="p-6 space-y-6">
<div className="space-y-2">
<Label>Role Selection</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Administrator</SelectItem>
<SelectItem value="mod">Moderator</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tooltip Demo</Label>
<div className="p-4 border border-dashed rounded-lg flex items-center justify-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Hover Me</Button>
</TooltipTrigger>
<TooltipContent>
<p>This is a glowing tooltip!</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
</div>
</section>
{/* Cards & Containers */}
<section className="space-y-6">
<SectionTitle title="Cards & Containers" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="hover-lift">
<CardHeader>
<CardTitle>Standard Card</CardTitle>
<CardDescription>Default glassmorphic style</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">The default card component comes with built-in separation and padding.</p>
</CardContent>
<CardFooter>
<Button size="sm" variant="secondary" className="w-full">Action</Button>
</CardFooter>
</Card>
<Card className="bg-aurora/10 border-primary/20 hover-glow">
<CardHeader>
<CardTitle className="text-primary">Highlighted Card</CardTitle>
<CardDescription>Active or featured state</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Use this variation to draw attention to specific content blocks.</p>
</CardContent>
</Card>
<Card className="border-dashed shadow-none bg-transparent">
<CardHeader>
<CardTitle>Ghost/Dashed Card</CardTitle>
<CardDescription>Placeholder or empty state</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-center py-8">
<div className="bg-muted p-4 rounded-full">
<Activity className="size-6 text-muted-foreground" />
</div>
</CardContent>
</Card>
</div>
</section>
</TabsContent>
{/* PATTERNS TAB */}
<TabsContent value="patterns" className="space-y-12">
{/* Dashboard Widgets */}
<section className="space-y-6">
<SectionTitle title="Dashboard Widgets" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Total XP"
value="1,240,500"
subtitle="+12% from last week"
icon={Trophy}
isLoading={false}
iconClassName="text-yellow-500"
/>
<StatCard
title="Active Users"
value="3,405"
subtitle="Currently online"
icon={User}
isLoading={false}
iconClassName="text-blue-500"
/>
<StatCard
title="System Load"
value="42%"
subtitle="Optimal performance"
icon={Activity}
isLoading={false}
iconClassName="text-green-500"
/>
</div>
</section>
{/* Complex Lists */}
<section className="space-y-6">
<SectionTitle title="Complex Lists & Charts" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<RecentActivity
events={mockEvents}
isLoading={false}
className="h-[400px]"
/>
<LeaderboardCard
data={mockLeaderboardData}
isLoading={false}
className="h-[400px]"
/>
</div>
</section>
{/* Application Patterns */}
<section className="space-y-6">
<SectionTitle title="Application Forms" />
<div className="max-w-xl mx-auto">
<QuestForm />
</div>
</section>
</TabsContent>
</Tabs>
</div>
);
}
function SectionTitle({ title }: { title: string }) {
return (
<div className="flex items-center gap-4 py-4">
<div className="h-0.5 bg-linear-to-r from-transparent via-primary/50 to-transparent flex-1" />
<h2 className="text-xl font-bold text-foreground/80 uppercase tracking-widest">{title}</h2>
<div className="h-0.5 bg-linear-to-r from-transparent via-primary/50 to-transparent flex-1" />
</div>
);
}
function TypographyRow({ step, className, label }: { step: string, className: string, label: string }) {
return (
<div className="flex flex-col md:flex-row md:items-baseline gap-4 border-b border-border/50 pb-4 last:border-0 last:pb-0">
<span className="text-xs font-mono text-muted-foreground w-24 shrink-0">var(--step-{step})</span>
<p className={`${className} font-medium truncate`}>{label}</p>
</div>
);
}
function ColorSwatch({ label, color, text = "text-foreground", border = false }: { label: string, color: string, text?: string, border?: boolean }) {
return (
<div className="group space-y-2 cursor-pointer">
<div className={`h-24 w-full rounded-xl ${color} ${border ? 'border border-border' : ''} flex items-end p-3 shadow-lg group-hover:scale-105 transition-transform duration-300 relative overflow-hidden`}>
<div className="absolute inset-0 bg-linear-to-b from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<span className={`text-xs font-bold uppercase tracking-widest ${text} relative z-10`}>{label}</span>
</div>
<div className="flex justify-between text-xs text-muted-foreground px-1">
<span>{color.replace('bg-', '')}</span>
</div>
</div>
);
}
export default DesignSystem;

View File

@@ -1,219 +0,0 @@
import React from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { FeatureCard } from "../components/feature-card";
import { InfoCard } from "../components/info-card";
import { SectionHeader } from "../components/section-header";
import { TestimonialCard } from "../components/testimonial-card";
import {
GraduationCap,
Coins,
Package,
ShieldCheck,
Zap,
Trophy
} from "lucide-react";
export function Home() {
return (
<>
{/* Hero Section */}
<header className="relative pt-16 pb-20 px-8 text-center max-w-5xl mx-auto space-y-10">
<Badge variant="glass" className="mb-4 py-1.5 px-4 text-step--1 animate-in zoom-in spin-in-12 duration-700 delay-100">
The Ultimate Academic Strategy RPG
</Badge>
<h1 className="flex flex-col items-center justify-center text-step-5 font-black tracking-tighter leading-[0.9] text-primary drop-shadow-sm">
<span className="animate-in slide-in-from-bottom-8 fade-in duration-700 delay-200 fill-mode-both">
Rise to the Top
</span>
<span className="animate-in slide-in-from-bottom-8 fade-in duration-700 delay-300 fill-mode-both">
of the Elite Academy
</span>
</h1>
<p className="text-step--1 md:text-step-0 text-muted-foreground max-w-2xl mx-auto leading-relaxed animate-in slide-in-from-bottom-4 fade-in duration-700 delay-500 fill-mode-both">
Aurora is a competitive academic RPG bot where students are assigned to Classes A through D, vying for supremacy in a high-stakes elite school setting.
</p>
<div className="flex flex-wrap justify-center gap-6 pt-6 animate-in zoom-in-50 fade-in duration-700 delay-700 fill-mode-both">
<Button className="bg-primary text-primary-foreground active-press font-bold px-6">
Join our Server
</Button>
<Button className="bg-secondary text-primary-foreground active-press font-bold px-6">
Explore Documentation
</Button>
</div>
</header>
{/* Features Section (Bento Grid) */}
<section className="px-8 pb-32 max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-6 lg:grid-cols-4 gap-6">
{/* Class System */}
<FeatureCard
className="md:col-span-3 lg:col-span-2 delay-400"
title="Class Constellations"
category="Immersion"
description="You are assigned to one of the four constellations: Class A, B, C, or D. Work with your classmates to rise through the rankings and avoid expulsion."
icon={<GraduationCap className="w-32 h-32 text-primary" />}
>
<div className="flex gap-2 pt-2">
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">Constellation Units</Badge>
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">Special Exams</Badge>
</div>
</FeatureCard>
{/* Economy */}
<FeatureCard
className="md:col-span-3 lg:col-span-1 delay-500"
title="Astral Units"
category="Commerce"
description="Earn Astral Units through exams, tasks, and achievements. Use them to purchase privileges or influence test results."
icon={<Coins className="w-20 h-20 text-secondary" />}
/>
{/* Inventory */}
<FeatureCard
className="md:col-span-2 lg:col-span-1 delay-500"
title="Inventory"
category="Management"
description="Manage vast collections of items, from common materials to legendary artifacts with unique rarities."
icon={<Package className="w-20 h-20 text-primary" />}
/>
{/* Exams */}
<FeatureCard
className="md:col-span-2 lg:col-span-1 delay-600"
title="Special Exams"
category="Academics"
description="Participate in complex written and physical exams. Strategy and cooperation are key to survival."
>
<div className="space-y-2 pt-2">
<div className="h-1.5 w-full bg-secondary/20 rounded-full overflow-hidden">
<div className="h-full bg-aurora w-[65%]" />
</div>
<div className="flex justify-between text-[10px] text-muted-foreground font-bold uppercase tracking-wider">
<span>Island Exam</span>
<span>Active</span>
</div>
</div>
</FeatureCard>
{/* Trading & Social */}
<FeatureCard
className="md:col-span-3 lg:col-span-2 delay-400"
title="Class Constellations"
category="Immersion"
description="You are assigned to one of the four constellations: Class A, B, C, or D. Work with your classmates to rise through the rankings and avoid expulsion."
icon={<GraduationCap className="w-32 h-32 text-primary" />}
>
<div className="flex gap-2 pt-2">
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">Constellation Units</Badge>
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">Special Exams</Badge>
</div>
</FeatureCard>
{/* Tech Stack */}
<FeatureCard
className="md:col-span-6 lg:col-span-1 delay-700 bg-primary/5"
title="Modern Core"
category="Technology"
description="Built for speed and reliability using the most modern tech stack."
>
<div className="flex flex-wrap gap-2 text-[10px] font-bold">
<span className="px-2 py-1 bg-black text-white rounded">BUN 1.0+</span>
<span className="px-2 py-1 bg-[#5865F2] text-white rounded">DISCORD.JS</span>
<span className="px-2 py-1 bg-[#C5F74F] text-black rounded">DRIZZLE</span>
<span className="px-2 py-1 bg-[#336791] text-white rounded">POSTGRES</span>
</div>
</FeatureCard>
</div>
</section>
{/* Unique Features Section */}
<section className="px-8 py-20 bg-primary/5 border-y border-border/50">
<div className="max-w-7xl mx-auto space-y-16">
<SectionHeader
badge="Why Aurora?"
title="More Than Just A Game"
description="Aurora isn't just about leveling up. It's a social experiment designed to test your strategic thinking, diplomacy, and resource management."
/>
<div className="grid md:grid-cols-3 gap-8">
<InfoCard
icon={<Trophy className="w-6 h-6" />}
title="Merit-Based Society"
description="Your class standing determines your privileges. Earn points to rise, or lose them and face the consequences of falling behind."
iconWrapperClassName="bg-primary/20 text-primary"
/>
<InfoCard
icon={<ShieldCheck className="w-6 h-6" />}
title="Psychological Warfare"
description="Form alliances, uncover spies, and execute strategies during Special Exams where trust is the most valuable currency."
iconWrapperClassName="bg-secondary/20 text-secondary"
/>
<InfoCard
icon={<Zap className="w-6 h-6" />}
title="Dynamic World"
description="The school rules change based on the actions of the student body. Your decisions shape the future of the academy."
iconWrapperClassName="bg-primary/20 text-primary"
/>
</div>
</div>
</section>
{/* Testimonials Section */}
<section className="px-8 py-32 max-w-7xl mx-auto">
<SectionHeader
badge="Student Voices"
title="Overheard at the Academy"
/>
<div className="grid md:grid-cols-3 gap-6">
<TestimonialCard
quote="I thought I could just grind my way to the top like other RPGs. I was wrong. The Class D exams forced me to actually talk to people and strategize."
author="Alex K."
role="Class D Representative"
avatarGradient="bg-gradient-to-br from-blue-500 to-purple-500"
/>
<TestimonialCard
className="mt-8 md:mt-0"
quote="The economy systems are surprisingly deep. Manipulating the market during exam week is honestly the most fun I've had in a Discord server."
author="Sarah M."
role="Class B Treasurer"
avatarGradient="bg-gradient-to-br from-emerald-500 to-teal-500"
/>
<TestimonialCard
quote="Aurora creates an environment where 'elite' actually means something. Maintaining Class A status is stressful but incredibly rewarding."
author="James R."
role="Class A President"
avatarGradient="bg-gradient-to-br from-rose-500 to-orange-500"
/>
</div>
</section>
{/* Footer */}
<footer className="py-20 px-8 border-t border-border/50 bg-background/50">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-8">
<div className="flex flex-col items-center md:items-start gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-aurora" />
<span className="text-lg font-bold text-primary">Aurora</span>
</div>
<p className="text-step--1 text-muted-foreground text-center md:text-left">
© 2026 Aurora Project. Licensed under MIT.
</p>
</div>
<div className="flex gap-8 text-step--1 font-medium text-muted-foreground">
<a href="#" className="hover:text-primary transition-colors">Documentation</a>
<a href="#" className="hover:text-primary transition-colors">Support Server</a>
<a href="#" className="hover:text-primary transition-colors">Privacy Policy</a>
</div>
</div>
</footer>
</>
);
}
export default Home;

View File

@@ -1,20 +0,0 @@
import React from "react";
import { SectionHeader } from "../../components/section-header";
export function AdminItems() {
return (
<main className="pt-8 px-8 pb-12 max-w-7xl mx-auto space-y-12">
<SectionHeader
badge="Item Management"
title="Items"
description="Create and manage items for the Aurora RPG."
/>
<div className="animate-in fade-in slide-up duration-700">
<p className="text-muted-foreground">Items management coming soon...</p>
</div>
</main>
);
}
export default AdminItems;

View File

@@ -1,164 +0,0 @@
import React, { useState } from "react";
import { SectionHeader } from "../../components/section-header";
import { useSocket } from "../../hooks/use-socket";
import { StatCard } from "../../components/stat-card";
import { ActivityChart } from "../../components/activity-chart";
import { LootdropCard } from "../../components/lootdrop-card";
import { LeaderboardCard } from "../../components/leaderboard-card";
import { RecentActivity } from "../../components/recent-activity";
import { CommandsDrawer } from "../../components/commands-drawer";
import { Server, Users, Terminal, Activity, Coins, TrendingUp, Flame, Package } from "lucide-react";
import { cn } from "../../lib/utils";
export function AdminOverview() {
const { isConnected, stats } = useSocket();
const [commandsDrawerOpen, setCommandsDrawerOpen] = useState(false);
return (
<>
<main className="pt-8 px-8 pb-12 max-w-7xl mx-auto space-y-8">
<SectionHeader
badge="Admin Dashboard"
title="Overview"
description="Monitor your Aurora RPG server statistics and activity."
/>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 animate-in fade-in slide-up duration-700">
<StatCard
title="Total Servers"
icon={Server}
isLoading={!stats}
value={stats?.guilds.count.toLocaleString()}
subtitle={stats?.guilds.changeFromLastMonth
? `${stats.guilds.changeFromLastMonth > 0 ? '+' : ''}${stats.guilds.changeFromLastMonth} from last month`
: "Active Guilds"
}
/>
<StatCard
title="Total Users"
icon={Users}
isLoading={!stats}
value={stats?.users.total.toLocaleString()}
subtitle={stats ? `${stats.users.active.toLocaleString()} active now` : undefined}
className="delay-100"
/>
<StatCard
title="Commands"
icon={Terminal}
isLoading={!stats}
value={stats?.commands.total.toLocaleString()}
subtitle={stats ? `${stats.commands.active} active · ${stats.commands.disabled} disabled` : undefined}
className="delay-200"
onClick={() => setCommandsDrawerOpen(true)}
/>
<StatCard
title="System Ping"
icon={Activity}
isLoading={!stats}
value={stats ? `${Math.round(stats.ping.avg)}ms` : undefined}
subtitle="Average latency"
className="delay-300"
valueClassName={stats ? cn(
"transition-colors duration-300",
stats.ping.avg < 100 ? "text-emerald-500" :
stats.ping.avg < 200 ? "text-yellow-500" : "text-red-500"
) : undefined}
/>
</div>
{/* Activity Chart */}
<div className="animate-in fade-in slide-up delay-400">
<ActivityChart />
</div>
<div className="grid gap-8 lg:grid-cols-3 animate-in fade-in slide-up delay-500">
{/* Economy Stats */}
<div className="lg:col-span-2 space-y-6">
<div>
<h2 className="text-xl font-semibold tracking-tight mb-4">Economy Overview</h2>
<div className="grid gap-4 md:grid-cols-2">
<StatCard
title="Total Wealth"
icon={Coins}
isLoading={!stats}
value={stats ? `${Number(stats.economy.totalWealth).toLocaleString()} AU` : undefined}
subtitle="Astral Units in circulation"
valueClassName="text-primary"
iconClassName="text-primary"
/>
<StatCard
title="Items Circulating"
icon={Package}
isLoading={!stats}
value={stats?.economy.totalItems?.toLocaleString()}
subtitle="Total items owned by users"
className="delay-75"
valueClassName="text-blue-500"
iconClassName="text-blue-500"
/>
<StatCard
title="Average Level"
icon={TrendingUp}
isLoading={!stats}
value={stats ? `Lvl ${stats.economy.avgLevel}` : undefined}
subtitle="Global player average"
className="delay-100"
valueClassName="text-secondary"
iconClassName="text-secondary"
/>
<StatCard
title="Top /daily Streak"
icon={Flame}
isLoading={!stats}
value={stats?.economy.topStreak}
subtitle="Days daily streak"
className="delay-200"
valueClassName="text-destructive"
iconClassName="text-destructive"
/>
</div>
</div>
<LeaderboardCard
data={stats?.leaderboards}
isLoading={!stats}
className="w-full"
/>
</div>
{/* Recent Activity & Lootdrops */}
<div className="space-y-6">
<LootdropCard
drop={stats?.activeLootdrops?.[0]}
state={stats?.lootdropState}
isLoading={!stats}
/>
<div className="h-[calc(100%-12rem)] min-h-[400px]">
<h2 className="text-xl font-semibold tracking-tight mb-4">Live Feed</h2>
<RecentActivity
events={stats?.recentEvents || []}
isLoading={!stats}
className="h-full"
/>
</div>
</div>
</div>
</main>
{/* Commands Drawer */}
<CommandsDrawer
open={commandsDrawerOpen}
onOpenChange={setCommandsDrawerOpen}
/>
</>
);
}
export default AdminOverview;

View File

@@ -1,290 +0,0 @@
import React from "react";
import { useSettingsForm } from "./SettingsLayout";
import { FormField, FormItem, FormLabel, FormControl, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Users, Backpack, Sparkles, CreditCard, MessageSquare } from "lucide-react";
export function EconomySettings() {
const { form } = useSettingsForm();
return (
<div className="space-y-6 animate-in fade-in slide-up duration-500">
<Accordion type="multiple" className="w-full space-y-4" defaultValue={["daily", "inventory"]}>
<AccordionItem value="daily" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-yellow-500/10 flex items-center justify-center text-yellow-500">
<Users className="w-4 h-4" />
</div>
<span className="font-bold">Daily Rewards</span>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pb-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="economy.daily.amount"
render={({ field }) => (
<FormItem>
<FormLabel>Base Amount</FormLabel>
<FormControl>
<Input {...field} type="text" className="bg-background/50" placeholder="100" />
</FormControl>
<FormDescription className="text-xs">Reward (AU)</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="economy.daily.streakBonus"
render={({ field }) => (
<FormItem>
<FormLabel>Streak Bonus</FormLabel>
<FormControl>
<Input {...field} type="text" className="bg-background/50" placeholder="10" />
</FormControl>
<FormDescription className="text-xs">Bonus/day</FormDescription>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="economy.daily.weeklyBonus"
render={({ field }) => (
<FormItem>
<FormLabel>Weekly Bonus</FormLabel>
<FormControl>
<Input {...field} type="text" className="bg-background/50" placeholder="50" />
</FormControl>
<FormDescription className="text-xs">7-day bonus</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="economy.daily.cooldownMs"
render={({ field }) => (
<FormItem>
<FormLabel>Cooldown (ms)</FormLabel>
<FormControl>
<Input {...field} type="number" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="inventory" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-orange-500/10 flex items-center justify-center text-orange-500">
<Backpack className="w-4 h-4" />
</div>
<span className="font-bold">Inventory</span>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pb-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="inventory.maxStackSize"
render={({ field }) => (
<FormItem>
<FormLabel>Max Stack Size</FormLabel>
<FormControl>
<Input {...field} type="text" className="bg-background/50" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="inventory.maxSlots"
render={({ field }) => (
<FormItem>
<FormLabel>Max Slots</FormLabel>
<FormControl>
<Input {...field} type="number" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="leveling" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500">
<Sparkles className="w-4 h-4" />
</div>
<span className="font-bold">Leveling & XP</span>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pb-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="leveling.base"
render={({ field }) => (
<FormItem>
<FormLabel>Base XP</FormLabel>
<FormControl>
<Input {...field} type="number" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="leveling.exponent"
render={({ field }) => (
<FormItem>
<FormLabel>Exponent</FormLabel>
<FormControl>
<Input {...field} type="number" step="0.1" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="bg-muted/30 p-4 rounded-lg space-y-3">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<MessageSquare className="w-3 h-3" /> Chat XP
</h4>
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="leveling.chat.minXp"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel className="text-xs">Min</FormLabel>
<FormControl>
<Input {...field} type="number" className="h-9 text-sm" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="leveling.chat.maxXp"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel className="text-xs">Max</FormLabel>
<FormControl>
<Input {...field} type="number" className="h-9 text-sm" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="leveling.chat.cooldownMs"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel className="text-xs">Cooldown</FormLabel>
<FormControl>
<Input {...field} type="number" className="h-9 text-sm" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="transfers" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center text-green-500">
<CreditCard className="w-4 h-4" />
</div>
<span className="font-bold">Transfers</span>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pb-4">
<FormField
control={form.control}
name="economy.transfers.allowSelfTransfer"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-border/50 bg-background/50 p-4">
<div className="space-y-0.5">
<FormLabel className="text-sm font-medium">Allow Self-Transfer</FormLabel>
<FormDescription className="text-xs">
Permit users to transfer currency to themselves.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="economy.transfers.minAmount"
render={({ field }) => (
<FormItem>
<FormLabel>Minimum Transfer Amount</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="1" className="bg-background/50" />
</FormControl>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="exam" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500">
<Sparkles className="w-4 h-4" />
</div>
<span className="font-bold">Exams</span>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pb-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="economy.exam.multMin"
render={({ field }) => (
<FormItem>
<FormLabel>Min Multiplier</FormLabel>
<FormControl>
<Input {...field} type="number" step="0.1" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="economy.exam.multMax"
render={({ field }) => (
<FormItem>
<FormLabel>Max Multiplier</FormLabel>
<FormControl>
<Input {...field} type="number" step="0.1" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
</FormControl>
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -1,149 +0,0 @@
import React from "react";
import { useSettingsForm } from "./SettingsLayout";
import { FormField, FormItem, FormLabel, FormControl, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { MessageSquare, Terminal } from "lucide-react";
import { fromSelectValue, toSelectValue, NONE_VALUE } from "@/hooks/use-settings";
export function GeneralSettings() {
const { form, meta } = useSettingsForm();
return (
<div className="space-y-8 animate-in fade-in slide-up duration-500">
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
<MessageSquare className="w-3 h-3 mr-1" /> Onboarding
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="welcomeChannelId"
render={({ field }) => (
<FormItem className="glass-card p-5 rounded-xl border border-border/50">
<FormLabel className="text-foreground/80">Welcome Channel</FormLabel>
<Select onValueChange={v => field.onChange(fromSelectValue(v))} value={toSelectValue(field.value || null)}>
<FormControl>
<SelectTrigger className="bg-background/50 border-border/50">
<SelectValue placeholder="Select a channel" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}>None</SelectItem>
{meta?.channels
.filter(c => c.type === 0)
.map(c => (
<SelectItem key={c.id} value={c.id}>#{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Where to send welcome messages.</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="welcomeMessage"
render={({ field }) => (
<FormItem className="glass-card p-5 rounded-xl border border-border/50">
<FormLabel className="text-foreground/80">Welcome Message Template</FormLabel>
<FormControl>
<Textarea
{...field}
value={field.value || ""}
placeholder="Welcome {user}!"
className="min-h-[100px] font-mono text-xs bg-background/50 border-border/50 focus:border-primary/50 focus:ring-primary/20 resize-none"
/>
</FormControl>
<FormDescription>Available variables: {"{user}"}, {"{count}"}.</FormDescription>
</FormItem>
)}
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
Channels & Features
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="feedbackChannelId"
render={({ field }) => (
<FormItem className="glass-card p-5 rounded-xl border border-border/50">
<FormLabel className="text-foreground/80">Feedback Channel</FormLabel>
<Select onValueChange={v => field.onChange(fromSelectValue(v))} value={toSelectValue(field.value || null)}>
<FormControl>
<SelectTrigger className="bg-background/50 border-border/50">
<SelectValue placeholder="Select a channel" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}>None</SelectItem>
{meta?.channels.filter(c => c.type === 0).map(c => (
<SelectItem key={c.id} value={c.id}>#{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Where user feedback is sent.</FormDescription>
</FormItem>
)}
/>
<div className="glass-card p-5 rounded-xl border border-border/50 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Terminal className="w-4 h-4 text-muted-foreground" />
<h4 className="font-medium text-sm">Terminal Embed</h4>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="terminal.channelId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs text-muted-foreground uppercase tracking-wide">Channel</FormLabel>
<Select onValueChange={v => field.onChange(fromSelectValue(v))} value={toSelectValue(field.value || null)}>
<FormControl>
<SelectTrigger className="bg-background/50 border-border/50 h-9 text-xs">
<SelectValue placeholder="Select channel" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}>None</SelectItem>
{meta?.channels.filter(c => c.type === 0).map(c => (
<SelectItem key={c.id} value={c.id}>#{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminal.messageId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs text-muted-foreground uppercase tracking-wide">Message ID</FormLabel>
<FormControl>
<Input {...field} value={field.value || ""} placeholder="Message ID" className="font-mono text-xs bg-background/50 border-border/50 h-9" />
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,141 +0,0 @@
import React from "react";
import { useSettingsForm } from "./SettingsLayout";
import { FormField, FormItem, FormLabel, FormControl, FormDescription } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Palette, Users } from "lucide-react";
import { fromSelectValue, toSelectValue } from "@/hooks/use-settings";
export function RolesSettings() {
const { form, meta } = useSettingsForm();
return (
<div className="space-y-8 animate-in fade-in slide-up duration-500">
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
<Users className="w-3 h-3 mr-1" /> System Roles
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="studentRole"
render={({ field }) => (
<FormItem className="glass-card p-5 rounded-xl border border-border/50">
<FormLabel className="font-bold">Student Role</FormLabel>
<Select onValueChange={v => field.onChange(fromSelectValue(v))} value={toSelectValue(field.value || null)}>
<FormControl>
<SelectTrigger className="bg-background/50">
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{meta?.roles.map(r => (
<SelectItem key={r.id} value={r.id}>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full" style={{ background: r.color }} />
{r.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-xs">Default role for new members/students.</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="visitorRole"
render={({ field }) => (
<FormItem className="glass-card p-5 rounded-xl border border-border/50">
<FormLabel className="font-bold">Visitor Role</FormLabel>
<Select onValueChange={v => field.onChange(fromSelectValue(v))} value={toSelectValue(field.value || null)}>
<FormControl>
<SelectTrigger className="bg-background/50">
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{meta?.roles.map(r => (
<SelectItem key={r.id} value={r.id}>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full" style={{ background: r.color }} />
{r.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-xs">Role for visitors/guests.</FormDescription>
</FormItem>
)}
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
<Palette className="w-3 h-3 mr-1" /> Color Roles
</Badge>
</div>
<div className="glass-card p-6 rounded-xl border border-border/50 bg-card/30">
<div className="mb-4">
<FormDescription className="text-sm">
Select roles that users can choose from to set their name color in the bot.
</FormDescription>
</div>
<ScrollArea className="h-[400px] pr-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{meta?.roles.map((role) => (
<FormField
key={role.id}
control={form.control}
name="colorRoles"
render={({ field }) => {
const isSelected = field.value?.includes(role.id);
return (
<FormItem
key={role.id}
className={`flex flex-row items-center space-x-3 space-y-0 p-3 rounded-lg border transition-all cursor-pointer ${
isSelected
? 'bg-primary/10 border-primary/30 ring-1 ring-primary/20'
: 'hover:bg-muted/50 border-transparent'
}`}
>
<FormControl>
<Switch
checked={isSelected}
onCheckedChange={(checked) => {
return checked
? field.onChange([...(field.value || []), role.id])
: field.onChange(
field.value?.filter(
(value: string) => value !== role.id
)
)
}}
/>
</FormControl>
<FormLabel className="font-medium flex items-center gap-2 cursor-pointer w-full text-foreground text-sm">
<span className="w-3 h-3 rounded-full shadow-sm" style={{ background: role.color }} />
{role.name}
</FormLabel>
</FormItem>
)
}}
/>
))}
</div>
</ScrollArea>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More