31 Commits

Author SHA1 Message Date
syntaxbullet
194a032c7f chore(cleanup): remove completed tickets 2026-01-14 18:10:31 +01:00
syntaxbullet
94a5a183d0 feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage 2026-01-14 18:10:13 +01:00
syntaxbullet
c7730b9355 refactor: migrate web server to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
1e20a5a7a0 refactor: migrate bot handlers to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
54944283a3 feat: implement centralized logger with file persistence 2026-01-14 17:58:28 +01:00
syntaxbullet
f79ee6fbc7 refactor: remove completed ticket file 2026-01-14 16:27:49 +01:00
syntaxbullet
915f1bc4ad fix(economy): improve daily cooldown message and consolidate UserError class 2026-01-14 16:26:27 +01:00
syntaxbullet
4af2690bab feat: implement branded discord embeds and versioning 2026-01-14 16:10:23 +01:00
syntaxbullet
6e57ab07e4 chore: update gitiignore 2026-01-14 15:12:51 +01:00
syntaxbullet
3a620a84c5 feat: add trivia category selection and sync trivia fixes 2026-01-11 16:08:11 +01:00
syntaxbullet
7d68652ea5 fix: fix potential issues with trivia command 2026-01-11 15:00:10 +01:00
syntaxbullet
35bd1f58dd feat: trivia command! 2026-01-11 14:37:17 +01:00
syntaxbullet
1cd3dbcd72 agent: update agent workflows 2026-01-09 22:04:40 +01:00
syntaxbullet
c97249f2ca docs: update README with dashboard architecture and ssh tunnel guide 2026-01-09 22:02:09 +01:00
syntaxbullet
0d923491b5 feat: (ui) settings drawers 2026-01-09 19:28:14 +01:00
syntaxbullet
d870ef69d5 feat: (ui) leaderboards 2026-01-09 16:45:36 +01:00
syntaxbullet
682e9d208e feat: more stat components 2026-01-09 16:18:52 +01:00
syntaxbullet
4a691ac71d feat: (ui) first dynamic data 2026-01-09 15:22:13 +01:00
syntaxbullet
1b84dbd36d feat: (ui) new design 2026-01-09 15:12:35 +01:00
syntaxbullet
a5b8d922e3 feat(web): implement full activity page with charts and logs 2026-01-08 23:20:00 +01:00
syntaxbullet
238d9a8803 refactor(web): enhance ui visual polish and ux
- Replace native selects with Shadcn UI Select in Settings
- Increase ActivityChart height for better visibility
- specific Economy Overview card height to fill column
- Add hover/active scale animations to sidebar items
2026-01-08 23:10:14 +01:00
syntaxbullet
713ea07040 feat(ui): use shadcn switch for toggles and remove sidebar user footer 2026-01-08 23:00:44 +01:00
syntaxbullet
bea6c33024 feat(settings): group commands by category in system tab 2026-01-08 22:55:40 +01:00
syntaxbullet
8fe300c8a2 feat(web): add toast notifications for settings save status 2026-01-08 22:47:31 +01:00
syntaxbullet
9caa95a0d8 feat(settings): support toggling disabled commands and auto-reload bot on save 2026-01-08 22:44:48 +01:00
syntaxbullet
c6fd23b5fa feat(dashboard): implement bot settings page with partial updates and serialization fixes 2026-01-08 22:35:46 +01:00
syntaxbullet
d46434de18 feat(dashboard): expand stats & remove admin token auth 2026-01-08 22:14:13 +01:00
syntaxbullet
cf4c28e1df fix : 404 error fix 2026-01-08 21:45:53 +01:00
syntaxbullet
39e405afde chore: polish analytics API logging and typing 2026-01-08 21:39:53 +01:00
syntaxbullet
6763e3c543 fix: address code review findings for analytics and security 2026-01-08 21:39:01 +01:00
syntaxbullet
11e07a0068 feat: implement visual analytics and activity charts 2026-01-08 21:36:19 +01:00
99 changed files with 7272 additions and 1630 deletions

View File

@@ -0,0 +1,51 @@
---
name: create-ticket
description: Create a ticket for a task that needs to be worked on.
---
# Skill: create-ticket
## Purpose
Decompose high-level objectives into "Atomic Tickets" to maximize development velocity and minimize cognitive overhead.
## Execution Rules
1. **Directory Check:** Scan `.agent/work/tickets/`. Determine sequence number `NNN`.
2. **Naming:** `.agent/work/tickets/NNN-brief-description.md`.
3. **The Atomic/Velocity Test:** - Max 3 files modified.
- Max 80 lines of logic.
- Must be verifiable via a single command or test suite.
- If it exceeds these, **split it.**
4. **Context Injection:** Include relevant code snippets or interface definitions directly in the ticket to prevent "context hunting."
5. **No Breaking Changes:** If a ticket changes a shared interface, it must include the refactor for consumers or be split into a "Transition" ticket.
## Ticket Template
### Context & Goal
[Why this matters + the specific problem it solves.]
### Dependencies
- [e.g., Ticket #NNN]
### Affected Files
- `path/to/file_A.ext`: [Specific change description]
- `path/to/file_B.ext`: [Specific change description]
### Technical Constraints & Strategy
- [e.g., Implementation: Use the existing X wrapper instead of a new fetch call.]
- [e.g., Constraint: Maintain backward compatibility with Y.]
### Definition of Done (Binary)
- [ ] Criterion 1 (e.g., `npm test` passes for `X.test.ts`)
- [ ] Criterion 2 (e.g., UI component renders without hydration errors)
- [ ] Criterion 3 (e.g., API response matches the schema in `types.ts`)
### New Test Files
- `path/to/test_A.ext`: [What is being tested]

View File

@@ -0,0 +1,54 @@
---
name: code-review
description: A "Default-to-Fail" audit of codebase changes. Observation only; no file modifications.
---
# Skill: code-review
## Purpose
Protect the codebase from "feature creep," technical debt, and weak validation. This skill assumes the latest changes are flawed until they pass a rigorous audit.
## Execution Rules
1. **Read-Only Protocol:** This is a diagnostic skill. **Under no circumstances should any files be modified.** Provide feedback only.
2. **Default-to-Fail:** Assume the code is broken or insufficient. The burden of proof lies on the code and its tests.
3. **The Atomic Veto:** - Check the diff. If it exceeds 3 files or 80 lines of logic, **Veto immediately.**
- Reason: "Change exceeds atomic threshold; high risk of cognitive load."
4. **Strictness Audit (Tests):**
- **Veto** if assertions are fuzzy (e.g., `toBeTruthy()`).
- **Veto** if there is no "Red Path" (failure case) test.
- **Veto** if the test is "loose" (e.g., doesn't check specific property values).
5. **Direct Feedback:** No sycophancy. Use "Blockers" for issues and "Verdict: APPROVE" only when the code is bulletproof.
## Review Template
### Verdict: [FAIL / APPROVE]
**Primary Blocker:** [One sentence identifying the biggest reason for rejection.]
---
### 1. Atomic Constraint Check
- **Files Changed:** [Count] / 3
- **Logic Lines:** [Count] / 80
- **Status:** [PASS / FAIL (Veto if FAIL)]
### 2. Test Strictness Audit
- **Assertion Quality:** [List specific lines with fuzzy matchers. Demand strict equality.]
- **Failure Coverage:** [Does a test exist for the 'Error/Empty' state? If no, FAIL.]
- **Logic Sync:** [Does the test actually exercise the logic added, or just side effects?]
### 3. Logic & Resilience
- **Unchecked States:** [Identify unhandled nulls, undefineds, or missing error catches.]
- **Efficiency:** [Is there a faster path or a redundant operation?]
### 4. Direct Actionables
_Note: The reviewer does not apply these. The user/agent must create a ticket or apply fixes manually._
1. [Specific fix for Blocker 1]
2. [Specific fix for Blocker 2]

View File

@@ -0,0 +1,30 @@
### Context & Goal
Currently, every command manually performs checks like user existence or maintenance mode, or these are hardcoded into the `CommandHandler`. Standardizing these requirements in the command definition itself makes the code cleaner and more declarative.
### Dependencies
- None
### Affected Files
- `shared/lib/types.ts`: Update `Command` interface to include a optional `requirements` object.
- `bot/lib/handlers/CommandHandler.ts`: Update to read and enforce these requirements.
- `bot/commands/economy/balance.ts`: Refactor to use the new requirements (example).
### Technical Constraints & Strategy
- Implementation: Use a standardized `requirements` object in the `Command` interface.
- Requirements could include: `userExists: boolean`, `permissions: string[]`, `devOnly: boolean`.
- Ensure `CommandHandler` provides clear error messages to the user when a requirement fails.
### Definition of Done (Binary)
- [ ] `Command` interface updated in `types.ts`.
- [ ] `CommandHandler.ts` enforces requirements before executing command.
- [ ] At least one command (e.g., `balance`) is refactored to use the new system.
- [ ] Clear error embeds are shown to the user when requirements aren't met.
### New Test Files
- None (Verification via manual testing of command execution).

View File

@@ -0,0 +1,32 @@
### Context & Goal
The current `web/src/server.ts` is a monolithic file with a very long `fetch` handler. This makes it difficult to read and maintain. Modularizing the logic into separate route handlers and middleware-like functions will improve code quality and scalability.
### Dependencies
- None
### Affected Files
- `web/src/server.ts`: Refactor to use modular handlers.
- `web/src/routes/api.ts`: New file for API route definitions.
- `web/src/routes/static.ts`: New file for static file serving logic.
- `web/src/routes/websocket.ts`: New file for WebSocket event handling.
### Technical Constraints & Strategy
- Implementation: Move different responsibilities (API, Static, WS) into separate files.
- The main `serve` configuration should just call these modules.
- Ensure the SPA fallback logic remains intact.
### Definition of Done (Binary)
- [ ] `web/src/server.ts` length reduced by at least 50%.
- [ ] API routes moved to dedicated module.
- [ ] Static file serving moved to dedicated module.
- [ ] WebSocket logic moved to dedicated module.
- [ ] Dashboard still loads and functions correctly.
### New Test Files
- None (Verification via manual testing of the dashboard).

View File

@@ -0,0 +1,29 @@
### Context & Goal
The `exam` command currently contains a lot of business logic, including reward calculations, timer management, and complex database transactions. Moving this logic to a dedicated `ExamService` will improve testability, maintainability, and keep the command file focused on user interaction.
### Dependencies
- None
### Affected Files
- `shared/modules/economy/exam.service.ts`: New file for the exam logic.
- `bot/commands/economy/exam.ts`: Refactor to use the new service.
### Technical Constraints & Strategy
- Implementation: Create an `ExamService` that handles `getExamStatus`, `takeExam`, and `registerForExam`.
- The command should only handle user input and formatting the response embeds based on the service's result.
- Ensure the Drizzle transactions are correctly handled within the service.
### Definition of Done (Binary)
- [ ] `ExamService` implemented with methods for all exam-related operations.
- [ ] `bot/commands/economy/exam.ts` refactored to use the service.
- [ ] Logic is covered by unit tests in a new test file.
- [ ] Manual verification shows the exam command still works as expected.
### New Test Files
- `shared/modules/economy/exam.service.test.ts`: Unit tests for reward calculations and state transitions.

View File

@@ -1,57 +0,0 @@
---
description: Create a new Ticket
---
### Role
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
### Task
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
1. Analyze the request for technical implications, edge cases, and architectural fit.
2. Generate a new Markdown file.
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
### File Naming Convention
You must use the following naming convention strictly:
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
### File Content Structure
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
```markdown
# [Ticket ID]: [Feature Title]
**Status:** Draft
**Created:** [YYYY-MM-DD]
**Tags:** [comma, separated, tags]
## 1. Context & User Story
* **As a:** [Role]
* **I want to:** [Action]
* **So that:** [Benefit/Value]
## 2. Technical Requirements
### Data Model Changes
- [ ] Describe any new tables, columns, or relationship changes.
- [ ] SQL migration required? (Yes/No)
### API / Interface
- [ ] Define endpoints (method, path) or function signatures.
- [ ] Payload definition (JSON structure or Types).
## 3. Constraints & Validations (CRITICAL)
*This section must be exhaustive. Do not be vague.*
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
## 4. Acceptance Criteria
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
1. [ ] Criteria 1
2. [ ] Criteria 2
## 5. Implementation Plan
- [ ] Step 1: ...
- [ ] Step 2: ...

View File

@@ -1,53 +0,0 @@
---
description: Review the most recent changes critically.
---
### Role
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
### Phase 1: The Security & Logic Audit
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
1. **TypeScript Strictness:**
* Flag any usage of `any`.
* Flag any use of non-null assertions (`!`) unless strictly guarded.
* Flag forced type casting (`as UnknownType`) without validation.
2. **Bun/Runtime Specifics:**
* Check for unhandled Promises (floating promises).
* Ensure environment variables are not hardcoded.
3. **Security Vectors:**
* **Injection:** Check SQL/NoSQL queries for concatenation.
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
* **Auth:** Are sensitive routes actually protected by middleware?
### Phase 2: Test Quality Verification
Do not just check if tests pass. Check if the tests are **valid**.
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
2. **Edge Case Coverage:**
* Did the code handle the *Constraints & Validations* listed in the original ticket?
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
### Phase 3: The Verdict
Output your review in the following strict format:
---
# 🛡️ Code Review Report
**Ticket ID:** [Ticket Name]
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
## 🚨 Critical Issues (Must Fix)
*List logic bugs, security risks, or failing tests.*
1. ...
2. ...
## ⚠️ Suggestions (Refactoring)
*List code style improvements, variable naming, or DRY opportunities.*
1. ...
## 🧪 Test Coverage Gap Analysis
*List specific scenarios that are NOT currently tested but should be.*
- [ ] Scenario: ...

View File

@@ -1,50 +0,0 @@
---
description: Pick a Ticket and work on it.
---
### Role
You are an Autonomous Senior Software Engineer specializing in TypeScript and Bun. You are responsible for the full lifecycle of feature implementation: selection, coding, testing, verification, and closure.
### Phase 1: Triage & Selection
1. **Scan:** Read all files in the `/tickets` directory.
2. **Filter:** Ignore tickets marked `Status: Done` or `Status: Archived`.
3. **Prioritize:** Select a single ticket based on the following hierarchy:
* **Tags:** `Critical` > `High Priority` > `Bug` > `Feature`.
* **Age:** Oldest created date first (FIFO).
4. **Announce:** Explicitly state: "I am picking ticket: [Ticket ID/Name] because [Reason]."
### Phase 2: Setup (Non-Destructive)
1. **Branching:** Create a new git branch based on the ticket name.
* *Format:* `feat/{ticket-kebab-name}` or `fix/{ticket-kebab-name}`.
* *Command:* `git checkout -b feat/user-auth-flow`.
2. **Context:** Read the selected ticket markdown file thoroughly, paying special attention to "Constraints & Validations."
### Phase 3: Implementation & Testing (The Loop)
*Iterate until the requirements are met.*
1. **Write Code:** Implement the feature or fix using TypeScript.
2. **Tightened Testing:**
* You must create or update test files (`*.test.ts` or `*.spec.ts`).
* **Requirement:** Tests must cover happy paths AND the edge cases defined in the ticket's "Constraints" section.
* *Mocking:* Mock external dependencies where appropriate to ensure isolation.
3. **Type Safety Check:**
* Run: `bun x tsc --noEmit`
* **CRITICAL:** If there are ANY TypeScript errors, you must fix them immediately. Do not proceed.
4. **Runtime Verification:**
* Run: `bun test`
* Ensure all tests pass. If a test fails, analyze the stack trace, fix the implementation, and rerun.
### Phase 4: Self-Review & Clean Up
Before declaring the task finished, perform a self-review:
1. **Linting:** Check for unused variables, any types, or console logs.
2. **Refactor:** Ensure code is DRY (Don't Repeat Yourself) and strictly typed.
3. **Ticket Update:**
* Modify the Markdown ticket file.
* Change `Status: Draft` to `Status: In Review` or `Status: Done`.
* Add a new section at the bottom: `## Implementation Notes` listing the specific files changed.
### Phase 5: Handover
Only when `bun x tsc` and `bun test` pass with 0 errors:
1. Commit the changes with a semantic message (e.g., `feat: implement user auth logic`).
2. Present a summary of the work done and ask for a human code review.

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.env .env
node_modules node_modules
docker-compose.override.yml
shared/db-logs shared/db-logs
shared/db/data shared/db/data
shared/db/loga shared/db/loga

View File

@@ -7,24 +7,44 @@
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2) ![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F) ![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791) ![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. 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.
## ✨ Features ## ✨ Features
### Discord Bot
* **Class System**: Users can join different classes. * **Class System**: Users can join different classes.
* **Economy**: Complete economy system with balance, transactions, and daily rewards. * **Economy**: Complete economy system with balance, transactions, and daily rewards.
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management. * **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
* **Leveling**: XP-based leveling system to track user activity and progress. * **Leveling**: XP-based leveling system to track user activity and progress.
* **Quests**: Quest system with requirements and rewards. * **Quests**: Quest system with requirements and rewards.
* **Trading**: Secure trading system between users. * **Trading**: Secure trading system between users.
* **Lootdrops**: Random loot drops in channels to engage users. * **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management. * **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.
* **Database Inspection**: Integrated Drizzle Studio access.
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
## 🏗️ 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).
* **Simplified Deployment**: You only need to deploy a single Docker container.
## 🛠️ Tech Stack ## 🛠️ Tech Stack
* **Runtime**: [Bun](https://bun.sh/) * **Runtime**: [Bun](https://bun.sh/)
* **Framework**: [Discord.js](https://discord.js.org/) * **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/)
* **Database**: [PostgreSQL](https://www.postgresql.org/) * **Database**: [PostgreSQL](https://www.postgresql.org/)
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/) * **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
* **Validation**: [Zod](https://zod.dev/) * **Validation**: [Zod](https://zod.dev/)
@@ -74,12 +94,14 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
bun run db:push bun run db:push
``` ```
### Running the Bot ### Running the Bot & Dashboard
**Development Mode** (with hot reload): **Development Mode** (with hot reload):
```bash ```bash
bun run dev bun run dev
``` ```
* Bot: Online in Discord
* Dashboard: http://localhost:3000
**Production Mode**: **Production Mode**:
Build and run with Docker (recommended): Build and run with Docker (recommended):
@@ -87,27 +109,46 @@ Build and run with Docker (recommended):
docker compose up -d app 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.
To access them from your local machine, use the included SSH tunnel script.
1. Add your VPS details to your local `.env` file:
```env
VPS_USER=root
VPS_HOST=123.45.67.89
```
2. Run the remote connection script:
```bash
bun run remote
```
This will establish secure tunnels for:
* **Dashboard**: http://localhost:3000
* **Drizzle Studio**: http://localhost:4983
## 📜 Scripts ## 📜 Scripts
* `bun run dev`: Start the bot in watch mode. * `bun run dev`: Start the bot and dashboard in watch mode.
* `bun run remote`: Open SSH tunnel to production services.
* `bun run generate`: Generate Drizzle migrations. * `bun run generate`: Generate Drizzle migrations.
* `bun run migrate`: Apply migrations (via Docker). * `bun run migrate`: Apply migrations (via Docker).
* `bun run db:push`: Push, schema to DB (via Docker).
* `bun run db:studio`: Open Drizzle Studio to inspect the database. * `bun run db:studio`: Open Drizzle Studio to inspect the database.
* `bun test`: Run tests. * `bun test`: Run tests.
## 📂 Project Structure ## 📂 Project Structure
``` ```
├── src ├── bot # Discord Bot logic & entry point
│ ├── commands # Slash commands ├── web # React Web Dashboard (Frontend + Server)
│ ├── events # Discord event handlers ├── shared # Shared code (Database, Config, Types)
│ ├── modules # Feature modules (Economy, Inventory, etc.)
│ ├── db # Database schema and connection
│ └── lib # Shared utilities
├── drizzle # Drizzle migration files ├── drizzle # Drizzle migration files
├── config # Configuration files ├── scripts # Utility scripts
── scripts # Utility scripts ── docker-compose.yml
└── package.json
``` ```
## 🤝 Contributing ## 🤝 Contributing

View File

@@ -10,7 +10,7 @@ import {
} from "discord.js"; } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds"; import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
import { items } from "@db/schema"; import { items } from "@db/schema";
import { ilike, isNotNull, and } from "drizzle-orm"; import { ilike, isNotNull, and } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
@@ -65,10 +65,10 @@ export const listing = createCommand({
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` }); await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error: any) { } catch (error: any) {
if (error instanceof UserError) { if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else { } else {
console.error("Error creating listing:", error); console.error("Error creating listing:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
} }
} }
}, },

View File

@@ -3,13 +3,14 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@shared/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
export const daily = createCommand({ export const daily = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("daily") .setName("daily")
.setDescription("Claim your daily reward"), .setDescription("Claim your daily reward"),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply();
try { try {
const result = await economyService.claimDaily(interaction.user.id); const result = await economyService.claimDaily(interaction.user.id);
@@ -21,14 +22,14 @@ export const daily = createCommand({
) )
.setColor("Gold"); .setColor("Gold");
await interaction.reply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} catch (error: any) { } catch (error: any) {
if (error instanceof UserError) { if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else { } else {
console.error("Error claiming daily:", error); console.error("Error claiming daily:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
} }
} }
} }

View File

@@ -1,21 +1,7 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors"; import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
import { userTimers, users } from "@db/schema";
import { eq, and, sql } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { config } from "@shared/lib/config";
import { TimerType } from "@shared/lib/constants";
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default';
interface ExamMetadata {
examDay: number;
lastXp: string;
}
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -25,105 +11,42 @@ export const exam = createCommand({
.setDescription("Take your weekly exam to earn rewards based on your XP progress."), .setDescription("Take your weekly exam to earn rewards based on your XP progress."),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await interaction.deferReply();
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
return;
}
const now = new Date();
const currentDay = now.getDay();
try { try {
// 1. Fetch existing timer/exam data // First, try to take the exam or check status
const timer = await DrizzleClient.query.userTimers.findFirst({ const result = await examService.takeExam(interaction.user.id);
where: and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
// 2. First Run Logic if (result.status === ExamStatus.NOT_REGISTERED) {
if (!timer) { // Register the user
// Set exam day to today const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
const nextExamDate = new Date(now); const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const metadata: ExamMetadata = {
examDay: currentDay,
lastXp: (user.xp ?? 0n).toString()
};
await DrizzleClient.insert(userTimers).values({
userId: user.id,
type: EXAM_TIMER_TYPE,
key: EXAM_TIMER_KEY,
expiresAt: nextExamDate,
metadata: metadata
});
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed( embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` + `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`, `Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful" "Exam Registration Successful"
)] )]
}); });
return; return;
} }
const metadata = timer.metadata as unknown as ExamMetadata; const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
const examDay = metadata.examDay;
// 3. Cooldown Check
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
// Calculate time remaining
const timestamp = Math.floor(expiresAt.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
await interaction.editReply({ await interaction.editReply({
embeds: [createErrorEmbed( embeds: [createErrorEmbed(
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` + `You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)` `Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)] )]
}); });
return; return;
} }
// 4. Day Check if (result.status === ExamStatus.MISSED) {
if (currentDay !== examDay) {
// Calculate next correct exam day to correct the schedule
let daysUntil = (examDay - currentDay + 7) % 7;
if (daysUntil === 0) daysUntil = 7;
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + daysUntil);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: (user.xp ?? 0n).toString()
};
await DrizzleClient.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
await interaction.editReply({ await interaction.editReply({
embeds: [createErrorEmbed( embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` + `You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
`You verify your attendance but score a **0**.\n` + `You verify your attendance but score a **0**.\n` +
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`, `Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed" "Exam Failed"
@@ -132,74 +55,21 @@ export const exam = createCommand({
return; return;
} }
// 5. Reward Calculation // If it reached here with AVAILABLE, it means they passed
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
const currentXp = user.xp ?? 0n;
const diff = currentXp - lastXp;
// Calculate Reward
const multMin = config.economy.exam.multMin;
const multMax = config.economy.exam.multMax;
const multiplier = Math.random() * (multMax - multMin) + multMin;
// Allow negative reward? existing description implies "difference", usually gain.
// If diff is negative (lost XP?), reward might be 0.
let reward = 0n;
if (diff > 0n) {
reward = BigInt(Math.floor(Number(diff) * multiplier));
}
// 6. Update State
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: currentXp.toString()
};
await DrizzleClient.transaction(async (tx) => {
// Update Timer
await tx.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
// Add Currency
if (reward > 0n) {
await tx.update(users)
.set({
balance: sql`${users.balance} + ${reward}`
})
.where(eq(users.id, user.id));
}
});
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed( embeds: [createSuccessEmbed(
`**XP Gained:** ${diff.toString()}\n` + `**XP Gained:** ${result.xpDiff?.toString()}\n` +
`**Multiplier:** x${multiplier.toFixed(2)}\n` + `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
`**Reward:** ${reward.toString()} Currency\n\n` + `**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`, `See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!" "Exam Passed!"
)] )]
}); });
} catch (error: any) { } catch (error: any) {
if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
} else {
console.error("Error in exam command:", error); console.error("Error in exam command:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
}
} }
} }
}); });

View File

@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
export const pay = createCommand({ export const pay = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()

View File

@@ -0,0 +1,117 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { triviaService } from "@shared/modules/trivia/trivia.service";
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
import { TriviaCategory } from "@shared/lib/constants";
export const trivia = createCommand({
data: new SlashCommandBuilder()
.setName("trivia")
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
.addStringOption(option =>
option.setName('category')
.setDescription('Select a specific category')
.setRequired(false)
.addChoices(
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
{ name: 'Film', value: String(TriviaCategory.FILM) },
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
{ name: 'History', value: String(TriviaCategory.HISTORY) },
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
{ name: 'Art', value: String(TriviaCategory.ART) },
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
)
),
execute: async (interaction) => {
try {
const categoryId = interaction.options.getString('category');
// Check if user can play BEFORE deferring
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
if (!canPlay.canPlay) {
// Cooldown error - ephemeral
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
await interaction.reply({
embeds: [createErrorEmbed(
`You're on cooldown! Try again <t:${timestamp}:R>.`
)],
ephemeral: true
});
return;
}
// User can play - defer publicly for trivia question
await interaction.deferReply();
// Start trivia session (deducts entry fee)
const session = await triviaService.startTrivia(
interaction.user.id,
interaction.user.username,
categoryId ? parseInt(categoryId) : undefined
);
// Generate Components v2 message
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
// Reply with Components v2 question
await interaction.editReply({
components,
flags
});
// Set up automatic timeout cleanup
setTimeout(async () => {
const stillActive = triviaService.getSession(session.sessionId);
if (stillActive) {
// User didn't answer - clean up session with no reward
try {
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
} catch (error) {
// Session already cleaned up, ignore
}
}
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
} catch (error: any) {
if (error instanceof UserError) {
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
}
} else {
console.error("Error in trivia command:", error);
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
ephemeral: true
});
}
}
}
}
});

View File

@@ -5,7 +5,7 @@ import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types"; import type { ItemUsageData } from "@shared/lib/types";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
export const use = createCommand({ export const use = createCommand({

View File

@@ -8,6 +8,7 @@ import { EventLoader } from "@lib/loaders/EventLoader";
export class Client extends DiscordClient { export class Client extends DiscordClient {
commands: Collection<string, Command>; commands: Collection<string, Command>;
knownCommands: Map<string, string>;
lastCommandTimestamp: number | null = null; lastCommandTimestamp: number | null = null;
maintenanceMode: boolean = false; maintenanceMode: boolean = false;
private commandLoader: CommandLoader; private commandLoader: CommandLoader;
@@ -16,6 +17,7 @@ export class Client extends DiscordClient {
constructor({ intents }: { intents: number[] }) { constructor({ intents }: { intents: number[] }) {
super({ intents }); super({ intents });
this.commands = new Collection<string, Command>(); this.commands = new Collection<string, Command>();
this.knownCommands = new Map<string, string>();
this.commandLoader = new CommandLoader(this); this.commandLoader = new CommandLoader(this);
this.eventLoader = new EventLoader(this); this.eventLoader = new EventLoader(this);
} }
@@ -77,6 +79,7 @@ export class Client extends DiscordClient {
async loadCommands(reload: boolean = false) { async loadCommands(reload: boolean = false) {
if (reload) { if (reload) {
this.commands.clear(); this.commands.clear();
this.knownCommands.clear();
console.log("♻️ Reloading commands..."); console.log("♻️ Reloading commands...");
} }

View File

@@ -28,6 +28,7 @@ export function getClientStats(): ClientStats {
ping: AuroraClient.ws.ping, ping: AuroraClient.ws.ping,
cachedUsers: AuroraClient.users.cache.size, cachedUsers: AuroraClient.users.cache.size,
commandsRegistered: AuroraClient.commands.size, commandsRegistered: AuroraClient.commands.size,
commandsKnown: AuroraClient.knownCommands.size,
uptime: process.uptime(), uptime: process.uptime(),
lastCommandTimestamp: AuroraClient.lastCommandTimestamp, lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
}; };

View File

@@ -1,4 +1,15 @@
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js"; import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
import { BRANDING } from "@shared/lib/constants";
import pkg from "../../package.json";
/**
* Applies standard branding to an embed.
*/
function applyBranding(embed: EmbedBuilder): EmbedBuilder {
return embed.setFooter({
text: `${BRANDING.FOOTER_TEXT} v${pkg.version}`
});
}
/** /**
* Creates a standardized error embed. * Creates a standardized error embed.
@@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
* @returns An EmbedBuilder instance configured as an error. * @returns An EmbedBuilder instance configured as an error.
*/ */
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder { export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
return new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`${title}`) .setTitle(`${title}`)
.setDescription(message) .setDescription(message)
.setColor(Colors.Red) .setColor(Colors.Red)
.setTimestamp(); .setTimestamp();
return applyBranding(embed);
} }
/** /**
@@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe
* @returns An EmbedBuilder instance configured as a warning. * @returns An EmbedBuilder instance configured as a warning.
*/ */
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder { export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
return new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`⚠️ ${title}`) .setTitle(`⚠️ ${title}`)
.setDescription(message) .setDescription(message)
.setColor(Colors.Yellow) .setColor(Colors.Yellow)
.setTimestamp(); .setTimestamp();
return applyBranding(embed);
} }
/** /**
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
* @returns An EmbedBuilder instance configured as a success. * @returns An EmbedBuilder instance configured as a success.
*/ */
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder { export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
return new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`${title}`) .setTitle(`${title}`)
.setDescription(message) .setDescription(message)
.setColor(Colors.Green) .setColor(Colors.Green)
.setTimestamp(); .setTimestamp();
return applyBranding(embed);
} }
/** /**
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
* @returns An EmbedBuilder instance configured as info. * @returns An EmbedBuilder instance configured as info.
*/ */
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder { export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
return new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(` ${title}`) .setTitle(` ${title}`)
.setDescription(message) .setDescription(message)
.setColor(Colors.Blue) .setColor(Colors.Blue)
.setTimestamp(); .setTimestamp();
return applyBranding(embed);
} }
/** /**
@@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
*/ */
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder { export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTimestamp(); .setTimestamp()
.setColor(color ?? BRANDING.COLOR);
if (title) embed.setTitle(title); if (title) embed.setTitle(title);
if (description) embed.setDescription(description); if (description) embed.setDescription(description);
if (color) embed.setColor(color);
return embed; return applyBranding(embed);
} }

View File

@@ -1,18 +0,0 @@
export class ApplicationError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
export class UserError extends ApplicationError {
constructor(message: string) {
super(message);
}
}
export class SystemError extends ApplicationError {
constructor(message: string) {
super(message);
}
}

View File

@@ -1,5 +1,6 @@
import { AutocompleteInteraction } from "discord.js"; import { AutocompleteInteraction } from "discord.js";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { logger } from "@shared/lib/logger";
/** /**
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
try { try {
await command.autocomplete(interaction); await command.autocomplete(interaction);
} catch (error) { } catch (error) {
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error); logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
} }
} }
} }

View File

@@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
/** /**
@@ -13,7 +14,7 @@ export class CommandHandler {
const command = AuroraClient.commands.get(interaction.commandName); const command = AuroraClient.commands.get(interaction.commandName);
if (!command) { if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`); logger.error("bot", `No command matching ${interaction.commandName} was found.`);
return; return;
} }
@@ -28,14 +29,14 @@ export class CommandHandler {
try { try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username); await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
} catch (error) { } catch (error) {
console.error("Failed to ensure user exists:", error); logger.error("bot", "Failed to ensure user exists", error);
} }
try { try {
await command.execute(interaction); await command.execute(interaction);
AuroraClient.lastCommandTimestamp = Date.now(); AuroraClient.lastCommandTimestamp = Date.now();
} catch (error) { } catch (error) {
console.error(String(error)); logger.error("bot", `Error executing command ${interaction.commandName}`, error);
const errorEmbed = createErrorEmbed('There was an error while executing this command!'); const errorEmbed = createErrorEmbed('There was an error while executing this command!');
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {

View File

@@ -1,7 +1,8 @@
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js"; import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
import { UserError } from "@lib/errors"; import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction; type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
return; return;
} }
} else { } else {
console.error(`Handler method ${route.method} not found in module`); logger.error("bot", `Handler method ${route.method} not found in module`);
} }
} }
} }
@@ -52,7 +53,7 @@ export class ComponentInteractionHandler {
// Log system errors (non-user errors) for debugging // Log system errors (non-user errors) for debugging
if (!isUserError) { if (!isUserError) {
console.error(`Error in ${handlerName}:`, error); logger.error("bot", `Error in ${handlerName}`, error);
} }
const errorEmbed = createErrorEmbed(errorMessage); const errorEmbed = createErrorEmbed(errorMessage);
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
} }
} catch (replyError) { } catch (replyError) {
// If we can't send a reply, log it // If we can't send a reply, log it
console.error(`Failed to send error response in ${handlerName}:`, replyError); logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
} }
} }
} }

View File

@@ -37,6 +37,11 @@ export const interactionRoutes: InteractionRoute[] = [
handler: () => import("@/modules/economy/lootdrop.interaction"), handler: () => import("@/modules/economy/lootdrop.interaction"),
method: 'handleLootdropInteraction' method: 'handleLootdropInteraction'
}, },
{
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
handler: () => import("@/modules/trivia/trivia.interaction"),
method: 'handleTriviaInteraction'
},
// --- ADMIN MODULE --- // --- ADMIN MODULE ---
{ {

View File

@@ -71,6 +71,9 @@ export class CommandLoader {
if (this.isValidCommand(command)) { if (this.isValidCommand(command)) {
command.category = category; command.category = category;
// Track all known commands regardless of enabled status
this.client.knownCommands.set(command.data.name, category);
const isEnabled = config.commands[command.data.name] !== false; const isEnabled = config.commands[command.data.name] !== false;
if (!isEnabled) { if (!isEnabled) {

View File

@@ -1,6 +1,6 @@
import { ButtonInteraction } from "discord.js"; import { ButtonInteraction } from "discord.js";
import { lootdropService } from "@shared/modules/economy/lootdrop.service"; import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view"; import { getLootdropClaimedMessage } from "./lootdrop.view";
export async function handleLootdropInteraction(interaction: ButtonInteraction) { export async function handleLootdropInteraction(interaction: ButtonInteraction) {

View File

@@ -1,7 +1,7 @@
import { ButtonInteraction, MessageFlags } from "discord.js"; import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
export async function handleShopInteraction(interaction: ButtonInteraction) { export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return; if (!interaction.customId.startsWith("shop_buy_")) return;

View File

@@ -4,7 +4,7 @@ import { config } from "@shared/lib/config";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view"; import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types"; import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
export const handleFeedbackInteraction = async (interaction: Interaction) => { export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type // Handle select menu for choosing feedback type

View File

@@ -10,7 +10,7 @@ import {
import { tradeService } from "@shared/modules/trade/trade.service"; import { tradeService } from "@shared/modules/trade/trade.service";
import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds"; import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
import { UserError } from "@lib/errors"; import { UserError } from "@shared/lib/errors";
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view"; import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";

View File

@@ -0,0 +1,116 @@
# Trivia - Components v2 Implementation
This trivia feature uses **Discord Components v2** for a premium visual experience.
## 🎨 Visual Features
### **Container with Accent Colors**
Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty:
- **🟢 Easy**: Green accent bar (`0x57F287`)
- **🟡 Medium**: Yellow accent bar (`0xFEE75C`)
- **🔴 Hard**: Red accent bar (`0xED4245`)
### **Modern Layout Components**
- **TextDisplay** - Rich markdown formatting for question text
- **Separator** - Visual spacing between sections
- **Container** - Groups all content with difficulty-based styling
### **Interactive Features**
**Give Up Button** - Players can forfeit if they're unsure
**Disabled Answer Buttons** - After answering, buttons show:
- ✅ Green for correct answer
- ❌ Red for user's incorrect answer
- Gray for other options
**Time Display** - Shows both relative time (`in 30s`) and seconds remaining
**Stakes Preview** - Clear display: `50 AU ➜ 100 AU`
## 📁 File Structure
```
bot/modules/trivia/
├── trivia.view.ts # Components v2 view functions
├── trivia.interaction.ts # Button interaction handler
└── README.md # This file
bot/commands/economy/
└── trivia.ts # /trivia slash command
```
## 🔧 Technical Details
### Components v2 Requirements
- Uses `MessageFlags.IsComponentsV2` flag
- No `embeds` or `content` fields (uses TextDisplay instead)
- Numeric component types:
- `1` - Action Row
- `2` - Button
- `10` - Text Display
- `14` - Separator
- `17` - Container
- Max 40 components per message (vs 5 for legacy)
### Button Styles
- **Secondary (2)**: Gray - Used for answer buttons
- **Success (3)**: Green - Used for "True" and correct answers
- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up"
## 🎮 User Experience Flow
1. User runs `/trivia`
2. Sees question in a Container with difficulty-based accent color
3. Can choose to:
- Select an answer (A/B/C/D or True/False)
- Give up using the 🏳️ button
4. After answering, sees result with:
- Disabled buttons showing correct/incorrect answers
- Container with result-based accent color (green/red/yellow)
- Reward or penalty information
## 🌟 Visual Examples
### Question Display
```
┌─[GREEN]─────────────────────────┐
│ # 🎯 Trivia Challenge │
│ 🟢 Easy • 📚 Geography │
│ ─────────────────────────── │
│ ### What is the capital of │
│ France? │
│ │
│ ⏱️ Time: in 30s (30s) │
│ 💰 Stakes: 50 AU ➜ 100 AU │
│ 👤 Player: Username │
└─────────────────────────────────┘
[🇦 A: Paris] [🇧 B: London]
[🇨 C: Berlin] [🇩 D: Madrid]
[🏳️ Give Up]
```
### Result Display (Correct)
```
┌─[GREEN]─────────────────────────┐
│ # 🎉 Correct Answer! │
│ ### What is the capital of │
│ France? │
│ ─────────────────────────── │
│ ✅ Your answer: Paris │
│ │
│ 💰 Reward: +100 AU │
│ │
│ 🏆 Great job! Keep it up! │
└─────────────────────────────────┘
[✅ A: Paris] [❌ B: London]
[❌ C: Berlin] [❌ D: Madrid]
(all buttons disabled)
```
## 🚀 Future Enhancements
Potential improvements:
- [ ] Thumbnail images based on trivia category
- [ ] Progress bar for time remaining
- [ ] Streak counter display
- [ ] Category-specific accent colors
- [ ] Media Gallery for image-based questions
- [ ] Leaderboard integration in results

View File

@@ -0,0 +1,129 @@
import { ButtonInteraction } from "discord.js";
import { triviaService } from "@shared/modules/trivia/trivia.service";
import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view";
import { UserError } from "@shared/lib/errors";
export async function handleTriviaInteraction(interaction: ButtonInteraction) {
const parts = interaction.customId.split('_');
// Check for "Give Up" button
if (parts.length >= 3 && parts[0] === 'trivia' && parts[1] === 'giveup') {
const sessionId = `${parts[2]}_${parts[3]}`;
const session = triviaService.getSession(sessionId);
if (!session) {
await interaction.reply({
content: '❌ This trivia question has expired or already been answered.',
ephemeral: true
});
return;
}
// Validate ownership
if (session.userId !== interaction.user.id) {
await interaction.reply({
content: '❌ This isn\'t your trivia question!',
ephemeral: true
});
return;
}
await interaction.deferUpdate();
// Process as incorrect (user gave up)
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, false);
// Show timeout view (since they gave up)
const { components, flags } = getTriviaTimeoutView(
session.question.question,
session.question.correctAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
return;
}
// Handle answer button
if (parts.length < 5 || parts[0] !== 'trivia' || parts[1] !== 'answer') {
return;
}
const sessionId = `${parts[2]}_${parts[3]}`;
const answerIndexStr = parts[4];
if (!answerIndexStr) {
throw new UserError('Invalid answer format.');
}
const answerIndex = parseInt(answerIndexStr);
// Get session BEFORE deferring to check ownership
const session = triviaService.getSession(sessionId);
if (!session) {
// Session doesn't exist or expired
await interaction.reply({
content: '❌ This trivia question has expired or already been answered.',
ephemeral: true
});
return;
}
// Validate ownership BEFORE deferring
if (session.userId !== interaction.user.id) {
// Wrong user trying to answer - send ephemeral error
await interaction.reply({
content: '❌ This isn\'t your trivia question! Use `/trivia` to start your own game.',
ephemeral: true
});
return;
}
// Only defer if ownership is valid
await interaction.deferUpdate();
// Check timeout
if (new Date() > session.expiresAt) {
const { components, flags } = getTriviaTimeoutView(
session.question.question,
session.question.correctAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
// Clean up session
await triviaService.submitAnswer(sessionId, interaction.user.id, false);
return;
}
// Check if correct
const isCorrect = answerIndex === session.correctIndex;
const userAnswer = session.allAnswers[answerIndex];
// Process result
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, isCorrect);
// Update message with enhanced visual feedback
const { components, flags } = getTriviaResultView(
result,
session.question.question,
userAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
}

View File

@@ -0,0 +1,336 @@
import { MessageFlags } from "discord.js";
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
/**
* Get color based on difficulty level
*/
function getDifficultyColor(difficulty: string): number {
switch (difficulty.toLowerCase()) {
case 'easy':
return 0x57F287; // Green
case 'medium':
return 0xFEE75C; // Yellow
case 'hard':
return 0xED4245; // Red
default:
return 0x5865F2; // Blurple
}
}
/**
* Get emoji for difficulty level
*/
function getDifficultyEmoji(difficulty: string): string {
switch (difficulty.toLowerCase()) {
case 'easy':
return '🟢';
case 'medium':
return '🟡';
case 'hard':
return '🔴';
default:
return '⭐';
}
}
/**
* Generate Components v2 message for a trivia question
*/
export function getTriviaQuestionView(session: TriviaSession, username: string): {
components: any[];
flags: number;
} {
const { question, allAnswers, entryFee, potentialReward, expiresAt, sessionId } = session;
// Calculate time remaining
const now = Date.now();
const timeLeft = Math.max(0, expiresAt.getTime() - now);
const secondsLeft = Math.floor(timeLeft / 1000);
const difficultyEmoji = getDifficultyEmoji(question.difficulty);
const difficultyText = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1);
const accentColor = getDifficultyColor(question.difficulty);
const components: any[] = [];
// Main Container with difficulty accent color
components.push({
type: 17, // Container
accent_color: accentColor,
components: [
// Title and metadata section
{
type: 10, // Text Display
content: `# 🎯 Trivia Challenge\n**${difficultyEmoji} ${difficultyText}** • 📚 ${question.category}`
},
// Separator
{
type: 14, // Separator
spacing: 1,
divider: true
},
// Question
{
type: 10, // Text Display
content: `### ${question.question}`
},
// Stats section
{
type: 14, // Separator
spacing: 1,
divider: false
},
{
type: 10, // Text Display
content: `⏱️ **Time:** <t:${Math.floor(expiresAt.getTime() / 1000)}:R> (${secondsLeft}s)\n💰 **Stakes:** ${entryFee} AU ➜ ${potentialReward} AU\n👤 **Player:** ${username}`
}
]
});
// Answer buttons
if (question.type === 'boolean') {
const trueIndex = allAnswers.indexOf('True');
const falseIndex = allAnswers.indexOf('False');
components.push({
type: 1, // Action Row
components: [
{
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
label: 'True',
style: 3, // Success
emoji: { name: '✅' }
},
{
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
label: 'False',
style: 4, // Danger
emoji: { name: '❌' }
}
]
});
} else {
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
const buttonRow: any = {
type: 1, // Action Row
components: []
};
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: 2, // Secondary
emoji: { name: emoji }
});
}
components.push(buttonRow);
}
// Give Up button in separate row
components.push({
type: 1, // Action Row
components: [
{
type: 2, // Button
custom_id: `trivia_giveup_${sessionId}`,
label: 'Give Up',
style: 4, // Danger
emoji: { name: '🏳️' }
}
]
});
return {
components,
flags: MessageFlags.IsComponentsV2
};
}
/**
* Generate Components v2 result message
*/
export function getTriviaResultView(
result: TriviaResult,
question: string,
userAnswer?: string,
allAnswers?: string[],
entryFee: bigint = 0n
): {
components: any[];
flags: number;
} {
const { correct, reward, correctAnswer } = result;
const components: any[] = [];
if (correct) {
// Success container
components.push({
type: 17, // Container
accent_color: 0x57F287, // Green
components: [
{
type: 10, // Text Display
content: `# 🎉 Correct Answer!\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `✅ **Your answer:** ${correctAnswer}\n\n💰 **Reward:** +${reward} AU\n\n🏆 Great job! Keep it up!`
}
]
});
} else {
const answerDisplay = userAnswer
? `❌ **Your answer:** ${userAnswer}\n✅ **Correct answer:** ${correctAnswer}`
: `✅ **Correct answer:** ${correctAnswer}`;
// Error container
components.push({
type: 17, // Container
accent_color: 0xED4245, // Red
components: [
{
type: 10, // Text Display
content: `# ❌ Incorrect Answer\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `${answerDisplay}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n📚 Better luck next time!`
}
]
});
}
// Show disabled buttons with visual feedback
if (allAnswers && allAnswers.length > 0) {
const buttonRow: any = {
type: 1, // Action Row
components: []
};
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
const isCorrect = answer === correctAnswer;
const wasUserAnswer = answer === userAnswer;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_result_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
disabled: true
});
}
components.push(buttonRow);
}
return {
components,
flags: MessageFlags.IsComponentsV2
};
}
/**
* Generate Components v2 timeout message
*/
export function getTriviaTimeoutView(
question: string,
correctAnswer: string,
allAnswers?: string[],
entryFee: bigint = 0n
): {
components: any[];
flags: number;
} {
const components: any[] = [];
// Timeout container
components.push({
type: 17, // Container
accent_color: 0xFEE75C, // Yellow
components: [
{
type: 10, // Text Display
content: `# ⏱️ Time's Up!\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `⏰ **You ran out of time!**\n✅ **Correct answer:** ${correctAnswer}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n⚡ Be faster next time!`
}
]
});
// Show disabled buttons with correct answer highlighted
if (allAnswers && allAnswers.length > 0) {
const buttonRow: any = {
type: 1, // Action Row
components: []
};
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
const isCorrect = answer === correctAnswer;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_timeout_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: isCorrect ? 3 : 2, // Success : Secondary
emoji: { name: isCorrect ? '✅' : emoji },
disabled: true
});
}
components.push(buttonRow);
}
return {
components,
flags: MessageFlags.IsComponentsV2
};
}

View File

@@ -3,7 +3,7 @@ import { config } from "@shared/lib/config";
import { getEnrollmentSuccessMessage } from "./enrollment.view"; import { getEnrollmentSuccessMessage } from "./enrollment.view";
import { classService } from "@shared/modules/class/class.service"; import { classService } from "@shared/modules/class/class.service";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
import { sendWebhookMessage } from "@/lib/webhookUtils"; import { sendWebhookMessage } from "@/lib/webhookUtils";
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) { export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {

View File

@@ -9,12 +9,12 @@
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"zod": "^4.1.13", "zod": "^4.1.13",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"drizzle-kit": "^0.31.7", "drizzle-kit": "^0.31.7",
"postgres": "^3.4.7",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5", "typescript": "^5",

View File

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

View File

@@ -13,7 +13,13 @@ import {
bigserial, bigserial,
check check
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { relations, sql } from 'drizzle-orm'; 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 --- // --- TABLES ---

View File

@@ -1,3 +1,4 @@
import { jsonReplacer } from './utils';
import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { z } from 'zod'; import { z } from 'zod';
@@ -69,6 +70,14 @@ export interface GameConfigType {
autoTimeoutThreshold?: number; autoTimeoutThreshold?: number;
}; };
}; };
trivia: {
entryFee: bigint;
rewardMultiplier: number;
timeoutSeconds: number;
cooldownMs: number;
categories: number[];
difficulty: 'easy' | 'medium' | 'hard' | 'random';
};
system: Record<string, any>; system: Record<string, any>;
} }
@@ -162,6 +171,21 @@ const configSchema = z.object({
dmOnWarn: true dmOnWarn: true
} }
}), }),
trivia: z.object({
entryFee: bigIntSchema,
rewardMultiplier: z.number().min(0).max(10),
timeoutSeconds: z.number().min(5).max(300),
cooldownMs: z.number().min(0),
categories: z.array(z.number()).default([]),
difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'),
}).default({
entryFee: 50n,
rewardMultiplier: 1.8,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: 'random'
}),
system: z.record(z.string(), z.any()).default({}), system: z.record(z.string(), z.any()).default({}),
}); });
@@ -191,14 +215,7 @@ export function saveConfig(newConfig: unknown) {
// Validate and transform input // Validate and transform input
const validatedConfig = configSchema.parse(newConfig); const validatedConfig = configSchema.parse(newConfig);
const replacer = (key: string, value: any) => { const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
writeFileSync(configPath, jsonString, 'utf-8'); writeFileSync(configPath, jsonString, 'utf-8');
reloadConfig(); reloadConfig();
} }

View File

@@ -7,6 +7,7 @@ export enum TimerType {
EFFECT = 'EFFECT', EFFECT = 'EFFECT',
ACCESS = 'ACCESS', ACCESS = 'ACCESS',
EXAM_SYSTEM = 'EXAM_SYSTEM', EXAM_SYSTEM = 'EXAM_SYSTEM',
TRIVIA_COOLDOWN = 'TRIVIA_COOLDOWN',
} }
export enum EffectType { export enum EffectType {
@@ -30,6 +31,8 @@ export enum TransactionType {
TRADE_IN = 'TRADE_IN', TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT', TRADE_OUT = 'TRADE_OUT',
QUEST_REWARD = 'QUEST_REWARD', QUEST_REWARD = 'QUEST_REWARD',
TRIVIA_ENTRY = 'TRIVIA_ENTRY',
TRIVIA_WIN = 'TRIVIA_WIN',
} }
export enum ItemTransactionType { export enum ItemTransactionType {
@@ -63,3 +66,28 @@ export enum LootType {
XP = 'XP', XP = 'XP',
ITEM = 'ITEM', ITEM = 'ITEM',
} }
export enum TriviaCategory {
GENERAL_KNOWLEDGE = 9,
BOOKS = 10,
FILM = 11,
MUSIC = 12,
VIDEO_GAMES = 15,
SCIENCE_NATURE = 17,
COMPUTERS = 18,
MATHEMATICS = 19,
MYTHOLOGY = 20,
SPORTS = 21,
GEOGRAPHY = 22,
HISTORY = 23,
POLITICS = 24,
ART = 25,
ANIMALS = 27,
ANIME_MANGA = 31,
}
export const BRANDING = {
COLOR: 0x00d4ff as const,
FOOTER_TEXT: 'Aurora' as const,
};

View File

@@ -7,7 +7,7 @@ const envSchema = z.object({
DATABASE_URL: z.string().min(1, "Database URL is required"), DATABASE_URL: z.string().min(1, "Database URL is required"),
PORT: z.coerce.number().default(3000), PORT: z.coerce.number().default(3000),
HOST: z.string().default("127.0.0.1"), HOST: z.string().default("127.0.0.1"),
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters"), ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters").optional(),
}); });
const parsedEnv = envSchema.safeParse(process.env); const parsedEnv = envSchema.safeParse(process.env);

118
shared/lib/logger.test.ts Normal file
View File

@@ -0,0 +1,118 @@
import { expect, test, describe, beforeAll, afterAll, spyOn } from "bun:test";
import { logger } from "./logger";
import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
describe("Logger", () => {
const logDir = join(process.cwd(), "logs");
const logFile = join(logDir, "error.log");
beforeAll(() => {
// Cleanup if exists
try {
if (existsSync(logFile)) unlinkSync(logFile);
} catch (e) {}
});
test("should log info messages to console with correct format", () => {
const spy = spyOn(console, "log");
const message = "Formatting test";
logger.info("system", message);
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0]?.[0];
expect(callArgs).toBeDefined();
if (callArgs) {
// Strict regex check for ISO timestamp and format
const regex = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] \[SYSTEM\] Formatting test$/;
expect(callArgs).toMatch(regex);
}
spy.mockRestore();
});
test("should write error logs to file with stack trace", async () => {
const errorMessage = "Test error message";
const testError = new Error("Source error");
logger.error("system", errorMessage, testError);
// Polling for file write instead of fixed timeout
let content = "";
for (let i = 0; i < 20; i++) {
if (existsSync(logFile)) {
content = readFileSync(logFile, "utf-8");
if (content.includes("Source error")) break;
}
await new Promise(resolve => setTimeout(resolve, 50));
}
expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[ERROR\] \[SYSTEM\] Test error message: Source error/);
expect(content).toContain("Stack Trace:");
expect(content).toContain("Error: Source error");
expect(content).toContain("logger.test.ts");
});
test("should handle log directory creation failures gracefully", async () => {
const consoleSpy = spyOn(console, "error");
// We trigger an error by trying to use a path that is a file where a directory should be
const triggerFile = join(process.cwd(), "logs_fail_trigger");
try {
writeFileSync(triggerFile, "not a directory");
// Manually override paths for this test instance
const originalLogDir = (logger as any).logDir;
const originalLogPath = (logger as any).errorLogPath;
(logger as any).logDir = triggerFile;
(logger as any).errorLogPath = join(triggerFile, "error.log");
(logger as any).initialized = false;
logger.error("system", "This should fail directory creation");
// Wait for async initialization attempt
await new Promise(resolve => setTimeout(resolve, 100));
expect(consoleSpy).toHaveBeenCalled();
expect(consoleSpy.mock.calls.some(call =>
String(call[0]).includes("Failed to initialize logger directory")
)).toBe(true);
// Reset logger state
(logger as any).logDir = originalLogDir;
(logger as any).errorLogPath = originalLogPath;
(logger as any).initialized = false;
} finally {
if (existsSync(triggerFile)) unlinkSync(triggerFile);
consoleSpy.mockRestore();
}
});
test("should include complex data objects in logs", () => {
const spy = spyOn(console, "log");
const data = { userId: "123", tags: ["test"] };
logger.info("bot", "Message with data", data);
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0]?.[0];
expect(callArgs).toBeDefined();
if (callArgs) {
expect(callArgs).toContain(` | Data: ${JSON.stringify(data)}`);
}
spy.mockRestore();
});
test("should handle circular references in data objects", () => {
const spy = spyOn(console, "log");
const data: any = { name: "circular" };
data.self = data;
logger.info("bot", "Circular test", data);
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0]?.[0];
expect(callArgs).toContain("[Circular]");
spy.mockRestore();
});
});

162
shared/lib/logger.ts Normal file
View File

@@ -0,0 +1,162 @@
import { join, resolve } from "path";
import { appendFile, mkdir, stat } from "fs/promises";
import { existsSync } from "fs";
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
const LogLevelNames = {
[LogLevel.DEBUG]: "DEBUG",
[LogLevel.INFO]: "INFO",
[LogLevel.WARN]: "WARN",
[LogLevel.ERROR]: "ERROR",
};
export type LogSource = "bot" | "web" | "shared" | "system";
export interface LogEntry {
timestamp: string;
level: string;
source: LogSource;
message: string;
data?: any;
stack?: string;
}
class Logger {
private logDir: string;
private errorLogPath: string;
private initialized: boolean = false;
private initPromise: Promise<void> | null = null;
constructor() {
// Use resolve with __dirname or process.cwd() but make it more robust
// Since this is in shared/lib/, we can try to find the project root
// For now, let's stick to a resolved path from process.cwd() or a safer alternative
this.logDir = resolve(process.cwd(), "logs");
this.errorLogPath = join(this.logDir, "error.log");
}
private async ensureInitialized() {
if (this.initialized) return;
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
try {
await mkdir(this.logDir, { recursive: true });
this.initialized = true;
} catch (err: any) {
if (err.code === "EEXIST" || err.code === "ENOTDIR") {
try {
const stats = await stat(this.logDir);
if (stats.isDirectory()) {
this.initialized = true;
return;
}
} catch (statErr) {}
}
console.error(`[SYSTEM] Failed to initialize logger directory at ${this.logDir}:`, err);
} finally {
this.initPromise = null;
}
})();
return this.initPromise;
}
private safeStringify(data: any): string {
try {
return JSON.stringify(data);
} catch (err) {
const seen = new WeakSet();
return JSON.stringify(data, (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[Circular]";
seen.add(value);
}
return value;
});
}
}
private formatMessage(entry: LogEntry): string {
const dataStr = entry.data ? ` | Data: ${this.safeStringify(entry.data)}` : "";
const stackStr = entry.stack ? `\nStack Trace:\n${entry.stack}` : "";
return `[${entry.timestamp}] [${entry.level}] [${entry.source.toUpperCase()}] ${entry.message}${dataStr}${stackStr}`;
}
private async writeToErrorLog(formatted: string) {
await this.ensureInitialized();
try {
await appendFile(this.errorLogPath, formatted + "\n");
} catch (err) {
console.error("[SYSTEM] Failed to write to error log file:", err);
}
}
private log(level: LogLevel, source: LogSource, message: string, errorOrData?: any) {
const timestamp = new Date().toISOString();
const levelName = LogLevelNames[level];
const entry: LogEntry = {
timestamp,
level: levelName,
source,
message,
};
if (level === LogLevel.ERROR && errorOrData instanceof Error) {
entry.stack = errorOrData.stack;
entry.message = `${message}: ${errorOrData.message}`;
} else if (errorOrData !== undefined) {
entry.data = errorOrData;
}
const formatted = this.formatMessage(entry);
// Print to console
switch (level) {
case LogLevel.DEBUG:
console.debug(formatted);
break;
case LogLevel.INFO:
console.log(formatted);
break;
case LogLevel.WARN:
console.warn(formatted);
break;
case LogLevel.ERROR:
console.error(formatted);
break;
}
// Persistent error logging
if (level === LogLevel.ERROR) {
this.writeToErrorLog(formatted).catch(() => {
// Silently fail to avoid infinite loops
});
}
}
debug(source: LogSource, message: string, data?: any) {
this.log(LogLevel.DEBUG, source, message, data);
}
info(source: LogSource, message: string, data?: any) {
this.log(LogLevel.INFO, source, message, data);
}
warn(source: LogSource, message: string, data?: any) {
this.log(LogLevel.WARN, source, message, data);
}
error(source: LogSource, message: string, error?: any) {
this.log(LogLevel.ERROR, source, message, error);
}
}
export const logger = new Logger();

View File

@@ -9,3 +9,42 @@ import type { Command } from "./types";
export function createCommand(command: Command): Command { export function createCommand(command: Command): Command {
return command; return command;
} }
/**
* JSON Replacer function for serialization
* Handles safe serialization of BigInt values to strings
*/
export const jsonReplacer = (_key: string, value: unknown): unknown => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
/**
* Deep merge utility
*/
export function deepMerge(target: any, source: any): any {
if (typeof target !== 'object' || target === null) {
return source;
}
if (typeof source !== 'object' || source === null) {
return source;
}
const output = { ...target };
Object.keys(source).forEach(key => {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = deepMerge(target[key], source[key]);
}
} else {
Object.assign(output, { [key]: source[key] });
}
});
return output;
}

View File

@@ -1,192 +1,99 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"; import { describe, test, expect, mock, beforeEach } from "bun:test";
import { dashboardService } from "./dashboard.service";
// Mock DrizzleClient // Mock DrizzleClient before importing service
const mockSelect = mock(() => ({ const mockFindMany = mock();
from: mock(() => Promise.resolve([{ count: "5" }])), const mockLimit = mock();
}));
const mockQuery = { // Helper to support the chained calls in getLeaderboards
transactions: { const mockChain = {
findMany: mock((): Promise<any[]> => Promise.resolve([])), from: () => mockChain,
}, orderBy: () => mockChain,
moderationCases: { limit: mockLimit
findMany: mock((): Promise<any[]> => Promise.resolve([])),
},
}; };
mock.module("@shared/db/DrizzleClient", () => ({ mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: { DrizzleClient: {
select: mockSelect, select: () => mockChain,
query: mockQuery, query: {
}, lootdrops: {
findMany: mockFindMany
}
}
}
})); }));
// Import service after mocking
import { dashboardService } from "./dashboard.service";
describe("dashboardService", () => { describe("dashboardService", () => {
beforeEach(() => { beforeEach(() => {
mockSelect.mockClear(); mockFindMany.mockClear();
mockQuery.transactions.findMany.mockClear(); mockLimit.mockClear();
mockQuery.moderationCases.findMany.mockClear();
// Reset default mock implementation
mockSelect.mockImplementation(() => ({
from: mock(() => Promise.resolve([{ count: "5" }])),
}));
}); });
describe("getActiveUserCount", () => { describe("getActiveLootdrops", () => {
test("should return active user count from database", async () => { test("should return active lootdrops when found", async () => {
mockSelect.mockImplementationOnce(() => ({ const mockDrops = [
// @ts-ignore ts(2322)
from: mock(() => ({
where: mock(() => Promise.resolve([{ count: "5" }])),
})),
}));
const count = await dashboardService.getActiveUserCount();
expect(count).toBe(5);
expect(mockSelect).toHaveBeenCalled();
});
test("should return 0 when no users found", async () => {
mockSelect.mockImplementationOnce(() => ({
// @ts-ignore ts(2322)
from: mock(() => ({
where: mock(() => Promise.resolve([{ count: "0" }])),
})),
}));
const count = await dashboardService.getActiveUserCount();
expect(count).toBe(0);
});
});
describe("getTotalUserCount", () => {
test("should return total user count", async () => {
const count = await dashboardService.getTotalUserCount();
expect(count).toBe(5);
});
});
describe("getRecentTransactions", () => {
test("should return formatted transaction events", async () => {
const mockTx = [
{ {
type: "DAILY_REWARD", messageId: "123",
description: "Daily reward", channelId: "general",
rewardAmount: 100,
currency: "Gold",
createdAt: new Date(), createdAt: new Date(),
user: { username: "testuser" }, expiresAt: new Date(Date.now() + 3600000),
}, claimedBy: null
] as any;
mockQuery.transactions.findMany.mockResolvedValueOnce(mockTx);
const events = await dashboardService.getRecentTransactions(10);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("info");
expect(events[0]?.message).toContain("testuser");
expect(events[0]?.icon).toBe("☀️");
});
test("should handle empty transactions", async () => {
mockQuery.transactions.findMany.mockResolvedValueOnce([]);
const events = await dashboardService.getRecentTransactions(10);
expect(events).toHaveLength(0);
});
});
describe("getRecentModerationCases", () => {
test("should return formatted moderation events", async () => {
const mockCases = [
{
type: "warn",
username: "baduser",
reason: "Spam",
createdAt: new Date(),
},
] as any;
mockQuery.moderationCases.findMany.mockResolvedValueOnce(mockCases);
const events = await dashboardService.getRecentModerationCases(10);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("error");
expect(events[0]?.message).toContain("WARN");
expect(events[0]?.message).toContain("baduser");
expect(events[0]?.icon).toBe("⚠️");
});
});
describe("getRecentEvents", () => {
test("should combine and sort transactions and moderation events", async () => {
const now = new Date();
const earlier = new Date(now.getTime() - 1000);
mockQuery.transactions.findMany.mockResolvedValueOnce([
{
type: "DAILY_REWARD",
description: "Daily",
createdAt: now,
user: { username: "user1" },
},
] as unknown as any[]);
mockQuery.moderationCases.findMany.mockResolvedValueOnce([
{
type: "warn",
username: "user2",
reason: "Test",
createdAt: earlier,
},
] as unknown as any[]);
const events = await dashboardService.getRecentEvents(10);
expect(events).toHaveLength(2);
// Should be sorted by timestamp (newest first)
const t0 = events[0]?.timestamp instanceof Date ? events[0].timestamp.getTime() : new Date(events[0]?.timestamp ?? 0).getTime();
const t1 = events[1]?.timestamp instanceof Date ? events[1].timestamp.getTime() : new Date(events[1]?.timestamp ?? 0).getTime();
expect(t0).toBeGreaterThanOrEqual(t1);
});
});
describe("recordEvent", () => {
test("should emit NEW_EVENT to systemEvents", async () => {
const mockEmit = mock((_event: string, _data: unknown) => { });
mock.module("@shared/lib/events", () => ({
systemEvents: {
emit: mockEmit,
},
EVENTS: {
DASHBOARD: {
NEW_EVENT: "dashboard:new_event",
} }
} ];
})); mockFindMany.mockResolvedValue(mockDrops);
await dashboardService.recordEvent({ const result = await dashboardService.getActiveLootdrops();
type: 'info', expect(result).toEqual(mockDrops);
message: 'Test Event', expect(mockFindMany).toHaveBeenCalledTimes(1);
icon: '🚀'
}); });
expect(mockEmit).toHaveBeenCalled(); test("should return empty array if no active drops", async () => {
const calls = mockEmit.mock.calls; mockFindMany.mockResolvedValue([]);
if (calls.length > 0 && calls[0]) { const result = await dashboardService.getActiveLootdrops();
expect(calls[0][0]).toBe("dashboard:new_event"); expect(result).toEqual([]);
const data = calls[0][1] as { message: string, timestamp: string }; });
expect(data.message).toBe("Test Event"); });
expect(data.timestamp).toBeDefined();
// Verify it's an ISO string describe("getLeaderboards", () => {
expect(() => new Date(data.timestamp).toISOString()).not.toThrow(); test("should combine top levels and wealth", async () => {
} else { const mockTopLevels = [
throw new Error("mockEmit was not called with expected arguments"); { username: "Alice", level: 10, avatar: "a.png" },
} { username: "Bob", level: 5, avatar: null },
{ username: "Charlie", level: 2, avatar: "c.png" }
];
const mockTopWealth = [
{ username: "Alice", balance: 1000n, avatar: "a.png" },
{ username: "Dave", balance: 500n, avatar: "d.png" },
{ username: "Bob", balance: 100n, avatar: null }
];
// Mock sequential calls to limit()
// First call is topLevels, second is topWealth
mockLimit
.mockResolvedValueOnce(mockTopLevels)
.mockResolvedValueOnce(mockTopWealth);
const result = await dashboardService.getLeaderboards();
expect(result.topLevels).toEqual(mockTopLevels);
// Verify balance BigInt to string conversion
expect(result.topWealth).toHaveLength(3);
expect(result.topWealth[0]!.balance).toBe("1000");
expect(result.topWealth[0]!.username).toBe("Alice");
expect(result.topWealth[1]!.balance).toBe("500");
expect(mockLimit).toHaveBeenCalledTimes(2);
});
test("should handle empty leaderboards", async () => {
mockLimit.mockResolvedValue([]);
const result = await dashboardService.getLeaderboards();
expect(result.topLevels).toEqual([]);
expect(result.topWealth).toEqual([]);
}); });
}); });
}); });

View File

@@ -1,7 +1,8 @@
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, transactions, moderationCases, inventory } from "@db/schema"; import { users, transactions, moderationCases, inventory, lootdrops, items, type User } from "@db/schema";
import { desc, sql, and, gte } from "drizzle-orm"; import { desc, sql, gte, eq } from "drizzle-orm";
import type { RecentEvent } from "./dashboard.types"; import type { RecentEvent, ActivityData } from "./dashboard.types";
import { TransactionType } from "@shared/lib/constants";
export const dashboardService = { export const dashboardService = {
/** /**
@@ -38,18 +39,18 @@ export const dashboardService = {
const allUsers = await DrizzleClient.select().from(users); const allUsers = await DrizzleClient.select().from(users);
const totalWealth = allUsers.reduce( const totalWealth = allUsers.reduce(
(acc: bigint, u: any) => acc + (u.balance || 0n), (acc: bigint, u: User) => acc + (u.balance || 0n),
0n 0n
); );
const avgLevel = allUsers.length > 0 const avgLevel = allUsers.length > 0
? Math.round( ? Math.round(
allUsers.reduce((acc: number, u: any) => acc + (u.level || 1), 0) / allUsers.length allUsers.reduce((acc: number, u: User) => acc + (u.level || 1), 0) / allUsers.length
) )
: 1; : 1;
const topStreak = allUsers.reduce( const topStreak = allUsers.reduce(
(max: number, u: any) => Math.max(max, u.dailyStreak || 0), (max: number, u: User) => Math.max(max, u.dailyStreak || 0),
0 0
); );
@@ -82,7 +83,7 @@ export const dashboardService = {
}, },
}); });
return recentTx.map((tx: any) => ({ return recentTx.map((tx) => ({
type: 'info' as const, type: 'info' as const,
message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`, message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`,
timestamp: tx.createdAt || new Date(), timestamp: tx.createdAt || new Date(),
@@ -102,11 +103,11 @@ export const dashboardService = {
where: gte(moderationCases.createdAt, oneDayAgo), where: gte(moderationCases.createdAt, oneDayAgo),
}); });
return recentCases.map((modCase: any) => ({ return recentCases.map((modCase) => ({
type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info', type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info',
message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`, message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`,
timestamp: modCase.createdAt || new Date(), timestamp: modCase.createdAt || new Date(),
icon: getModerationIcon(modCase.type), icon: getModerationIcon(modCase.type as string),
})); }));
}, },
@@ -149,6 +150,109 @@ export const dashboardService = {
console.error("Failed to emit system event:", e); console.error("Failed to emit system event:", e);
} }
}, },
/**
* Get hourly activity aggregation for the last 24 hours
*/
getActivityAggregation: async (): Promise<ActivityData[]> => {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Postgres aggregation
// We treat everything as a transaction.
// We treat everything except TRANSFER_IN as a 'command' (to avoid double counting transfers)
const result = await DrizzleClient
.select({
hour: sql<string>`date_trunc('hour', ${transactions.createdAt})`,
transactions: sql<string>`COUNT(*)`,
commands: sql<string>`COUNT(*) FILTER (WHERE ${transactions.type} != ${TransactionType.TRANSFER_IN})`
})
.from(transactions)
.where(gte(transactions.createdAt, twentyFourHoursAgo))
.groupBy(sql`1`)
.orderBy(sql`1`);
// Map into a record for easy lookups
const dataMap = new Map<string, { commands: number, transactions: number }>();
result.forEach(row => {
if (!row.hour) return;
const dateStr = new Date(row.hour).toISOString();
dataMap.set(dateStr, {
commands: Number(row.commands),
transactions: Number(row.transactions)
});
});
// Generate the last 24 hours of data
const activity: ActivityData[] = [];
const current = new Date();
current.setHours(current.getHours(), 0, 0, 0);
for (let i = 23; i >= 0; i--) {
const h = new Date(current.getTime() - i * 60 * 60 * 1000);
const iso = h.toISOString();
const existing = dataMap.get(iso);
activity.push({
hour: iso,
commands: existing?.commands || 0,
transactions: existing?.transactions || 0
});
}
return activity;
},
/**
* Get active lootdrops
*/
getActiveLootdrops: async () => {
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
return activeDrops;
},
/**
* Get leaderboards (Top 3 Levels and Wealth)
*/
getLeaderboards: async () => {
const topLevels = await DrizzleClient.select({
username: users.username,
level: users.level,
})
.from(users)
.orderBy(desc(users.level))
.limit(10);
const topWealth = await DrizzleClient.select({
username: users.username,
balance: users.balance,
})
.from(users)
.orderBy(desc(users.balance))
.limit(10);
const topNetWorth = await DrizzleClient.select({
username: users.username,
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
})
.from(users)
.leftJoin(inventory, eq(users.id, inventory.userId))
.leftJoin(items, eq(inventory.itemId, items.id))
.groupBy(users.id, users.username, users.balance)
.orderBy(desc(sql`net_worth`))
.limit(10);
return {
topLevels,
topWealth: topWealth.map(u => ({ ...u, balance: (u.balance || 0n).toString() })),
topNetWorth: topNetWorth.map(u => ({ ...u, netWorth: (u.netWorth || 0n).toString() }))
};
}
}; };
/** /**

View File

@@ -25,6 +25,8 @@ export const DashboardStatsSchema = z.object({
}), }),
commands: z.object({ commands: z.object({
total: z.number(), total: z.number(),
active: z.number(),
disabled: z.number(),
changePercentFromLastMonth: z.number().optional(), changePercentFromLastMonth: z.number().optional(),
}), }),
ping: z.object({ ping: z.object({
@@ -35,8 +37,42 @@ export const DashboardStatsSchema = z.object({
totalWealth: z.string(), totalWealth: z.string(),
avgLevel: z.number(), avgLevel: z.number(),
topStreak: z.number(), topStreak: z.number(),
totalItems: z.number().optional(),
}), }),
recentEvents: z.array(RecentEventSchema), recentEvents: z.array(RecentEventSchema),
activeLootdrops: z.array(z.object({
rewardAmount: z.number(),
currency: z.string(),
createdAt: z.string(),
expiresAt: z.string().nullable(),
})).optional(),
lootdropState: z.object({
monitoredChannels: z.number(),
hottestChannel: z.object({
id: z.string(),
messages: z.number(),
progress: z.number(),
cooldown: z.boolean(),
}).nullable(),
config: z.object({
requiredMessages: z.number(),
dropChance: z.number(),
}),
}).optional(),
leaderboards: z.object({
topLevels: z.array(z.object({
username: z.string(),
level: z.number(),
})),
topWealth: z.array(z.object({
username: z.string(),
balance: z.string(),
})),
topNetWorth: z.array(z.object({
username: z.string(),
netWorth: z.string(),
})),
}).optional(),
uptime: z.number(), uptime: z.number(),
lastCommandTimestamp: z.number().nullable(), lastCommandTimestamp: z.number().nullable(),
maintenanceMode: z.boolean(), maintenanceMode: z.boolean(),
@@ -53,6 +89,7 @@ export const ClientStatsSchema = z.object({
ping: z.number(), ping: z.number(),
cachedUsers: z.number(), cachedUsers: z.number(),
commandsRegistered: z.number(), commandsRegistered: z.number(),
commandsKnown: z.number(),
uptime: z.number(), uptime: z.number(),
lastCommandTimestamp: z.number().nullable(), lastCommandTimestamp: z.number().nullable(),
}); });
@@ -74,3 +111,10 @@ export const WsMessageSchema = z.discriminatedUnion("type", [
]); ]);
export type WsMessage = z.infer<typeof WsMessageSchema>; export type WsMessage = z.infer<typeof WsMessageSchema>;
export const ActivityDataSchema = z.object({
hour: z.string(),
commands: z.number(),
transactions: z.number(),
});
export type ActivityData = z.infer<typeof ActivityDataSchema>;

View File

@@ -87,7 +87,7 @@ export const economyService = {
}); });
if (cooldown && cooldown.expiresAt > now) { if (cooldown && cooldown.expiresAt > now) {
throw new UserError(`Daily already claimed today. Next claim <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F>`); throw new UserError(`You have already claimed your daily reward today.\nNext claim available: <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F> (<t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:R>)`);
} }
// Get user for streak logic // Get user for streak logic

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
import { users, userTimers, transactions } from "@db/schema";
// Define mock functions
const mockFindFirst = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
// Chainable mock setup
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ returning: mockReturning });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
const createMockTx = () => ({
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
});
return {
DrizzleClient: {
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
transaction: async (cb: any) => {
return cb(createMockTx());
}
},
};
});
// Mock withTransaction
mock.module("@/lib/db", () => ({
withTransaction: async (cb: any, tx?: any) => {
if (tx) return cb(tx);
return cb({
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
});
}
}));
// Mock Config
mock.module("@shared/lib/config", () => ({
config: {
economy: {
exam: {
multMin: 1.0,
multMax: 2.0,
}
}
}
}));
// Mock User Service
mock.module("@shared/modules/user/user.service", () => ({
userService: {
getOrCreateUser: mock()
}
}));
// Mock Dashboard Service
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
dashboardService: {
recordEvent: mock()
}
}));
describe("ExamService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
});
describe("getExamStatus", () => {
it("should return NOT_REGISTERED if no timer exists", async () => {
mockFindFirst.mockResolvedValue(undefined);
const status = await examService.getExamStatus("1");
expect(status.status).toBe(ExamStatus.NOT_REGISTERED);
});
it("should return COOLDOWN if now < expiresAt", async () => {
const now = new Date("2024-01-10T12:00:00Z");
setSystemTime(now);
const future = new Date("2024-01-11T00:00:00Z");
mockFindFirst.mockResolvedValue({
expiresAt: future,
metadata: { examDay: 3, lastXp: "100" }
});
const status = await examService.getExamStatus("1");
expect(status.status).toBe(ExamStatus.COOLDOWN);
expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0));
});
it("should return MISSED if it is the wrong day", async () => {
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
setSystemTime(now);
const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week
mockFindFirst.mockResolvedValue({
expiresAt: past,
metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday
});
const status = await examService.getExamStatus("1");
expect(status.status).toBe(ExamStatus.MISSED);
expect(status.examDay).toBe(3);
});
it("should return AVAILABLE if it is the correct day", async () => {
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
setSystemTime(now);
const past = new Date("2024-01-10T00:00:00Z");
mockFindFirst.mockResolvedValue({
expiresAt: past,
metadata: { examDay: 3, lastXp: "100" }
});
const status = await examService.getExamStatus("1");
expect(status.status).toBe(ExamStatus.AVAILABLE);
expect(status.examDay).toBe(3);
expect(status.lastXp).toBe(100n);
});
});
describe("registerForExam", () => {
it("should create user and timer correctly", async () => {
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
setSystemTime(now);
const { userService } = await import("@shared/modules/user/user.service");
(userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n });
const result = await examService.registerForExam("1", "testuser");
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
expect(result.examDay).toBe(1);
expect(mockInsert).toHaveBeenCalledWith(userTimers);
expect(mockInsert).toHaveBeenCalledTimes(1);
});
});
describe("takeExam", () => {
it("should return NOT_REGISTERED if not registered", async () => {
mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check
.mockResolvedValueOnce(undefined); // timer check
const result = await examService.takeExam("1");
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
});
it("should handle missed exam and schedule for next exam day", async () => {
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
setSystemTime(now);
const past = new Date("2024-01-10T00:00:00Z");
mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user
.mockResolvedValueOnce({
expiresAt: past,
metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday
}); // timer
const result = await examService.takeExam("1");
expect(result.status).toBe(ExamStatus.MISSED);
expect(result.examDay).toBe(3);
// Should set next exam to next Wednesday
// Monday (1) + 2 days = Wednesday (3)
const expected = new Date("2024-01-17T00:00:00Z");
expect(result.nextExamAt!.getTime()).toBe(expected.getTime());
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
});
it("should calculate rewards and update state when passed", async () => {
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
setSystemTime(now);
const past = new Date("2024-01-10T00:00:00Z");
mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user
.mockResolvedValueOnce({
expiresAt: past,
metadata: { examDay: 3, lastXp: "500" }
}); // timer
const result = await examService.takeExam("1");
expect(result.status).toBe(ExamStatus.AVAILABLE);
expect(result.xpDiff).toBe(500n);
// Multiplier is between 1.0 and 2.0 based on mock config
expect(result.multiplier).toBeGreaterThanOrEqual(1.0);
expect(result.multiplier).toBeLessThanOrEqual(2.0);
expect(result.reward).toBeGreaterThanOrEqual(500n);
expect(result.reward).toBeLessThanOrEqual(1000n);
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
expect(mockUpdate).toHaveBeenCalledWith(users);
// Verify transaction
expect(mockInsert).toHaveBeenCalledWith(transactions);
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
amount: result.reward,
userId: 1n,
type: expect.anything()
}));
});
});
});

View File

@@ -0,0 +1,262 @@
import { users, userTimers, transactions } from "@db/schema";
import { eq, and, sql } from "drizzle-orm";
import { TimerType, TransactionType } from "@shared/lib/constants";
import { config } from "@shared/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default';
export interface ExamMetadata {
examDay: number;
lastXp: string;
}
export enum ExamStatus {
NOT_REGISTERED = 'NOT_REGISTERED',
COOLDOWN = 'COOLDOWN',
MISSED = 'MISSED',
AVAILABLE = 'AVAILABLE',
}
export interface ExamActionResult {
status: ExamStatus;
nextExamAt?: Date;
reward?: bigint;
xpDiff?: bigint;
multiplier?: number;
examDay?: number;
}
export const examService = {
/**
* Get the current exam status for a user.
*/
async getExamStatus(userId: string, tx?: Transaction) {
return await withTransaction(async (txFn) => {
const timer = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
if (!timer) {
return { status: ExamStatus.NOT_REGISTERED };
}
const now = new Date();
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
return {
status: ExamStatus.COOLDOWN,
nextExamAt: expiresAt
};
}
const metadata = timer.metadata as unknown as ExamMetadata;
const currentDay = now.getDay();
if (currentDay !== metadata.examDay) {
return {
status: ExamStatus.MISSED,
nextExamAt: expiresAt,
examDay: metadata.examDay
};
}
return {
status: ExamStatus.AVAILABLE,
examDay: metadata.examDay,
lastXp: BigInt(metadata.lastXp || "0")
};
}, tx);
},
/**
* Register a user for the first time.
*/
async registerForExam(userId: string, username: string, tx?: Transaction): Promise<ExamActionResult> {
return await withTransaction(async (txFn) => {
// Ensure user exists
const { userService } = await import("@shared/modules/user/user.service");
const user = await userService.getOrCreateUser(userId, username, txFn);
if (!user) throw new Error("Failed to get or create user.");
const now = new Date();
const currentDay = now.getDay();
// Set next exam to next week
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const metadata: ExamMetadata = {
examDay: currentDay,
lastXp: (user.xp ?? 0n).toString()
};
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: EXAM_TIMER_TYPE,
key: EXAM_TIMER_KEY,
expiresAt: nextExamDate,
metadata: metadata
});
return {
status: ExamStatus.NOT_REGISTERED,
nextExamAt: nextExamDate,
examDay: currentDay
};
}, tx);
},
/**
* Take the exam. Handles missed exams and reward calculations.
*/
async takeExam(userId: string, tx?: Transaction): Promise<ExamActionResult> {
return await withTransaction(async (txFn) => {
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(userId))
});
if (!user) throw new Error("User not found");
const timer = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
if (!timer) {
return { status: ExamStatus.NOT_REGISTERED };
}
const now = new Date();
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
return {
status: ExamStatus.COOLDOWN,
nextExamAt: expiresAt
};
}
const metadata = timer.metadata as unknown as ExamMetadata;
const examDay = metadata.examDay;
const currentDay = now.getDay();
if (currentDay !== examDay) {
// Missed exam logic
let daysUntil = (examDay - currentDay + 7) % 7;
if (daysUntil === 0) daysUntil = 7;
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + daysUntil);
nextExamDate.setHours(0, 0, 0, 0);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: (user.xp ?? 0n).toString()
};
await txFn.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
return {
status: ExamStatus.MISSED,
nextExamAt: nextExamDate,
examDay: examDay
};
}
// Reward Calculation
const lastXp = BigInt(metadata.lastXp || "0");
const currentXp = user.xp ?? 0n;
const diff = currentXp - lastXp;
const multMin = config.economy.exam.multMin;
const multMax = config.economy.exam.multMax;
const multiplier = Math.random() * (multMax - multMin) + multMin;
let reward = 0n;
if (diff > 0n) {
// Use scaled BigInt arithmetic to avoid precision loss with large XP values
const scaledMultiplier = BigInt(Math.round(multiplier * 10000));
reward = (diff * scaledMultiplier) / 10000n;
}
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: currentXp.toString()
};
// Update Timer
await txFn.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
// Add Currency
if (reward > 0n) {
await txFn.update(users)
.set({
balance: sql`${users.balance} + ${reward}`
})
.where(eq(users.id, BigInt(userId)));
// Add Transaction Record
await txFn.insert(transactions).values({
userId: BigInt(userId),
amount: reward,
type: TransactionType.EXAM_REWARD,
description: `Weekly exam reward (XP Diff: ${diff})`,
});
}
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'success',
message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`,
icon: '🎓'
});
return {
status: ExamStatus.AVAILABLE,
nextExamAt: nextExamDate,
reward,
xpDiff: diff,
multiplier,
examDay
};
}, tx);
}
};

View File

@@ -163,6 +163,43 @@ class LootdropService {
return { success: false, error: "An error occurred while processing the reward." }; return { success: false, error: "An error occurred while processing the reward." };
} }
} }
public getLootdropState() {
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
let maxMessages = -1;
const window = config.lootdrop.activityWindowMs;
const now = Date.now();
const required = config.lootdrop.minMessages;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
// Filter valid just to be sure we are reporting accurate numbers
const validCount = timestamps.filter(t => now - t < window).length;
// Check cooldown
const cooldownUntil = this.channelCooldowns.get(channelId);
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
if (validCount > maxMessages) {
maxMessages = validCount;
hottestChannel = {
id: channelId,
messages: validCount,
progress: Math.min(100, (validCount / required) * 100),
cooldown: isOnCooldown
};
}
}
return {
monitoredChannels: this.channelActivity.size,
hottestChannel,
config: {
requiredMessages: required,
dropChance: config.lootdrop.spawnChance
}
};
}
public async clearCaches() { public async clearCaches() {
this.channelActivity.clear(); this.channelActivity.clear();
this.channelCooldowns.clear(); this.channelCooldowns.clear();

View File

@@ -0,0 +1,241 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
import { triviaService } from "./trivia.service";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, userTimers } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { config } from "@shared/lib/config";
import { TimerType } from "@shared/lib/constants";
// Mock fetch for OpenTDB API
const mockFetch = mock(() => Promise.resolve({
json: () => Promise.resolve({
response_code: 0,
results: [{
category: Buffer.from('General Knowledge').toString('base64'),
type: 'multiple',
difficulty: Buffer.from('medium').toString('base64'),
question: Buffer.from('What is 2 + 2?').toString('base64'),
correct_answer: Buffer.from('4').toString('base64'),
incorrect_answers: [
Buffer.from('3').toString('base64'),
Buffer.from('5').toString('base64'),
Buffer.from('22').toString('base64'),
]
}]
})
}));
global.fetch = mockFetch as any;
describe("TriviaService", () => {
const TEST_USER_ID = "999999999";
const TEST_USERNAME = "testuser";
beforeEach(async () => {
// Clean up test data
await DrizzleClient.delete(userTimers)
.where(eq(userTimers.userId, BigInt(TEST_USER_ID)));
// Ensure test user exists with sufficient balance
await DrizzleClient.insert(users)
.values({
id: BigInt(TEST_USER_ID),
username: TEST_USERNAME,
balance: 1000n,
xp: 0n,
})
.onConflictDoUpdate({
target: [users.id],
set: {
balance: 1000n,
}
});
});
afterEach(async () => {
// Clean up
await DrizzleClient.delete(userTimers)
.where(eq(userTimers.userId, BigInt(TEST_USER_ID)));
});
describe("fetchQuestion", () => {
it("should fetch and decode a trivia question", async () => {
const question = await triviaService.fetchQuestion();
expect(question).toBeDefined();
expect(question.question).toBe('What is 2 + 2?');
expect(question.correctAnswer).toBe('4');
expect(question.incorrectAnswers).toHaveLength(3);
expect(question.type).toBe('multiple');
});
});
describe("canPlayTrivia", () => {
it("should allow playing when no cooldown exists", async () => {
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(true);
expect(result.nextAvailable).toBeUndefined();
});
it("should prevent playing when on cooldown", async () => {
const futureDate = new Date(Date.now() + 60000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: futureDate,
});
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(false);
expect(result.nextAvailable).toBeDefined();
});
it("should allow playing when cooldown has expired", async () => {
const pastDate = new Date(Date.now() - 1000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: pastDate,
});
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(true);
});
});
describe("startTrivia", () => {
it("should start a trivia session and deduct entry fee", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
expect(session).toBeDefined();
expect(session.sessionId).toContain(TEST_USER_ID);
expect(session.userId).toBe(TEST_USER_ID);
expect(session.question).toBeDefined();
expect(session.allAnswers).toHaveLength(4);
expect(session.entryFee).toBe(config.trivia.entryFee);
expect(session.potentialReward).toBeGreaterThan(0n);
// Verify balance deduction
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(1000n - config.trivia.entryFee);
// Verify cooldown was set
const cooldown = await DrizzleClient.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(TEST_USER_ID)),
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
eq(userTimers.key, 'default')
)
});
expect(cooldown).toBeDefined();
});
it("should throw error if user has insufficient balance", async () => {
// Set balance to less than entry fee
await DrizzleClient.update(users)
.set({ balance: 10n })
.where(eq(users.id, BigInt(TEST_USER_ID)));
await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
.rejects.toThrow('Insufficient funds');
});
it("should throw error if user is on cooldown", async () => {
const futureDate = new Date(Date.now() + 60000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: futureDate,
});
await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
.rejects.toThrow('cooldown');
});
});
describe("submitAnswer", () => {
it("should award prize for correct answer", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const balanceBefore = (await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
}))!.balance!;
const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true);
expect(result.correct).toBe(true);
expect(result.reward).toBe(session.potentialReward);
expect(result.correctAnswer).toBe(session.question.correctAnswer);
// Verify balance increase
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(balanceBefore + session.potentialReward);
});
it("should not award prize for incorrect answer", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const balanceBefore = (await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
}))!.balance!;
const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, false);
expect(result.correct).toBe(false);
expect(result.reward).toBe(0n);
expect(result.correctAnswer).toBe(session.question.correctAnswer);
// Verify balance unchanged (already deducted at start)
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(balanceBefore);
});
it("should throw error if session doesn't exist", async () => {
await expect(triviaService.submitAnswer("invalid_session", TEST_USER_ID, true))
.rejects.toThrow('Session not found');
});
it("should prevent double submission", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true);
// Try to submit again
await expect(triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true))
.rejects.toThrow('Session not found');
});
});
describe("getSession", () => {
it("should retrieve active session", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const retrieved = triviaService.getSession(session.sessionId);
expect(retrieved).toBeDefined();
expect(retrieved?.sessionId).toBe(session.sessionId);
});
it("should return undefined for non-existent session", () => {
const retrieved = triviaService.getSession("invalid_session");
expect(retrieved).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,313 @@
import { users, userTimers, transactions } from "@db/schema";
import { eq, and, sql } from "drizzle-orm";
import { config } from "@shared/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
import { TimerType, TransactionType } from "@shared/lib/constants";
import { DrizzleClient } from "@shared/db/DrizzleClient";
// OpenTDB API Response Types
interface OpenTDBResponse {
response_code: number;
results: Array<{
category: string;
type: 'boolean' | 'multiple';
difficulty: string;
question: string;
correct_answer: string;
incorrect_answers: string[];
}>;
}
export interface TriviaQuestion {
question: string;
correctAnswer: string;
incorrectAnswers: string[];
type: 'boolean' | 'multiple';
difficulty: string;
category: string;
}
export interface TriviaSession {
sessionId: string;
userId: string;
question: TriviaQuestion;
allAnswers: string[];
correctIndex: number;
expiresAt: Date;
entryFee: bigint;
potentialReward: bigint;
}
export interface TriviaResult {
correct: boolean;
reward: bigint;
correctAnswer: string;
}
class TriviaService {
private activeSessions: Map<string, TriviaSession> = new Map();
constructor() {
// Cleanup expired sessions every 30 seconds
setInterval(() => {
this.cleanupExpiredSessions();
}, 30000);
}
private cleanupExpiredSessions() {
const now = Date.now();
const expired: string[] = [];
for (const [sessionId, session] of this.activeSessions.entries()) {
if (session.expiresAt.getTime() < now) {
expired.push(sessionId);
}
}
for (const sessionId of expired) {
this.activeSessions.delete(sessionId);
}
if (expired.length > 0) {
console.log(`[TriviaService] Cleaned up ${expired.length} expired sessions.`);
}
}
/**
* Fetch a trivia question from OpenTDB API
*/
async fetchQuestion(category?: number, difficulty?: string): Promise<TriviaQuestion> {
let url = 'https://opentdb.com/api.php?amount=1&encode=base64';
if (category) {
url += `&category=${category}`;
}
if (difficulty && difficulty !== 'random') {
url += `&difficulty=${difficulty}`;
}
try {
const response = await fetch(url);
const data = await response.json() as OpenTDBResponse;
if (data.response_code !== 0 || !data.results || data.results.length === 0) {
throw new Error('Failed to fetch trivia question');
}
const result = data.results[0];
if (!result) {
throw new Error('No trivia question returned');
}
// Decode base64
return {
category: Buffer.from(result.category, 'base64').toString('utf-8'),
type: result.type,
difficulty: Buffer.from(result.difficulty, 'base64').toString('utf-8'),
question: Buffer.from(result.question, 'base64').toString('utf-8'),
correctAnswer: Buffer.from(result.correct_answer, 'base64').toString('utf-8'),
incorrectAnswers: result.incorrect_answers.map(ans =>
Buffer.from(ans, 'base64').toString('utf-8')
),
};
} catch (error) {
console.error('[TriviaService] Error fetching question:', error);
throw new UserError('Failed to fetch trivia question. Please try again later.');
}
}
/**
* Check if user can play trivia (cooldown check)
*/
async canPlayTrivia(userId: string): Promise<{ canPlay: boolean; nextAvailable?: Date }> {
const now = new Date();
const cooldown = await DrizzleClient.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
eq(userTimers.key, 'default')
),
});
if (cooldown && cooldown.expiresAt > now) {
return { canPlay: false, nextAvailable: cooldown.expiresAt };
}
return { canPlay: true };
}
/**
* Start a trivia session - deducts entry fee and creates session
*/
async startTrivia(userId: string, username: string, categoryId?: number): Promise<TriviaSession> {
// Check cooldown
const cooldownCheck = await this.canPlayTrivia(userId);
if (!cooldownCheck.canPlay) {
const timestamp = Math.floor(cooldownCheck.nextAvailable!.getTime() / 1000);
throw new UserError(`You're on cooldown! Try again <t:${timestamp}:R>.`);
}
const entryFee = config.trivia.entryFee;
return await withTransaction(async (tx) => {
// Check balance
const user = await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId)),
});
if (!user) {
throw new UserError('User not found.');
}
if ((user.balance ?? 0n) < entryFee) {
throw new UserError(`Insufficient funds! You need ${entryFee} AU to play trivia.`);
}
// Deduct entry fee (SINK MECHANISM)
await tx.update(users)
.set({
balance: sql`${users.balance} - ${entryFee}`,
})
.where(eq(users.id, BigInt(userId)));
// Record transaction
await tx.insert(transactions).values({
userId: BigInt(userId),
amount: -entryFee,
type: TransactionType.TRIVIA_ENTRY,
description: 'Trivia entry fee',
});
// Fetch question
let category = categoryId;
if (!category) {
category = config.trivia.categories.length > 0
? config.trivia.categories[Math.floor(Math.random() * config.trivia.categories.length)]
: undefined;
}
const difficulty = config.trivia.difficulty;
const question = await this.fetchQuestion(category, difficulty);
// Shuffle answers
const allAnswers = [...question.incorrectAnswers, question.correctAnswer];
const shuffled = allAnswers.sort(() => Math.random() - 0.5);
const correctIndex = shuffled.indexOf(question.correctAnswer);
// Create session
const sessionId = `${userId}_${Date.now()}`;
const expiresAt = new Date(Date.now() + config.trivia.timeoutSeconds * 1000);
const potentialReward = BigInt(Math.floor(Number(entryFee) * config.trivia.rewardMultiplier));
const session: TriviaSession = {
sessionId,
userId,
question,
allAnswers: shuffled,
correctIndex,
expiresAt,
entryFee,
potentialReward,
};
this.activeSessions.set(sessionId, session);
// Set cooldown
const cooldownEnd = new Date(Date.now() + config.trivia.cooldownMs);
await tx.insert(userTimers)
.values({
userId: BigInt(userId),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: cooldownEnd,
})
.onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: cooldownEnd },
});
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'info',
message: `${username} started a trivia game (${question.difficulty})`,
icon: '🎯'
});
return session;
});
}
/**
* Get session by ID
*/
getSession(sessionId: string): TriviaSession | undefined {
return this.activeSessions.get(sessionId);
}
/**
* Submit answer and process reward
*/
async submitAnswer(sessionId: string, userId: string, isCorrect: boolean): Promise<TriviaResult> {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new UserError('Session not found or expired.');
}
if (session.userId !== userId) {
throw new UserError('This is not your trivia question!');
}
// Remove session to prevent double-submit
this.activeSessions.delete(sessionId);
const reward = isCorrect ? session.potentialReward : 0n;
if (isCorrect) {
await withTransaction(async (tx) => {
// Award prize
await tx.update(users)
.set({
balance: (await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId))
}))!.balance! + reward,
})
.where(eq(users.id, BigInt(userId)));
// Record transaction
await tx.insert(transactions).values({
userId: BigInt(userId),
amount: reward,
type: TransactionType.TRIVIA_WIN,
description: 'Trivia prize',
});
// Record dashboard event
const user = await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId))
});
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'success',
message: `${user?.username} won ${reward.toLocaleString()} AU from trivia!`,
icon: '🎉'
});
});
}
return {
correct: isCorrect,
reward,
correctAnswer: session.question.correctAnswer,
};
}
}
export const triviaService = new TriviaService();

View File

@@ -1,38 +0,0 @@
# DASH-003: Visual Analytics & Activity Charts
**Status:** Draft
**Created:** 2026-01-08
**Tags:** dashboard, analytics, charts, frontend
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** View a graphical representation of bot usage over the last 24 hours.
* **So that:** I can identify peak usage times and trends in command execution.
## 2. Technical Requirements
### Data Model Changes
- [ ] No new tables.
- [ ] Requires complex aggregation queries on the `transactions` table.
### API / Interface
- [ ] `GET /api/stats/activity`: Returns an array of data points for the last 24 hours (hourly granularity).
- [ ] Response Structure: `Array<{ hour: string, commands: number, transactions: number }>`.
## 3. Constraints & Validations (CRITICAL)
- **Input Validation:** Hourly buckets must be strictly validated for the 24h window.
- **System Constraints:**
- Database query must be cached for at least 5 minutes as it involves heavy aggregation.
- Chart must be responsive and handle mobile viewports.
- **Business Logic Guardrails:**
- If no data exists for an hour, it must return 0 rather than skipping the point.
## 4. Acceptance Criteria
1. [ ] **Given** a 24-hour history of transactions, **When** the dashboard loads, **Then** a line or area chart displays the command volume over time.
2. [ ] **Given** the premium glassmorphic theme, **When** the chart is rendered, **Then** it must use the primary brand colors and gradients to match the UI.
3. [ ] **Given** a mouse hover on the chart, **When** hovering over a point, **Then** a glassmorphic tooltip shows exact counts for that hour.
## 5. Implementation Plan
- [ ] Step 1: Add an aggregation method to `dashboard.service.ts` to fetch hourly counts from the `transactions` table.
- [ ] Step 2: Create the `/api/stats/activity` endpoint.
- [ ] Step 3: Install a charting library (e.g., `recharts` or `lucide-react` compatible library).
- [ ] Step 4: Implement the `ActivityChart` component into the middle column of the dashboard.

View File

@@ -1,53 +0,0 @@
# DASH-004: Administrative Control Panel
**Status:** Done
**Created:** 2026-01-08
**Tags:** dashboard, control-panel, bot-actions, operations
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** Execute common maintenance tasks directly from the dashboard buttons.
* **So that:** I don't have to use terminal commands or Discord slash commands for system-level operations.
## 2. Technical Requirements
### Data Model Changes
- [ ] N/A.
### API / Interface
- [ ] `POST /api/actions/reload-commands`: Triggers the bot's command loader.
- [ ] `POST /api/actions/clear-cache`: Clears internal bot caches.
- [ ] `POST /api/actions/maintenance-mode`: Toggles a maintenance flag for the bot.
## 3. Constraints & Validations (CRITICAL)
- **Input Validation:** Standard JSON body with optional `reason` field.
- **System Constraints:**
- Actions must be idempotent where possible.
- Actions must provide a response within 10 seconds.
- **Business Logic Guardrails:**
- **SECURITY**: This endpoint MUST require high-privilege authentication (currently we have single admin assumption, but token-based check should be planned).
- Maintenance mode toggle must be logged to the event feed.
## 4. Acceptance Criteria
1. [ ] **Given** a "Quick Actions" card, **When** the "Reload Commands" button is clicked, **Then** the bot reloads its local command files and posts a "Success" event to the feed.
2. [ ] **Given** a running bot, **When** the "Clear Cache" button is pushed, **Then** the bot flushes its internal memory maps and the memory usage metric reflects the drop.
## 5. Implementation Plan
- [x] Step 1: Create an `action.service.ts` to handle the logic of triggering bot-specific functions.
- [x] Step 2: Implement the `/api/actions` route group.
- [x] Step 3: Design a "Quick Actions" card with premium styled buttons in `Dashboard.tsx`.
- [x] Step 4: Add loading states to buttons to show when an operation is "In Progress."
## Implementation Notes
Successfully implemented the Administrative Control Panel with the following changes:
- **Backend Service**: Created `shared/modules/admin/action.service.ts` to coordinate actions like reloading commands, clearing cache, and toggling maintenance mode.
- **System Bus**: Updated `shared/lib/events.ts` with new action events.
- **API Endpoints**: Added `POST /api/actions/*` routes to the web server in `web/src/server.ts`.
- **Bot Integration**:
- Updated `AuroraClient` in `bot/lib/BotClient.ts` to listen for system action events.
- Implemented `maintenanceMode` flag in `AuroraClient`.
- Updated `CommandHandler.ts` to respect maintenance mode, blocking user commands with a helpful error embed.
- **Frontend UI**:
- Created `ControlPanel.tsx` component with a premium glassmorphic design and real-time state feedback.
- Integrated `ControlPanel` into the `Dashboard.tsx` page.
- Updated `use-dashboard-stats` hook and shared types to include maintenance mode status.
- **Verification**: Created 3 new test suites covering the service, the bot listener, and the command handler enforcement. All tests passing.

View File

@@ -1,202 +0,0 @@
# DASH-001: Dashboard Real Data Integration
**Status:** In Review
**Created:** 2026-01-08
**Tags:** dashboard, api, discord-client, database, real-time
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** See real data on the dashboard instead of mock/hardcoded values
* **So that:** I can monitor actual bot metrics, user activity, and system health in real-time
## 2. Technical Requirements
### Data Model Changes
- [ ] No new tables required
- [ ] SQL migration required? **No** existing schema already has `users`, `transactions`, `moderationCases`, and other relevant tables
### API / Interface
#### New Dashboard Stats Service
Create a new service at `shared/modules/dashboard/dashboard.service.ts`:
```typescript
interface DashboardStats {
guilds: {
count: number;
changeFromLastMonth?: number;
};
users: {
active: number;
changePercentFromLastMonth?: number;
};
commands: {
total: number;
changePercentFromLastMonth?: number;
};
ping: {
avg: number;
changeFromLastHour?: number;
};
recentEvents: RecentEvent[];
activityOverview: ActivityDataPoint[];
}
interface RecentEvent {
type: 'success' | 'error' | 'info';
message: string;
timestamp: Date;
}
```
#### API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/stats` | Returns `DashboardStats` object |
| `GET` | `/api/stats/realtime` | WebSocket/SSE for live updates |
### Discord Client Data
The `AuroraClient` (exported from `bot/lib/BotClient.ts`) provides access to:
| Property | Data Source | Dashboard Metric |
|----------|-------------|------------------|
| `client.guilds.cache.size` | Discord.js | Total Servers |
| `client.users.cache.size` | Discord.js | Active Users (approximate) |
| `client.ws.ping` | Discord.js | Avg Ping |
| `client.commands.size` | Bot commands | Commands Registered |
| `client.lastCommandTimestamp` | Custom property | Last command run time |
### Database Data
Query from existing tables:
| Metric | Query |
|--------|-------|
| User count (registered) | `SELECT COUNT(*) FROM users WHERE is_active = true` |
| Commands executed (today) | `SELECT COUNT(*) FROM transactions WHERE type = 'COMMAND_RUN' AND created_at >= NOW() - INTERVAL '1 day'` |
| Recent moderation events | `SELECT * FROM moderation_cases ORDER BY created_at DESC LIMIT 10` |
| Recent transactions | `SELECT * FROM transactions ORDER BY created_at DESC LIMIT 10` |
> [!IMPORTANT]
> The Discord client instance (`AuroraClient`) is in the `bot` package, while the web server is in the `web` package. Need to establish cross-package communication:
> - **Option A**: Export client reference from `bot` and import in `web` (same process, simple)
> - **Option B**: IPC via shared memory or message queue (separate processes)
> - **Option C**: Internal HTTP/WebSocket between bot and web (microservice pattern)
## 3. Constraints & Validations (CRITICAL)
- **Input Validation:**
- API endpoints must not accept arbitrary query parameters
- Rate limiting on `/api/stats` to prevent abuse (max 60 requests/minute per IP)
- **System Constraints:**
- Discord API rate limits apply when fetching guild/user data
- Cache Discord data and refresh at most every 30 seconds
- Database queries should be optimized with existing indices
- API response timeout: 5 seconds maximum
- **Business Logic Guardrails:**
- Do not expose sensitive user data (only aggregates)
- Do not expose Discord tokens or internal IDs in API responses
- Activity history limited to last 24 hours to prevent performance issues
- User counts should count only registered users, not all Discord users
## 4. Acceptance Criteria
1. [ ] **Given** the dashboard is loaded, **When** the API `/api/stats` is called, **Then** it returns real guild count from Discord client
2. [ ] **Given** the bot is connected to Discord, **When** viewing the dashboard, **Then** the "Total Servers" shows actual `guilds.cache.size`
3. [ ] **Given** users are registered in the database, **When** viewing the dashboard, **Then** "Active Users" shows count from `users` table where `is_active = true`
4. [ ] **Given** the bot is running, **When** viewing the dashboard, **Then** "Avg Ping" shows actual `client.ws.ping` value
5. [ ] **Given** recent bot activity occurred, **When** viewing "Recent Events", **Then** events from `transactions` and `moderation_cases` tables are displayed
6. [ ] **Given** mock data exists in components, **When** the feature is complete, **Then** all hardcoded values in `Dashboard.tsx` are replaced with API data
## 5. Implementation Plan
### Phase 1: Data Layer & Services
- [ ] Create `shared/modules/dashboard/dashboard.service.ts` with statistics aggregation functions
- [ ] Add helper to query active user count from database
- [ ] Add helper to query recent transactions (as events)
- [ ] Add helper to query moderation cases (as events)
---
### Phase 2: Discord Client Exposure
- [ ] Create a client stats provider that exposes Discord metrics
- [ ] Implement caching layer to avoid rate limiting (30-second TTL)
- [ ] Export stats getter from `bot` package for `web` package consumption
---
### Phase 3: API Implementation
- [ ] Add `/api/stats` endpoint in `web/src/server.ts`
- [ ] Wire up `dashboard.service.ts` functions to API
- [ ] Add error handling and response formatting
- [ ] Consider adding rate limiting middleware
---
### Phase 4: Frontend Integration
- [ ] Create custom React hook `useDashboardStats()` for data fetching
- [ ] Replace hardcoded values in `Dashboard.tsx` with hook data
- [ ] Add loading states and error handling
- [ ] Implement auto-refresh (poll every 30 seconds or use SSE/WebSocket)
---
### Phase 5: Activity Overview Chart
- [ ] Query hourly command/transaction counts for last 24 hours
- [ ] Integrate charting library (e.g., Recharts, Chart.js)
- [ ] Replace "Chart Placeholder" with actual chart component
---
## Architecture Decision Required
> [!WARNING]
> **Key Decision: How should the web server access Discord client data?**
>
> The bot and web server currently run in the same process. Recommend:
> - **Short term**: Direct import of `AuroraClient` singleton in API handlers
> - **Long term**: Consider event bus or shared state manager if splitting to microservices
## Out of Scope
- User authentication/authorization for API endpoints
- Historical data beyond 24 hours
- Command execution tracking (would require new database table)
- Guild-specific analytics (separate feature)
---
## Implementation Notes
**Status:** In Review
**Implemented:** 2026-01-08
**Branch:** `feat/dashboard-real-data-integration`
**Commit:** `17cb70e`
### Files Changed
#### New Files Created (7)
1. `shared/modules/dashboard/dashboard.types.ts` - TypeScript interfaces
2. `shared/modules/dashboard/dashboard.service.ts` - Database query service
3. `shared/modules/dashboard/dashboard.service.test.ts` - Service unit tests
4. `bot/lib/clientStats.ts` - Discord client stats provider with caching
5. `bot/lib/clientStats.test.ts` - Client stats unit tests
6. `web/src/hooks/use-dashboard-stats.ts` - React hook for data fetching
7. `tickets/2026-01-08-dashboard-real-data-integration.md` - This ticket
#### Modified Files (3)
1. `web/src/server.ts` - Added `/api/stats` endpoint
2. `web/src/pages/Dashboard.tsx` - Integrated real data with loading/error states
3. `.gitignore` - Removed `tickets/` to track tickets in version control
### Test Results
```
✓ 11 tests passing
✓ TypeScript check clean (bun x tsc --noEmit)
```
### Architecture Decision
Used **Option A** (direct import) for accessing `AuroraClient` from web server, as both run in the same process. This is the simplest approach and avoids unnecessary complexity.

View File

@@ -1,49 +0,0 @@
# DASH-002: Real-time Live Updates via WebSockets
**Status:** Done
**Created:** 2026-01-08
**Tags:** dashboard, websocket, real-time, performance
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** See metrics and events update instantly on my screen without refreshing or waiting for polling intervals.
* **So that:** I can react immediately to errors or spikes in latency and have a dashboard that feels "alive."
## 2. Technical Requirements
### Data Model Changes
- [x] No database schema changes required.
- [x] Created `shared/lib/events.ts` for a global system event bus.
### API / Interface
- [x] Establish a WebSocket endpoint at `/ws`.
- [x] Define the message protocol:
- `STATS_UPDATE`: Server to client containing full `DashboardStats`.
- `NEW_EVENT`: Server to client when a specific event is recorded.
## 3. Constraints & Validations (CRITICAL)
- **Input Validation:** WS messages validated using JSON parsing and type checks.
- **System Constraints:**
- WebSocket broadcast interval set to 5s for metrics.
- Automatic reconnection logic handled in the frontend hook.
- **Business Logic Guardrails:**
- Events are pushed immediately as they occur via the system event bus.
## 4. Acceptance Criteria
1. [x] **Given** the dashboard is open, **When** a command is run in Discord (e.g. Daily), **Then** the "Recent Events" list updates instantly on the web UI.
2. [x] **Given** a changing network environment, **When** the bot's ping fluctuates, **Then** the "Avg Latency" card updates in real-time.
3. [x] **Given** a connection loss, **When** the network returns, **Then** the client automatically reconnects to the WS room.
## 5. Implementation Plan
- [x] Step 1: Integrate a WebSocket library into `web/src/server.ts` using Bun's native `websocket` support.
- [x] Step 2: Implement a broadcast system in `dashboard.service.ts` to push events to the WS handler using `systemEvents`.
- [x] Step 3: Create/Update `useDashboardStats` hook in the frontend to handle connection lifecycle and state merging.
- [x] Step 4: Refactor `Dashboard.tsx` state consumption to benefit from real-time updates.
## Implementation Notes
### Files Changed
- `shared/lib/events.ts`: New event bus for the system.
- `web/src/server.ts`: Added WebSocket handler and stats broadcast.
- `web/src/hooks/use-dashboard-stats.ts`: Replaced polling with WebSocket + HTTP initial load.
- `shared/modules/dashboard/dashboard.service.ts`: Added `recordEvent` helper to emit WS events.
- `shared/modules/economy/economy.service.ts`: Integrated `recordEvent` into daily claims and transfers.
- `shared/modules/dashboard/dashboard.service.test.ts`: Added unit tests for event emission.

View File

@@ -13,6 +13,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"resolveJsonModule": true,
"noEmit": true, "noEmit": true,
// Best practices // Best practices
"strict": true, "strict": true,

View File

@@ -136,7 +136,7 @@ const build = async () => {
target: "browser", target: "browser",
sourcemap: "linked", sourcemap: "linked",
define: { define: {
"process.env.NODE_ENV": JSON.stringify("production"), "process.env.NODE_ENV": JSON.stringify((cliConfig as any).watch ? "development" : "production"),
}, },
...cliConfig, ...cliConfig,
}); });

View File

@@ -5,11 +5,16 @@
"": { "": {
"name": "bun-react-template", "name": "bun-react-template",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@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-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@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", "@radix-ui/react-tooltip": "^1.2.8",
"bun-plugin-tailwind": "^0.1.2", "bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -17,8 +22,12 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"react-hook-form": "^7.70.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^4.3.5",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@@ -38,6 +47,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], "@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-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": ["@oven/bun-darwin-x64@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-r33eHQOHAwkuiBJIwmkXIyqONQOQMnd1GMTpDzaxx9vf9+svby80LZO9Hcm1ns6KT/TBRFyODC/0loA7FAaffg=="],
@@ -64,8 +75,12 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@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-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-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-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=="],
@@ -94,12 +109,20 @@
"@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-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-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-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-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-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-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=="],
@@ -122,14 +145,40 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], "@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/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/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": ["@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/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=="], "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": ["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=="],
@@ -146,16 +195,54 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "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=="], "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=="], "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=="], "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=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"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": ["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-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=="],
@@ -166,14 +253,26 @@
"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=="], "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=="], "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=="], "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=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "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=="], "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=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
@@ -184,6 +283,12 @@
"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-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-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-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=="],
@@ -197,5 +302,7 @@
"@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-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=="], "@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

@@ -9,11 +9,16 @@
"build": "bun run build.ts" "build": "bun run build.ts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@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-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@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", "@radix-ui/react-tooltip": "^1.2.8",
"bun-plugin-tailwind": "^0.1.2", "bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -21,8 +26,12 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"react-hook-form": "^7.70.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"tailwind-merge": "^3.3.1" "recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19", "@types/react": "^19",

View File

@@ -1,19 +1,18 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { DashboardLayout } from "./layouts/DashboardLayout";
import { Dashboard } from "./pages/Dashboard";
import { Activity } from "./pages/Activity";
import { Settings } from "./pages/Settings";
import "./index.css"; import "./index.css";
import { Dashboard } from "./pages/Dashboard";
import { DesignSystem } from "./pages/DesignSystem";
import { Home } from "./pages/Home";
import { Toaster } from "sonner";
export function App() { export function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Toaster richColors position="top-right" theme="dark" />
<Routes> <Routes>
<Route path="/" element={<DashboardLayout />}> <Route path="/dashboard" element={<Dashboard />} />
<Route index element={<Dashboard />} /> <Route path="/design-system" element={<DesignSystem />} />
<Route path="activity" element={<Activity />} /> <Route path="/" element={<Home />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -1,111 +0,0 @@
import { LayoutDashboard, Settings, Activity } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarFooter,
SidebarRail,
} from "@/components/ui/sidebar";
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
// Menu items.
const items = [
{
title: "Dashboard",
url: "/",
icon: LayoutDashboard,
},
{
title: "Activity",
url: "/activity",
icon: Activity,
},
{
title: "Settings",
url: "/settings",
icon: Settings,
},
];
export function AppSidebar() {
const location = useLocation();
const { stats } = useDashboardStats();
const botName = stats?.bot?.name || "Aurora";
const botAvatar = stats?.bot?.avatarUrl;
return (
<Sidebar className="glass-sidebar border-r border-white/5">
<SidebarHeader className="p-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild className="hover:bg-white/5 transition-all duration-300 rounded-xl">
<Link to="/" className="flex items-center gap-3">
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-purple-600 text-primary-foreground shadow-lg shadow-primary/20 overflow-hidden border border-white/10">
{botAvatar ? (
<img src={botAvatar} alt={botName} className="size-full object-cover" />
) : (
<div className="size-full flex items-center justify-center font-bold text-lg italic">A</div>
)}
</div>
<div className="flex flex-col gap-0 leading-none">
<span className="text-lg font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">{botName}</span>
<span className="text-[10px] uppercase tracking-widest text-primary font-bold">Admin Portal</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="px-2">
<SidebarGroup>
<SidebarGroupLabel className="px-4 text-[10px] font-bold uppercase tracking-[0.2em] text-white/30 mb-2">Main Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-1">
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={location.pathname === item.url}
className={`transition-all duration-200 rounded-lg px-4 py-6 ${location.pathname === item.url
? "bg-primary/10 text-primary border border-primary/20 shadow-lg shadow-primary/5"
: "hover:bg-white/5 text-white/60 hover:text-white"
}`}
>
<Link to={item.url} className="flex items-center gap-3">
<item.icon className={`size-5 ${location.pathname === item.url ? "text-primary" : ""}`} />
<span className="font-medium">{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-4 border-t border-white/5">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="hover:bg-white/5 rounded-xl transition-colors">
<div className="bg-primary/20 border border-primary/20 flex aspect-square size-10 items-center justify-center rounded-full overflow-hidden">
<span className="text-sm font-bold text-primary italic">A</span>
</div>
<div className="flex flex-col gap-0.5 leading-none ml-2">
<span className="font-bold text-sm text-white/90">Administrator</span>
<span className="text-[10px] text-white/40 font-medium">Session Active</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View File

@@ -1,126 +0,0 @@
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
import { Button } from "./ui/button";
import { RefreshCw, Trash2, ShieldAlert, Loader2, Power } from "lucide-react";
/**
* Props for the ControlPanel component
*/
interface ControlPanelProps {
maintenanceMode: boolean;
}
declare global {
interface Window {
AURORA_ENV?: {
ADMIN_TOKEN: string;
};
}
}
/**
* ControlPanel component provides quick administrative actions for the bot.
* Integrated with the premium glassmorphic theme.
*/
export function ControlPanel({ maintenanceMode }: ControlPanelProps) {
const [loading, setLoading] = useState<string | null>(null);
/**
* Handles triggering an administrative action via the API
*/
const handleAction = async (action: string, payload?: Record<string, unknown>) => {
setLoading(action);
try {
const token = window.AURORA_ENV?.ADMIN_TOKEN;
const response = await fetch(`/api/actions/${action}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: payload ? JSON.stringify(payload) : undefined,
});
if (!response.ok) throw new Error(`Action ${action} failed`);
} catch (error) {
console.error("Action Error:", error);
// Ideally we'd show a toast here
} finally {
setLoading(null);
}
};
return (
<Card className="glass border-white/5 overflow-hidden group">
<CardHeader className="relative">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ShieldAlert className="h-12 w-12" />
</div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<div className="h-5 w-1 bg-primary rounded-full" />
System Controls
</CardTitle>
<CardDescription className="text-white/40">Administrative bot operations</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-2 gap-4">
{/* Reload Commands Button */}
<Button
variant="outline"
className="glass hover:bg-white/10 border-white/10 hover:border-primary/50 flex flex-col items-start gap-2 h-auto py-4 px-4 transition-all group/btn"
onClick={() => handleAction("reload-commands")}
disabled={!!loading}
>
<div className="p-2 rounded-lg bg-primary/10 text-primary group-hover/btn:scale-110 transition-transform">
{loading === "reload-commands" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</div>
<div className="space-y-0.5 text-left">
<p className="text-sm font-bold">Reload</p>
<p className="text-[10px] text-white/30">Sync commands</p>
</div>
</Button>
{/* Clear Cache Button */}
<Button
variant="outline"
className="glass hover:bg-white/10 border-white/10 hover:border-blue-500/50 flex flex-col items-start gap-2 h-auto py-4 px-4 transition-all group/btn"
onClick={() => handleAction("clear-cache")}
disabled={!!loading}
>
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500 group-hover/btn:scale-110 transition-transform">
{loading === "clear-cache" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</div>
<div className="space-y-0.5 text-left">
<p className="text-sm font-bold">Flush</p>
<p className="text-[10px] text-white/30">Clear caches</p>
</div>
</Button>
</div>
{/* Maintenance Mode Toggle Button */}
<Button
variant="outline"
className={`glass flex items-center justify-between h-auto py-4 px-5 border-white/10 transition-all group/maint ${maintenanceMode
? 'bg-red-500/10 border-red-500/50 hover:bg-red-500/20'
: 'hover:border-yellow-500/50 hover:bg-yellow-500/5'
}`}
onClick={() => handleAction("maintenance-mode", { enabled: !maintenanceMode, reason: "Dashboard toggle" })}
disabled={!!loading}
>
<div className="flex items-center gap-4">
<div className={`p-2.5 rounded-full transition-all ${maintenanceMode ? 'bg-red-500 text-white animate-pulse shadow-[0_0_15px_rgba(239,68,68,0.4)]' : 'bg-white/5 text-white/40'
}`}>
{loading === "maintenance-mode" ? <Loader2 className="h-5 w-5 animate-spin" /> : <Power className="h-5 w-5" />}
</div>
<div className="text-left">
<p className="text-sm font-bold">Maintenance Mode</p>
<p className="text-[10px] text-white/30">
{maintenanceMode ? "Bot is currently restricted" : "Restrict bot access"}
</p>
</div>
</div>
<div className={`h-2 w-2 rounded-full ${maintenanceMode ? 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]' : 'bg-white/10'}`} />
</Button>
</CardContent>
</Card>
);
}

View File

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

@@ -0,0 +1,230 @@
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");
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"}`, {
duration: 2000,
id: "command-toggle", // Replace previous toast instead of stacking
});
} catch (error) {
toast.error("Failed to toggle command");
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

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

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

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

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

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

@@ -0,0 +1,39 @@
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
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>
)}
<Icon className={cn(
"h-4 w-4 transition-all duration-300",
onClick && "group-hover:text-primary group-hover:scale-110",
iconClassName || "text-muted-foreground"
)} />
</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

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

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

@@ -0,0 +1,37 @@
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",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:opacity-80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground border-border hover:bg-accent hover:text-accent-foreground",
aurora: "border-transparent bg-aurora text-primary-foreground shadow-sm",
glass: "glass-card border-border/50 text-foreground",
},
},
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

@@ -19,6 +19,8 @@ const buttonVariants = cva(
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
aurora: "bg-aurora text-primary-foreground shadow-sm hover:opacity-90",
glass: "glass-card border-border/50 text-foreground hover:bg-accent/50",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-lg border py-6 shadow-sm",
className className
)} )}
{...props} {...props}

View File

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

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] 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", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base text-foreground shadow-xs transition-[color,box-shadow] 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",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className

View File

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

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

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

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

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

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

@@ -0,0 +1,18 @@
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,132 +0,0 @@
import { useState, useEffect } from "react";
interface DashboardStats {
bot: {
name: string;
avatarUrl: string | null;
};
guilds: {
count: number;
};
users: {
active: number;
total: number;
};
commands: {
total: number;
};
ping: {
avg: number;
};
economy: {
totalWealth: string;
avgLevel: number;
topStreak: number;
};
recentEvents: Array<{
type: 'success' | 'error' | 'info' | 'warn';
message: string;
timestamp: string;
icon?: string;
}>;
uptime: number;
lastCommandTimestamp: number | null;
maintenanceMode: boolean;
}
interface UseDashboardStatsResult {
stats: DashboardStats | null;
loading: boolean;
error: string | null;
}
/**
* Custom hook to fetch and auto-refresh dashboard statistics using WebSockets with HTTP fallback
*/
export function useDashboardStats(): UseDashboardStatsResult {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = async () => {
try {
const response = await fetch("/api/stats");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
setStats(data);
setError(null);
} catch (err) {
console.error("Failed to fetch dashboard stats:", err);
setError(err instanceof Error ? err.message : "Failed to fetch stats");
} finally {
setLoading(false);
}
};
useEffect(() => {
// Initial fetch
fetchStats();
// WebSocket setup
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws`;
let socket: WebSocket | null = null;
let reconnectTimeout: Timer | null = null;
const connect = () => {
socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log("🟢 [WS] Connected to dashboard live stream");
setError(null);
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
};
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "STATS_UPDATE") {
setStats(message.data);
} else if (message.type === "NEW_EVENT") {
setStats(prev => {
if (!prev) return prev;
return {
...prev,
recentEvents: [message.data, ...prev.recentEvents].slice(0, 10)
};
});
}
} catch (e) {
console.error("Error parsing WS message:", e);
}
};
socket.onclose = () => {
console.log("🟠 [WS] Connection lost. Attempting reconnect in 5s...");
reconnectTimeout = setTimeout(connect, 5000);
};
socket.onerror = (err) => {
console.error("🔴 [WS] Socket error:", err);
socket?.close();
};
};
connect();
// Cleanup on unmount
return () => {
if (socket) {
socket.onclose = null; // Prevent reconnect on intentional close
socket.close();
}
if (reconnectTimeout) clearTimeout(reconnectTimeout);
};
}, []);
return { stats, loading, error };
}

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

@@ -0,0 +1,61 @@
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,35 +0,0 @@
import { Outlet } from "react-router-dom";
import { AppSidebar } from "../components/AppSidebar";
import { SidebarProvider, SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
import { Separator } from "../components/ui/separator";
export function DashboardLayout() {
return (
<SidebarProvider>
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/20 blur-[120px] rounded-full animate-pulse" />
<div className="absolute bottom-[-10%] right-[-10%] w-[30%] h-[30%] bg-purple-500/10 blur-[100px] rounded-full" />
</div>
<AppSidebar />
<SidebarInset className="bg-transparent">
<header className="flex h-16 shrink-0 items-center gap-2 px-6 backdrop-blur-md bg-background/30 border-b border-white/5 sticky top-0 z-10">
<SidebarTrigger className="-ml-1 hover:bg-white/5 transition-colors" />
<Separator orientation="vertical" className="mx-4 h-4 bg-white/10" />
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight text-glow">Dashboard</h1>
</div>
<div className="ml-auto flex items-center gap-4">
<div className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
</div>
</header>
<main className="flex flex-1 flex-col gap-6 p-6">
<div className="flex-1 rounded-2xl md:min-h-min">
<Outlet />
</div>
</main>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,12 +0,0 @@
export function Activity() {
return (
<div>
<h2 className="text-3xl font-bold tracking-tight">Activity</h2>
<p className="text-muted-foreground">Recent bot activity logs.</p>
<div className="mt-6 rounded-xl border border-dashed p-8 text-center text-muted-foreground">
Activity feed coming soon...
</div>
</div>
);
}

View File

@@ -1,175 +1,201 @@
import { import React, { useState } from "react";
Card, import { Link } from "react-router-dom";
CardContent, import { useSocket } from "../hooks/use-socket";
CardDescription, import { Badge } from "../components/ui/badge";
CardHeader, import { StatCard } from "../components/stat-card";
CardTitle, import { RecentActivity } from "../components/recent-activity";
} from "@/components/ui/card"; import { ActivityChart } from "../components/activity-chart";
import { Activity, Server, Users, Zap } from "lucide-react"; import { LootdropCard } from "../components/lootdrop-card";
import { useDashboardStats } from "@/hooks/use-dashboard-stats"; import { LeaderboardCard } from "../components/leaderboard-card";
import { ControlPanel } from "@/components/ControlPanel"; import { CommandsDrawer } from "../components/commands-drawer";
import { Server, Users, Terminal, Activity, Coins, TrendingUp, Flame, Package } from "lucide-react";
import { cn } from "../lib/utils";
import { SettingsDrawer } from "../components/settings-drawer";
export function Dashboard() { export function Dashboard() {
const { stats, loading, error } = useDashboardStats(); const { isConnected, stats } = useSocket();
const [commandsDrawerOpen, setCommandsDrawerOpen] = useState(false);
if (loading && !stats) {
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
<p className="text-muted-foreground">Loading dashboard data...</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
</CardHeader>
<CardContent>
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
<p className="text-destructive">Error loading dashboard: {error}</p>
</div>
</div>
);
}
if (!stats) {
return null;
}
return ( return (
<div className="space-y-8 animate-in fade-in duration-700"> <div className="min-h-screen bg-aurora-page text-foreground font-outfit overflow-x-hidden">
<div className="flex flex-col gap-2"> {/* Navigation */}
<h2 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-white to-white/40"> <nav className="sticky top-0 z-50 glass-card border-b border-border/50 py-4 px-8 flex justify-between items-center">
{stats.bot.name} Overview <div className="flex items-center gap-3">
</h2> {/* Bot Avatar */}
<p className="text-white/40 font-medium">Monitoring real-time activity and core bot metrics.</p> {stats?.bot?.avatarUrl ? (
</div> <img
src={stats.bot.avatarUrl}
alt="Aurora Avatar"
className="w-8 h-8 rounded-full border border-primary/20 shadow-sm object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-aurora sun-flare shadow-sm" />
)}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <span className="text-xl font-bold tracking-tight text-primary">Aurora</span>
{/* Metric Cards */}
{[
{ title: "Active Users", value: stats.users.active.toLocaleString(), label: `${stats.users.total.toLocaleString()} total registered`, icon: Users, color: "from-purple-500 to-pink-500" },
{ title: "Commands registered", value: stats.commands.total, label: "Total system capabilities", icon: Zap, color: "from-yellow-500 to-orange-500" },
{ title: "Avg Latency", value: `${stats.ping.avg}ms`, label: "WebSocket heartbeat", icon: Activity, color: "from-emerald-500 to-teal-500" },
].map((metric, i) => (
<Card key={i} className="glass group hover:border-primary/50 transition-all duration-300 hover:scale-[1.02]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-white/50">{metric.title}</CardTitle>
<div className={`p-2 rounded-lg bg-gradient-to-br ${metric.color} bg-opacity-10 group-hover:scale-110 transition-transform duration-300`}>
<metric.icon className="h-4 w-4 text-white" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tracking-tight mb-1">{metric.value}</div>
<p className="text-xs font-medium text-white/30">{metric.label}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7"> {/* Live Status Badge */}
<Card className="col-span-4 glass border-white/5"> <div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full border transition-colors duration-500 ${isConnected
<CardHeader className="flex flex-row items-start justify-between"> ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500"
<div> : "bg-red-500/10 border-red-500/20 text-red-500"
<CardTitle className="text-xl font-bold flex items-center gap-2"> }`}>
<div className="h-5 w-1 bg-primary rounded-full" /> <div className="relative flex h-2 w-2">
Economy Overview {isConnected && (
</CardTitle> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75"></span>
<CardDescription className="text-white/40">Global wealth and progression statistics</CardDescription> )}
<span className={`relative inline-flex rounded-full h-2 w-2 ${isConnected ? "bg-emerald-500" : "bg-red-500"}`}></span>
</div> </div>
<div className="bg-white/5 px-3 py-1.5 rounded-full border border-white/10 flex items-center gap-2"> <span className="text-[10px] font-bold tracking-wider uppercase">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> {isConnected ? "Live" : "Offline"}
<span className="text-[10px] font-bold uppercase tracking-widest text-white/50">
Uptime: {Math.floor(stats.uptime / 3600)}h {Math.floor((stats.uptime % 3600) / 60)}m
</span> </span>
</div> </div>
</CardHeader>
<CardContent>
<div className="grid gap-8">
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-primary/20 to-purple-500/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition duration-1000"></div>
<div className="relative bg-white/5 rounded-xl p-6 border border-white/10">
<p className="text-sm font-bold uppercase tracking-wider text-white/30 mb-1">Total Distributed Wealth</p>
<p className="text-4xl font-black text-glow bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-400">
{BigInt(stats.economy.totalWealth).toLocaleString()} <span className="text-xl font-bold text-white/20">AU</span>
</p>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<p className="text-xs font-bold text-white/30 uppercase tracking-widest mb-1">Avg Level</p>
<p className="text-2xl font-bold">{stats.economy.avgLevel}</p>
</div>
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<p className="text-xs font-bold text-white/30 uppercase tracking-widest mb-1">Peak Streak</p>
<p className="text-2xl font-bold">{stats.economy.topStreak} <span className="text-sm text-white/20">days</span></p>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="col-span-3 flex flex-col gap-6"> <div className="flex items-center gap-6">
{/* Administrative Control Panel */} <Link to="/" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
<ControlPanel maintenanceMode={stats.maintenanceMode} /> Home
</Link>
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Design System
</Link>
<div className="h-4 w-px bg-border/50" />
<SettingsDrawer />
</div>
</nav>
{/* Recent Events Feed */} {/* Dashboard Content */}
<Card className="glass border-white/5 overflow-hidden flex-1"> <main className="pt-8 px-8 pb-8 max-w-7xl mx-auto space-y-8">
<CardHeader className="bg-white/[0.02] border-b border-white/5">
<CardTitle className="text-xl font-bold">Recent Events</CardTitle> {/* Stats Grid */}
<CardDescription className="text-white/30">Live system activity feed</CardDescription> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 animate-in fade-in slide-up">
</CardHeader> <StatCard
<CardContent className="p-0"> title="Total Servers"
<div className="divide-y divide-white/5"> icon={Server}
{stats.recentEvents.length === 0 ? ( isLoading={!stats}
<div className="p-8 text-center bg-transparent"> value={stats?.guilds.count.toLocaleString()}
<p className="text-sm text-white/20 font-medium">No activity recorded</p> 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> </div>
) : (
stats.recentEvents.slice(0, 6).map((event, i) => ( {/* Activity Chart */}
<div key={i} className="flex items-start gap-4 p-4 hover:bg-white/[0.03] transition-colors group"> <div className="animate-in fade-in slide-up delay-400">
<div className={`mt-1 p-2 rounded-lg ${event.type === 'success' ? 'bg-emerald-500/10 text-emerald-500' : <ActivityChart />
event.type === 'error' ? 'bg-red-500/10 text-red-500' :
event.type === 'warn' ? 'bg-yellow-500/10 text-yellow-500' :
'bg-blue-500/10 text-blue-500'
} group-hover:scale-110 transition-transform`}>
<div className="text-lg leading-none">{event.icon}</div>
</div>
<div className="space-y-1 flex-1">
<p className="text-sm font-semibold text-white/90 leading-tight">
{event.message}
</p>
<p className="text-[10px] font-bold text-white/20 uppercase tracking-wider">
{new Date(event.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
))
)}
</div>
{stats.recentEvents.length > 0 && (
<button className="w-full py-3 text-[10px] font-bold uppercase tracking-[0.2em] text-white/20 hover:text-primary hover:bg-white/[0.02] transition-all border-t border-white/5">
View Event Logs
</button>
)}
</CardContent>
</Card>
</div> </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-4">
<h2 className="text-xl font-semibold tracking-tight">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>
<LeaderboardCard
data={stats?.leaderboards}
isLoading={!stats}
className="w-full"
/>
</div>
{/* Recent Activity & Lootdrops */}
<div className="space-y-4">
<LootdropCard
drop={stats?.activeLootdrops?.[0]}
state={stats?.lootdropState}
isLoading={!stats}
/>
<h2 className="text-xl font-semibold tracking-tight">Live Feed</h2>
<RecentActivity
events={stats?.recentEvents || []}
isLoading={!stats}
className="h-[calc(100%-2rem)]"
/>
</div> </div>
</div> </div>
</main >
{/* Commands Drawer */}
<CommandsDrawer
open={commandsDrawerOpen}
onOpenChange={setCommandsDrawerOpen}
/>
</div >
); );
} }

View File

@@ -0,0 +1,508 @@
import React from "react";
import { Link } from "react-router-dom";
import { Badge } from "../components/ui/badge";
import { Card, CardHeader, CardTitle, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Switch } from "../components/ui/switch";
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 { Activity, Coins, Flame, Trophy } from "lucide-react";
import { SettingsDrawer } from "../components/settings-drawer";
import { RecentActivity } from "../components/recent-activity";
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
import { LeaderboardCard, type LeaderboardData } from "../components/leaderboard-card";
import { ActivityChart } from "../components/activity-chart";
import { type ActivityData } from "@shared/modules/dashboard/dashboard.types";
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 mockManyEvents: RecentEvent[] = Array.from({ length: 15 }).map((_, i) => ({
type: i % 3 === 0 ? 'success' : i % 3 === 1 ? 'info' : 'error', // Use string literals matching the type definition
message: `Event #${i + 1} generated for testing scroll behavior`,
timestamp: new Date(Date.now() - 1000 * 60 * i * 10),
icon: i % 3 === 0 ? '✨' : i % 3 === 1 ? '' : '🚨',
}));
const mockLeaderboardData: LeaderboardData = {
topLevels: [
{ username: "StellarMage", level: 99 },
{ username: "MoonWalker", level: 85 },
{ username: "SunChaser", level: 72 },
{ username: "NebulaKnight", level: 68 },
{ username: "CometRider", level: 65 },
{ username: "VoidWalker", level: 60 },
{ username: "AstroBard", level: 55 },
{ username: "StarGazer", level: 50 },
{ username: "CosmicDruid", level: 45 },
{ username: "GalaxyGuard", level: 42 }
],
topWealth: [
{ username: "GoldHoarder", balance: "1000000" },
{ username: "MerchantKing", balance: "750000" },
{ username: "LuckyLooter", balance: "500000" },
{ username: "CryptoMiner", balance: "450000" },
{ username: "MarketMaker", balance: "300000" },
{ username: "TradeWind", balance: "250000" },
{ username: "CoinKeeper", balance: "150000" },
{ username: "GemHunter", balance: "100000" },
{ username: "DustCollector", balance: "50000" },
{ username: "BrokeBeginner", balance: "100" }
],
topNetWorth: [
{ username: "MerchantKing", netWorth: "1500000" },
{ username: "GoldHoarder", netWorth: "1250000" },
{ username: "LuckyLooter", netWorth: "850000" },
{ username: "MarketMaker", netWorth: "700000" },
{ username: "GemHunter", netWorth: "650000" },
{ username: "CryptoMiner", netWorth: "550000" },
{ username: "TradeWind", netWorth: "400000" },
{ username: "CoinKeeper", netWorth: "250000" },
{ username: "DustCollector", netWorth: "150000" },
{ username: "BrokeBeginner", netWorth: "5000" }
]
};
export function DesignSystem() {
return (
<div className="min-h-screen bg-aurora-page text-foreground font-outfit">
{/* Navigation */}
<nav className="fixed top-0 w-full z-50 glass-card border-b border-border/50 py-4 px-8 flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-aurora sun-flare" />
<span className="text-xl font-bold tracking-tight text-primary">Aurora</span>
</div>
<div className="flex items-center gap-6">
<Link to="/" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Home
</Link>
<Link to="/dashboard" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Dashboard
</Link>
</div>
</nav>
<div className="pt-32 px-8 max-w-6xl mx-auto space-y-12 text-center md:text-left">
{/* Header Section */}
<header className="space-y-4 animate-in fade-in">
<Badge variant="aurora" className="mb-2">v1.2.0-solar</Badge>
<h1 className="text-6xl font-extrabold tracking-tight text-primary">
Aurora Design System
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto md:mx-0">
Welcome to the Solaris Dark theme. A warm, celestial-inspired aesthetic designed for the Aurora astrology RPG.
</p>
</header>
{/* Color Palette */}
<section className="space-y-6 animate-in slide-up delay-100">
<h2 className="text-3xl font-bold flex items-center justify-center md:justify-start gap-2">
<span className="w-8 h-8 rounded-full bg-primary inline-block" />
Color Palette
</h2>
<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>
{/* Badges & Pills */}
<section className="space-y-6 animate-in slide-up delay-200">
<h2 className="text-3xl font-bold flex items-center justify-center md:justify-start gap-2">
<span className="w-8 h-8 rounded-full bg-primary inline-block" />
Badges & Tags
</h2>
<div className="flex flex-wrap gap-4 items-center justify-center md:justify-start">
<Badge className="hover-scale cursor-default">Primary</Badge>
<Badge variant="secondary" className="hover-scale cursor-default">Secondary</Badge>
<Badge variant="aurora" className="hover-scale cursor-default">Solaris</Badge>
<Badge variant="glass" className="hover-scale cursor-default">Celestial Glass</Badge>
<Badge variant="outline" className="hover-scale cursor-default">Outline</Badge>
<Badge variant="destructive" className="hover-scale cursor-default">Destructive</Badge>
</div>
</section>
{/* Animations & Interactions */}
<section className="space-y-6 animate-in slide-up delay-300">
<h2 className="text-3xl font-bold flex items-center justify-center md:justify-start gap-2">
<span className="w-8 h-8 rounded-full bg-primary inline-block" />
Animations & Interactions
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="glass-card p-6 rounded-xl hover-lift cursor-pointer space-y-2">
<h3 className="font-bold text-primary">Hover Lift</h3>
<p className="text-sm text-muted-foreground">Smooth upward translation with enhanced depth.</p>
</div>
<div className="glass-card p-6 rounded-xl hover-glow cursor-pointer space-y-2">
<h3 className="font-bold text-primary">Hover Glow</h3>
<p className="text-sm text-muted-foreground">Subtle border and shadow illumination on hover.</p>
</div>
<div className="flex items-center justify-center p-6">
<Button className="bg-primary text-primary-foreground active-press font-bold px-8 py-6 rounded-xl shadow-lg">
Press Interaction
</Button>
</div>
</div>
</section>
{/* Gradients & Special Effects */}
<section className="space-y-6 animate-in slide-up delay-400">
<h2 className="text-3xl font-bold flex items-center justify-center md:justify-start gap-2">
<span className="w-8 h-8 rounded-full bg-primary inline-block" />
Gradients & Effects
</h2>
<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 (Background)</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>
{/* Components Showcase */}
<section className="space-y-6 animate-in slide-up delay-500">
<h2 className="text-3xl font-bold flex items-center justify-center md:justify-start gap-2">
<span className="w-8 h-8 rounded-full bg-primary inline-block" />
Component Showcase
</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Action Card with Tags */}
<Card className="glass-card sun-flare overflow-hidden border-none text-left hover-lift transition-all">
<div className="h-2 bg-primary w-full" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-primary">Celestial Action</CardTitle>
<Badge variant="aurora" className="h-5">New</Badge>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Badge variant="glass" className="text-[10px] uppercase">Quest</Badge>
<Badge variant="glass" className="text-[10px] uppercase">Level 15</Badge>
</div>
<p className="text-muted-foreground text-sm">
Experience the warmth of the sun in every interaction and claim your rewards.
</p>
<div className="flex gap-2 pt-2">
<Button className="bg-primary text-primary-foreground active-press font-bold px-6">
Ascend
</Button>
</div>
</CardContent>
</Card>
{/* Profile/Entity Card with Tags */}
<Card className="glass-card text-left hover-lift transition-all">
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="w-12 h-12 rounded-full bg-aurora border-2 border-primary/20 hover-scale transition-transform cursor-pointer" />
<Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20">Online</Badge>
</div>
<CardTitle className="mt-4">Stellar Navigator</CardTitle>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Level 42 Mage</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="text-[10px] py-0 hover-scale cursor-default">Astronomy</Badge>
<Badge variant="outline" className="text-[10px] py-0 hover-scale cursor-default">Pyromancy</Badge>
<Badge variant="outline" className="text-[10px] py-0 hover-scale cursor-default">Leadership</Badge>
</div>
<div className="h-1.5 w-full bg-secondary/20 rounded-full overflow-hidden">
<div className="h-full bg-aurora w-[75%] animate-in slide-up delay-500" />
</div>
</CardContent>
</Card>
{/* Interactive Card with Tags */}
<Card className="glass-card text-left hover-glow transition-all">
<CardHeader>
<div className="flex items-center gap-2 mb-2">
<Badge variant="glass" className="bg-primary/10 text-primary border-primary/20">Beta</Badge>
</div>
<div className="flex justify-between items-center">
<CardTitle>System Settings</CardTitle>
<SettingsDrawer />
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="font-medium">Starry Background</div>
<div className="text-sm text-muted-foreground">Enable animated SVG stars</div>
</div>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="font-medium flex items-center gap-2">
Solar Flare Glow
<Badge className="bg-amber-500/10 text-amber-500 border-amber-500/20 text-[9px] h-4">Pro</Badge>
</div>
<div className="text-sm text-muted-foreground">Add bloom to primary elements</div>
</div>
<Switch defaultChecked />
</div>
</CardContent>
</Card>
</div>
</section>
{/* Refactored Application Components */}
<section className="space-y-6 animate-in slide-up delay-600">
<h2 className="text-3xl font-bold flex items-center justify-center md:justify-start gap-2">
<span className="w-8 h-8 rounded-full bg-primary inline-block" />
Application Components
</h2>
<div className="space-y-12">
{/* Section Header Demo */}
<div className="border border-border/50 rounded-xl p-8 bg-background/50">
<SectionHeader
badge="Components"
title="Section Headers"
description="Standardized header component for defining page sections with badge, title, and description."
/>
</div>
{/* Feature Cards Demo */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FeatureCard
title="Feature Card"
category="UI Element"
description="A versatile card component for the bento grid layout."
icon={<div className="w-20 h-20 bg-primary/20 rounded-full animate-pulse" />}
/>
<FeatureCard
title="Interactive Feature"
category="Interactive"
description="Supports custom children nodes for complex content."
>
<div className="mt-2 p-3 bg-secondary/10 border border-secondary/20 rounded text-center text-secondary text-sm font-bold">
Custom Child Content
</div>
</FeatureCard>
</div>
{/* Info Cards Demo */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<InfoCard
icon={<div className="w-6 h-6 rounded-full bg-primary animate-ping" />}
title="Info Card"
description="Compact card for highlighting features or perks with an icon."
iconWrapperClassName="bg-primary/20 text-primary"
/>
</div>
{/* Stat Cards Demo */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Standard Stat"
value="1,234"
subtitle="Active users"
icon={Activity}
isLoading={false}
/>
<StatCard
title="Colored Stat"
value="9,999 AU"
subtitle="Total Wealth"
icon={Coins}
isLoading={false}
valueClassName="text-primary"
iconClassName="text-primary"
/>
<StatCard
title="Loading State"
value={null}
icon={Flame}
isLoading={true}
/>
</div>
{/* Data Visualization Demo */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-muted-foreground">Data Visualization</h3>
<div className="grid grid-cols-1 gap-6">
<ActivityChart
data={mockActivityData}
/>
<ActivityChart
// Empty charts (loading state)
/>
</div>
</div>
{/* Game Event Cards Demo */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-muted-foreground">Game Event Cards</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<LootdropCard
isLoading={true}
/>
<LootdropCard
drop={null}
state={{
monitoredChannels: 3,
hottestChannel: {
id: "123",
messages: 42,
progress: 42,
cooldown: false
},
config: { requiredMessages: 100, dropChance: 0.1 }
}}
isLoading={false}
/>
<LootdropCard
drop={null}
state={{
monitoredChannels: 3,
hottestChannel: {
id: "123",
messages: 100,
progress: 100,
cooldown: true
},
config: { requiredMessages: 100, dropChance: 0.1 }
}}
isLoading={false}
/>
<LootdropCard
drop={{
rewardAmount: 500,
currency: "AU",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60000).toISOString()
}}
isLoading={false}
/>
</div>
</div>
{/* Leaderboard Demo */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-muted-foreground">Leaderboard Cards</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<LeaderboardCard
isLoading={true}
/>
<LeaderboardCard
data={mockLeaderboardData}
isLoading={false}
/>
</div>
</div>
{/* Testimonial Cards Demo */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<TestimonialCard
quote="The testimonial card is perfect for social proof sections."
author="Jane Doe"
role="Beta Tester"
avatarGradient="bg-gradient-to-br from-pink-500 to-rose-500"
/>
</div>
{/* Recent Activity Demo */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-muted-foreground">Recent Activity Feed</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 h-[500px]">
<RecentActivity
events={[]}
isLoading={true}
className="h-full"
/>
<RecentActivity
events={[]}
isLoading={false}
className="h-full"
/>
<RecentActivity
events={mockEvents}
isLoading={false}
className="h-full"
/>
<RecentActivity
events={mockManyEvents}
isLoading={false}
className="h-full"
/>
</div>
</div>
</div>
</section>
{/* Typography */}
<section className="space-y-8 pb-12">
<h2 className="text-step-3 font-bold text-center">Fluid Typography</h2>
<div className="space-y-6">
<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>
<p className="text-step--1 text-muted-foreground text-center italic">
Try resizing your browser window to see the text scale smoothly.
</p>
</section>
</div>
</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">
<span className="text-step--2 font-mono text-muted-foreground w-20">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="space-y-2">
<div className={`h-20 w-full rounded-lg ${color} ${border ? 'border border-border' : ''} flex items-end p-2 shadow-lg`}>
<span className={`text-xs font-bold uppercase tracking-widest ${text}`}>{label}</span>
</div>
</div>
);
}
export default DesignSystem;

236
web/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,236 @@
import React from "react";
import { Link } from "react-router-dom";
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 (
<div className="min-h-screen bg-aurora-page text-foreground font-outfit overflow-x-hidden">
{/* Navigation (Simple) */}
<nav className="fixed top-0 w-full z-50 glass-card border-b border-border/50 py-4 px-8 flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-aurora sun-flare" />
<span className="text-xl font-bold tracking-tight text-primary">Aurora</span>
</div>
<div className="flex items-center gap-6">
<Link to="/dashboard" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Dashboard
</Link>
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
Design System
</Link>
</div>
</nav>
{/* Hero Section */}
<header className="relative pt-32 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>
</div>
);
}
export default Home;

View File

@@ -1,12 +0,0 @@
export function Settings() {
return (
<div>
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">Manage bot configuration.</p>
<div className="mt-6 rounded-xl border border-dashed p-8 text-center text-muted-foreground">
Settings panel coming soon...
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
import { type WebServerInstance } from "./server";
// Mock the dependencies
const mockConfig = {
leveling: {
base: 100,
exponent: 1.5,
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
},
economy: {
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: 50n },
exam: { multMin: 1.5, multMax: 2.5 }
},
inventory: { maxStackSize: 99n, maxSlots: 20 },
lootdrop: {
spawnChance: 0.1,
cooldownMs: 3600000,
minMessages: 10,
reward: { min: 100, max: 500, currency: "gold" }
},
commands: { "help": true },
system: {},
moderation: {
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
cases: { dmOnWarn: true }
}
};
const mockSaveConfig = jest.fn();
// Mock @shared/lib/config using mock.module
mock.module("@shared/lib/config", () => ({
config: mockConfig,
saveConfig: mockSaveConfig,
GameConfigType: {}
}));
// Mock BotClient
const mockGuild = {
roles: {
cache: [
{ id: "role1", name: "Admin", hexColor: "#ffffff", position: 1 },
{ id: "role2", name: "User", hexColor: "#000000", position: 0 }
]
},
channels: {
cache: [
{ id: "chan1", name: "general", type: 0 }
]
}
};
mock.module("../../bot/lib/BotClient", () => ({
AuroraClient: {
guilds: {
cache: {
get: () => mockGuild
}
},
commands: [
{ data: { name: "ping" } }
],
knownCommands: new Map([
["ping", "utility"],
["help", "utility"],
["disabled-cmd", "admin"]
])
}
}));
mock.module("@shared/lib/env", () => ({
env: {
DISCORD_GUILD_ID: "123456789"
}
}));
// Mock spawn
mock.module("bun", () => {
return {
spawn: jest.fn(() => ({
unref: () => { }
})),
serve: Bun.serve
};
});
// Import createWebServer after mocks
import { createWebServer } from "./server";
describe("Settings API", () => {
let serverInstance: WebServerInstance;
const PORT = 3009;
const BASE_URL = `http://localhost:${PORT}`;
beforeEach(async () => {
jest.clearAllMocks();
serverInstance = await createWebServer({ port: PORT });
});
afterEach(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
it("GET /api/settings should return current configuration", async () => {
const res = await fetch(`${BASE_URL}/api/settings`);
expect(res.status).toBe(200);
const data = await res.json();
// Check if BigInts are converted to strings
expect(data.economy.daily.amount).toBe("100");
expect(data.leveling.base).toBe(100);
});
it("POST /api/settings should save valid configuration via merge", async () => {
// We only send a partial update, expecting the server to merge it
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
// But the user requested "partial vs full" fix.
// Let's assume we implement the merge logic.
const partialConfig = { studentRole: "new-role-partial" };
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(partialConfig)
});
expect(res.status).toBe(200);
// Expect saveConfig to be called with the MERGED result
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
studentRole: "new-role-partial",
leveling: mockConfig.leveling // Should keep existing values
}));
});
it("POST /api/settings should return 400 when save fails", async () => {
mockSaveConfig.mockImplementationOnce(() => {
throw new Error("Validation failed");
});
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.details).toBe("Validation failed");
});
it("GET /api/settings/meta should return simplified metadata", async () => {
const res = await fetch(`${BASE_URL}/api/settings/meta`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.roles).toHaveLength(2);
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
// Check new commands structure
expect(data.commands).toBeArray();
expect(data.commands.length).toBeGreaterThan(0);
expect(data.commands[0]).toHaveProperty("name");
expect(data.commands[0]).toHaveProperty("category");
});
});

View File

@@ -17,6 +17,8 @@ mock.module("@shared/db/DrizzleClient", () => {
const mockBuilder = { const mockBuilder = {
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])), where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]), then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
orderBy: mock(() => mockBuilder), // Chainable
limit: mock(() => Promise.resolve([])), // Terminal
}; };
const mockFrom = { const mockFrom = {
@@ -33,6 +35,7 @@ mock.module("@shared/db/DrizzleClient", () => {
findFirst: mock(() => Promise.resolve({ username: "test" })), findFirst: mock(() => Promise.resolve({ username: "test" })),
findMany: mock(() => Promise.resolve([])), findMany: mock(() => Promise.resolve([])),
}, },
lootdrops: { findMany: mock(() => Promise.resolve([])) },
} }
}, },
}; };
@@ -100,37 +103,20 @@ describe("WebServer Security & Limits", () => {
expect(data.status).toBe("ok"); expect(data.status).toBe("ok");
}); });
describe("Administrative Actions Authorization", () => { describe("Administrative Actions", () => {
test("should reject administrative actions without token", async () => { test("should allow administrative actions without token", async () => {
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, { const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
method: "POST" method: "POST"
}); });
expect(response.status).toBe(401); // Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
}); expect(response.status).not.toBe(401);
test("should reject administrative actions with invalid token", async () => {
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
method: "POST",
headers: { "Authorization": "Bearer wrong-token" }
});
expect(response.status).toBe(401);
});
test("should accept administrative actions with valid token", async () => {
const { env } = await import("@shared/lib/env");
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
method: "POST",
headers: { "Authorization": `Bearer ${env.ADMIN_TOKEN}` }
});
expect(response.status).toBe(200); expect(response.status).toBe(200);
}); });
test("should reject maintenance mode with invalid payload", async () => { test("should reject maintenance mode with invalid payload", async () => {
const { env } = await import("@shared/lib/env");
const response = await fetch(`http://localhost:${port}/api/actions/maintenance-mode`, { const response = await fetch(`http://localhost:${port}/api/actions/maintenance-mode`, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${env.ADMIN_TOKEN}`,
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify({ not_enabled: true }) // Wrong field body: JSON.stringify({ not_enabled: true }) // Wrong field

View File

@@ -6,6 +6,7 @@
import { serve, spawn, type Subprocess } from "bun"; import { serve, spawn, type Subprocess } from "bun";
import { join, resolve, dirname } from "path"; import { join, resolve, dirname } from "path";
import { logger } from "@shared/lib/logger";
export interface WebServerConfig { export interface WebServerConfig {
port?: number; port?: number;
@@ -39,7 +40,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const isDev = process.env.NODE_ENV !== "production"; const isDev = process.env.NODE_ENV !== "production";
if (isDev) { if (isDev) {
console.log("🛠️ Starting Web Bundler in Watch Mode..."); logger.info("web", "Starting Web Bundler in Watch Mode...");
try { try {
buildProcess = spawn(["bun", "run", "build.ts", "--watch"], { buildProcess = spawn(["bun", "run", "build.ts", "--watch"], {
cwd: webRoot, cwd: webRoot,
@@ -47,7 +48,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
stderr: "inherit", stderr: "inherit",
}); });
} catch (error) { } catch (error) {
console.error("Failed to start build process:", error); logger.error("web", "Failed to start build process", error);
} }
} }
@@ -59,6 +60,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
// Interval for broadcasting stats to all connected WS clients // Interval for broadcasting stats to all connected WS clients
let statsBroadcastInterval: Timer | undefined; let statsBroadcastInterval: Timer | undefined;
// Cache for activity stats (heavy aggregation)
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
let lastActivityFetch: number = 0;
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const server = serve({ const server = serve({
port, port,
hostname, hostname,
@@ -70,7 +76,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
// Security Check: limit concurrent connections // Security Check: limit concurrent connections
const currentConnections = server.pendingWebSockets; const currentConnections = server.pendingWebSockets;
if (currentConnections >= MAX_CONNECTIONS) { if (currentConnections >= MAX_CONNECTIONS) {
console.warn(`⚠️ [WS] Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`); logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
return new Response("Connection limit reached", { status: 429 }); return new Response("Connection limit reached", { status: 429 });
} }
@@ -89,7 +95,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const stats = await getFullDashboardStats(); const stats = await getFullDashboardStats();
return Response.json(stats); return Response.json(stats);
} catch (error) { } catch (error) {
console.error("Error fetching dashboard stats:", error); logger.error("web", "Error fetching dashboard stats", error);
return Response.json( return Response.json(
{ error: "Failed to fetch dashboard statistics" }, { error: "Failed to fetch dashboard statistics" },
{ status: 500 } { status: 500 }
@@ -97,17 +103,39 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
} }
} }
if (url.pathname === "/api/stats/activity") {
try {
const now = Date.now();
// If we have a valid cache, return it
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
const data = await activityPromise;
return Response.json(data);
}
// Otherwise, trigger a new fetch (deduplicated by the promise)
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
activityPromise = (async () => {
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
return await dashboardService.getActivityAggregation();
})();
lastActivityFetch = now;
}
const activity = await activityPromise;
return Response.json(activity);
} catch (error) {
logger.error("web", "Error fetching activity stats", error);
return Response.json(
{ error: "Failed to fetch activity statistics" },
{ status: 500 }
);
}
}
// Administrative Actions // Administrative Actions
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") { if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
try { try {
// Security Check: Token-based authentication
const { env } = await import("@shared/lib/env");
const authHeader = req.headers.get("Authorization");
if (authHeader !== `Bearer ${env.ADMIN_TOKEN}`) {
console.warn(`⚠️ [API] Unauthorized administrative action attempt from ${req.headers.get("x-forwarded-for") || "unknown"}`);
return new Response("Unauthorized", { status: 401 });
}
const { actionService } = await import("@shared/modules/admin/action.service"); const { actionService } = await import("@shared/modules/admin/action.service");
const { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types"); const { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types");
@@ -133,7 +161,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
return Response.json(result); return Response.json(result);
} }
} catch (error) { } catch (error) {
console.error("Error executing administrative action:", error); logger.error("web", "Error executing administrative action", error);
return Response.json( return Response.json(
{ error: "Failed to execute administrative action" }, { error: "Failed to execute administrative action" },
{ status: 500 } { status: 500 }
@@ -141,6 +169,81 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
} }
} }
// Settings Management
if (url.pathname === "/api/settings") {
try {
if (req.method === "GET") {
const { config } = await import("@shared/lib/config");
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify(config, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
}
if (req.method === "POST") {
const partialConfig = await req.json();
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
const { deepMerge } = await import("@shared/lib/utils");
// Merge partial update into current config
const mergedConfig = deepMerge(currentConfig, partialConfig);
// saveConfig throws if validation fails
saveConfig(mergedConfig);
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
return Response.json({ success: true });
}
} catch (error) {
logger.error("web", "Settings error", error);
return Response.json(
{ error: "Failed to process settings request", details: error instanceof Error ? error.message : String(error) },
{ status: 400 }
);
}
}
if (url.pathname === "/api/settings/meta") {
try {
const { AuroraClient } = await import("../../bot/lib/BotClient");
const { env } = await import("@shared/lib/env");
if (!env.DISCORD_GUILD_ID) {
return Response.json({ roles: [], channels: [] });
}
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
if (!guild) {
return Response.json({ roles: [], channels: [] });
}
// Map roles and channels to a simplified format
const roles = guild.roles.cache
.sort((a, b) => b.position - a.position)
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
const channels = guild.channels.cache
.map(c => ({ id: c.id, name: c.name, type: c.type }));
const commands = Array.from(AuroraClient.knownCommands.entries())
.map(([name, category]) => ({ name, category }))
.sort((a, b) => {
if (a.category !== b.category) return a.category.localeCompare(b.category);
return a.name.localeCompare(b.name);
});
return Response.json({ roles, channels, commands });
} catch (error) {
logger.error("web", "Error fetching settings meta", error);
return Response.json(
{ error: "Failed to fetch metadata" },
{ status: 500 }
);
}
}
// Static File Serving // Static File Serving
let pathName = url.pathname; let pathName = url.pathname;
if (pathName === "/") pathName = "/index.html"; if (pathName === "/") pathName = "/index.html";
@@ -158,10 +261,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
if (await fileRef.exists()) { if (await fileRef.exists()) {
// If serving index.html, inject env vars for frontend // If serving index.html, inject env vars for frontend
if (pathName === "/index.html") { if (pathName === "/index.html") {
let html = await fileRef.text(); const html = await fileRef.text();
const { env } = await import("@shared/lib/env");
const envScript = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${env.ADMIN_TOKEN}" };</script>`;
html = html.replace("</head>", `${envScript}</head>`);
return new Response(html, { headers: { "Content-Type": "text/html" } }); return new Response(html, { headers: { "Content-Type": "text/html" } });
} }
return new Response(fileRef); return new Response(fileRef);
@@ -170,22 +270,32 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
// SPA Fallback: Serve index.html for unknown non-file routes // SPA Fallback: Serve index.html for unknown non-file routes
const parts = pathName.split("/"); const parts = pathName.split("/");
const lastPart = parts[parts.length - 1]; const lastPart = parts[parts.length - 1];
if (lastPart?.includes(".")) {
// If it's a direct request for a missing file (has dot), return 404
// EXCEPT for index.html which is our fallback entry point
if (lastPart?.includes(".") && lastPart !== "index.html") {
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 });
} }
const indexFile = Bun.file(join(distDir, "index.html")); const indexFile = Bun.file(join(distDir, "index.html"));
let indexHtml = await indexFile.text(); if (!(await indexFile.exists())) {
const { env: sharedEnv } = await import("@shared/lib/env"); if (isDev) {
const script = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${sharedEnv.ADMIN_TOKEN}" };</script>`; return new Response("<html><body><h1>🛠️ Dashboard is building...</h1><p>Please refresh in a few seconds. The bundler is currently generating the static assets.</p><script>setTimeout(() => location.reload(), 2000);</script></body></html>", {
indexHtml = indexHtml.replace("</head>", `${script}</head>`); status: 503,
headers: { "Content-Type": "text/html" }
});
}
return new Response("Dashboard Not Found", { status: 404 });
}
const indexHtml = await indexFile.text();
return new Response(indexHtml, { headers: { "Content-Type": "text/html" } }); return new Response(indexHtml, { headers: { "Content-Type": "text/html" } });
}, },
websocket: { websocket: {
open(ws) { open(ws) {
ws.subscribe("dashboard"); ws.subscribe("dashboard");
console.log(`🔌 [WS] Client connected. Total: ${server.pendingWebSockets}`); logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
// Send initial stats // Send initial stats
getFullDashboardStats().then(stats => { getFullDashboardStats().then(stats => {
@@ -199,7 +309,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const stats = await getFullDashboardStats(); const stats = await getFullDashboardStats();
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats })); server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
} catch (error) { } catch (error) {
console.error("Error in stats broadcast:", error); logger.error("web", "Error in stats broadcast", error);
} }
}, 5000); }, 5000);
} }
@@ -210,7 +320,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
// Defense-in-depth: redundant length check before parsing // Defense-in-depth: redundant length check before parsing
if (messageStr.length > MAX_PAYLOAD_BYTES) { if (messageStr.length > MAX_PAYLOAD_BYTES) {
console.error("❌ [WS] Payload exceeded maximum limit"); logger.error("web", "Payload exceeded maximum limit");
return; return;
} }
@@ -219,7 +329,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const parsed = WsMessageSchema.safeParse(rawData); const parsed = WsMessageSchema.safeParse(rawData);
if (!parsed.success) { if (!parsed.success) {
console.error("❌ [WS] Invalid message format:", parsed.error.issues); logger.error("web", "Invalid message format", parsed.error.issues);
return; return;
} }
@@ -227,12 +337,12 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
ws.send(JSON.stringify({ type: "PONG" })); ws.send(JSON.stringify({ type: "PONG" }));
} }
} catch (e) { } catch (e) {
console.error("❌ [WS] Failed to handle message:", e instanceof Error ? e.message : "Malformed JSON"); logger.error("web", "Failed to handle message", e);
} }
}, },
close(ws) { close(ws) {
ws.unsubscribe("dashboard"); ws.unsubscribe("dashboard");
console.log(`🔌 [WS] Client disconnected. Total remaining: ${server.pendingWebSockets}`); logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
// Stop broadcast interval if no clients left // Stop broadcast interval if no clients left
if (server.pendingWebSockets === 0 && statsBroadcastInterval) { if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
@@ -254,32 +364,78 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
async function getFullDashboardStats() { async function getFullDashboardStats() {
// Import services (dynamic to avoid circular deps) // Import services (dynamic to avoid circular deps)
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service"); const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { getClientStats } = await import("../../bot/lib/clientStats"); const { getClientStats } = await import("../../bot/lib/clientStats");
// Fetch all data in parallel // Fetch all data in parallel with error isolation
const [clientStats, activeUsers, totalUsers, economyStats, recentEvents] = await Promise.all([ const results = await Promise.allSettled([
Promise.resolve(getClientStats()), Promise.resolve(getClientStats()),
dashboardService.getActiveUserCount(), dashboardService.getActiveUserCount(),
dashboardService.getTotalUserCount(), dashboardService.getTotalUserCount(),
dashboardService.getEconomyStats(), dashboardService.getEconomyStats(),
dashboardService.getRecentEvents(10), dashboardService.getRecentEvents(10),
dashboardService.getTotalItems(),
dashboardService.getActiveLootdrops(),
dashboardService.getLeaderboards(),
Promise.resolve(lootdropService.getLootdropState()),
]); ]);
// Helper to unwrap result or return default
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
if (result.status === 'fulfilled') return result.value;
logger.error("web", `Failed to fetch ${name}`, result.reason);
return defaultValue;
};
const clientStats = unwrap(results[0], {
bot: { name: 'Aurora', avatarUrl: null },
guilds: 0,
commandsRegistered: 0,
commandsKnown: 0,
cachedUsers: 0,
ping: 0,
uptime: 0,
lastCommandTimestamp: null
}, 'clientStats');
const activeUsers = unwrap(results[1], 0, 'activeUsers');
const totalUsers = unwrap(results[2], 0, 'totalUsers');
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
const recentEvents = unwrap(results[4], [], 'recentEvents');
const totalItems = unwrap(results[5], 0, 'totalItems');
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards');
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
return { return {
bot: clientStats.bot, bot: clientStats.bot,
guilds: { count: clientStats.guilds }, guilds: { count: clientStats.guilds },
users: { active: activeUsers, total: totalUsers }, users: { active: activeUsers, total: totalUsers },
commands: { total: clientStats.commandsRegistered }, commands: {
total: clientStats.commandsKnown,
active: clientStats.commandsRegistered,
disabled: clientStats.commandsKnown - clientStats.commandsRegistered
},
ping: { avg: clientStats.ping }, ping: { avg: clientStats.ping },
economy: { economy: {
totalWealth: economyStats.totalWealth.toString(), totalWealth: economyStats.totalWealth.toString(),
avgLevel: economyStats.avgLevel, avgLevel: economyStats.avgLevel,
topStreak: economyStats.topStreak, topStreak: economyStats.topStreak,
totalItems,
}, },
recentEvents: recentEvents.map(event => ({ recentEvents: recentEvents.map(event => ({
...event, ...event,
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp, timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
})), })),
activeLootdrops: activeLootdrops.map(drop => ({
rewardAmount: drop.rewardAmount,
currency: drop.currency,
createdAt: drop.createdAt.toISOString(),
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
// Explicitly excluding channelId/messageId to prevent sniping
})),
lootdropState,
leaderboards,
uptime: clientStats.uptime, uptime: clientStats.uptime,
lastCommandTimestamp: clientStats.lastCommandTimestamp, lastCommandTimestamp: clientStats.lastCommandTimestamp,
maintenanceMode: (await import("../../bot/lib/BotClient")).AuroraClient.maintenanceMode, maintenanceMode: (await import("../../bot/lib/BotClient")).AuroraClient.maintenanceMode,

View File

@@ -39,68 +39,242 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--text-step--2: var(--step--2);
--text-step--1: var(--step--1);
--text-step-0: var(--step-0);
--text-step-1: var(--step-1);
--text-step-2: var(--step-2);
--text-step-3: var(--step-3);
--text-step-4: var(--step-4);
--text-step-5: var(--step-5);
} }
:root { :root {
--radius: 1rem; --step--2: clamp(0.5002rem, 0.449rem + 0.2273vw, 0.6252rem);
--background: oklch(0.12 0.02 260); --step--1: clamp(0.7072rem, 0.6349rem + 0.3215vw, 0.884rem);
--foreground: oklch(0.98 0.01 260); --step-0: clamp(1rem, 0.8977rem + 0.4545vw, 1.25rem);
--card: oklch(0.16 0.03 260 / 0.5); --step-1: clamp(1.414rem, 1.2694rem + 0.6427vw, 1.7675rem);
--card-foreground: oklch(0.98 0.01 260); --step-2: clamp(1.9994rem, 1.7949rem + 0.9088vw, 2.4992rem);
--popover: oklch(0.14 0.02 260 / 0.8); --step-3: clamp(2.8271rem, 2.538rem + 1.2851vw, 3.5339rem);
--popover-foreground: oklch(0.98 0.01 260); --step-4: clamp(3.9976rem, 3.5887rem + 1.8171vw, 4.997rem);
--primary: oklch(0.65 0.18 250); --step-5: clamp(5.6526rem, 5.0745rem + 2.5694vw, 7.0657rem);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.25 0.04 260); --radius: 0.5rem;
--secondary-foreground: oklch(0.98 0.01 260); --background: oklch(0.12 0.015 40);
--muted: oklch(0.2 0.03 260 / 0.6); --foreground: oklch(0.98 0.01 60);
--muted-foreground: oklch(0.7 0.02 260); --card: oklch(0.16 0.03 40 / 0.6);
--accent: oklch(0.3 0.05 250 / 0.4); --card-foreground: oklch(0.98 0.01 60);
--accent-foreground: oklch(0.98 0.01 260); --popover: oklch(0.14 0.02 40 / 0.85);
--destructive: oklch(0.6 0.18 25); --popover-foreground: oklch(0.98 0.01 60);
--border: oklch(1 0 0 / 10%); --primary: oklch(0.82 0.18 85);
--input: oklch(1 0 0 / 5%); --primary-foreground: oklch(0.12 0.015 40);
--ring: oklch(0.65 0.18 250 / 50%); --secondary: oklch(0.65 0.2 55);
--chart-1: oklch(0.6 0.18 250); --secondary-foreground: oklch(0.98 0.01 60);
--chart-2: oklch(0.7 0.15 160); --muted: oklch(0.22 0.02 40 / 0.6);
--chart-3: oklch(0.8 0.12 80); --muted-foreground: oklch(0.7 0.08 40);
--chart-4: oklch(0.6 0.2 300); --accent: oklch(0.75 0.15 70 / 0.15);
--chart-5: oklch(0.6 0.25 20); --accent-foreground: oklch(0.98 0.01 60);
--sidebar: oklch(0.14 0.02 260 / 0.6); --destructive: oklch(0.55 0.18 25);
--sidebar-foreground: oklch(0.98 0.01 260); --border: oklch(1 0 0 / 12%);
--sidebar-primary: oklch(0.65 0.18 250); --input: oklch(1 0 0 / 8%);
--sidebar-primary-foreground: oklch(1 0 0); --ring: oklch(0.82 0.18 85 / 40%);
--sidebar-accent: oklch(1 0 0 / 5%); --chart-1: oklch(0.82 0.18 85);
--sidebar-accent-foreground: oklch(0.98 0.01 260); --chart-2: oklch(0.65 0.2 55);
--sidebar-border: oklch(1 0 0 / 8%); --chart-3: oklch(0.75 0.15 70);
--sidebar-ring: oklch(0.65 0.18 250 / 50%); --chart-4: oklch(0.55 0.18 25);
--chart-5: oklch(0.9 0.1 95);
--sidebar: oklch(0.14 0.02 40 / 0.7);
--sidebar-foreground: oklch(0.98 0.01 60);
--sidebar-primary: oklch(0.82 0.18 85);
--sidebar-primary-foreground: oklch(0.12 0.015 40);
--sidebar-accent: oklch(1 0 0 / 8%);
--sidebar-accent-foreground: oklch(0.98 0.01 60);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.82 0.18 85 / 40%);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; border-color: var(--border);
} }
body { body {
@apply bg-background text-foreground selection:bg-primary/30; background-color: var(--background);
font-family: 'Outfit', 'Inter', system-ui, sans-serif; color: var(--foreground);
background-image: }
radial-gradient(at 0% 0%, oklch(0.25 0.1 260 / 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, oklch(0.35 0.12 300 / 0.1) 0px, transparent 50%); /* Global Scrollbar Styling */
background-attachment: fixed; html,
body {
scrollbar-width: thin;
scrollbar-color: var(--muted) transparent;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 8px;
height: 8px;
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track {
background: transparent;
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb {
background: var(--muted);
border-radius: 9999px;
}
html::-webkit-scrollbar-thumb:hover,
body::-webkit-scrollbar-thumb:hover {
background: var(--primary);
} }
} }
@layer utilities { @layer utilities {
.glass { .bg-aurora-page {
@apply bg-card backdrop-blur-xl border border-white/10 shadow-2xl; background: radial-gradient(circle at 50% -20%, oklch(0.25 0.1 50) 0%, var(--background) 70%);
background-attachment: fixed;
} }
.glass-sidebar { .bg-aurora {
@apply bg-sidebar backdrop-blur-2xl border-r border-white/5; background-image: linear-gradient(135deg, oklch(0.82 0.18 85) 0%, oklch(0.65 0.2 55) 100%);
} }
.text-glow { .glass-card {
text-shadow: 0 0 10px oklch(var(--primary) / 0.5); background: var(--card);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
}
.sun-flare {
box-shadow: 0 0 40px oklch(0.82 0.18 85 / 0.12);
}
/* Custom Scrollbar Utility Class */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--muted) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--muted);
border-radius: 9999px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
/* Entrance Animations */
.animate-in {
animation-duration: 0.6s;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
animation-fill-mode: forwards;
}
.fade-in {
opacity: 0;
animation-name: fade-in;
}
.slide-up {
opacity: 0;
transform: translateY(20px);
animation-name: slide-up;
}
.zoom-in {
opacity: 0;
transform: scale(0.95);
animation-name: zoom-in;
}
@keyframes fade-in {
to {
opacity: 1;
}
}
@keyframes slide-up {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes zoom-in {
to {
opacity: 1;
transform: scale(1);
}
}
/* Interaction Utilities */
.hover-lift {
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px -5px oklch(0 0 0 / 0.3), 0 0 20px oklch(0.82 0.18 85 / 0.1);
}
.hover-glow {
transition: box-shadow 0.3s ease, border-color 0.3s ease;
}
.hover-glow:hover {
border-color: oklch(0.82 0.18 85 / 0.4);
box-shadow: 0 0 20px oklch(0.82 0.18 85 / 0.15);
}
.hover-scale {
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.hover-scale:hover {
transform: scale(1.02);
}
.active-press {
transition: transform 0.1s ease;
}
.active-press:active {
transform: scale(0.97);
}
/* Staggered Delay Utilities */
.delay-100 {
animation-delay: 100ms;
}
.delay-200 {
animation-delay: 200ms;
}
.delay-300 {
animation-delay: 300ms;
}
.delay-400 {
animation-delay: 400ms;
}
.delay-500 {
animation-delay: 500ms;
} }
} }