22 Commits

Author SHA1 Message Date
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
72 changed files with 6499 additions and 1395 deletions

View File

@@ -1,57 +1,63 @@
---
description: Create a new Ticket
description: Converts conversational brain dumps into structured, metric-driven Markdown tickets in the ./tickets directory.
---
### Role
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
# WORKFLOW: PRAGMATIC ARCHITECT TICKET GENERATOR
### 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).
## 1. High-Level Goal
Transform informal user "brain dumps" into high-precision, metric-driven engineering tickets stored as Markdown files in the `./tickets/` directory. The workflow enforces a quality gate via targeted inquiry before any file persistence occurs, ensuring all tasks are observable, measurable, and actionable.
### File Naming Convention
You must use the following naming convention strictly:
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
## 2. Assumptions & Clarifications
- **Assumptions:** The agent has write access to the `./tickets/` and `/temp/` directories. The current date is accessible for naming conventions. "Metrics" refer to quantifiable constraints (latency, line counts, status codes).
- **Ambiguities:** If the user provides a second brain dump while a ticket is in progress, the agent will prioritize the current workflow until completion or explicit cancellation.
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
## 3. Stage Breakdown
### 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.
### Stage 1: Discovery & Quality Gate
- **Stage Name:** Requirement Analysis
- **Purpose:** Analyze input for vagueness and enforce the "Quality Gate" by extracting metrics.
- **Inputs:** Raw user brain dump (text).
- **Actions:** 1. Identify "Known Unknowns" (vague terms like "fast," "better," "clean").
2. Formulate exactly three (3) targeted questions to convert vague goals into comparable metrics.
3. Check for logical inconsistencies in the request.
- **Outputs:** Three questions presented to the user.
- **Persistence Strategy:** Save the original brain dump and the three questions to `/temp/pending_ticket_state.json`.
```markdown
# [Ticket ID]: [Feature Title]
### Stage 2: Drafting & Refinement
- **Stage Name:** Ticket Drafting
- **Purpose:** Synthesize the original dump and user answers into a structured Markdown draft.
- **Inputs:** User responses to the three questions; `/temp/pending_ticket_state.json`.
- **Actions:** 1. Construct a Markdown draft using the provided template.
2. Generate a slug-based filename: `YYYYMMDD-slug.md`.
3. Present the draft and filename to the user for review.
- **Outputs:** Formatted Markdown text and suggested filename displayed in the chat.
- **Persistence Strategy:** Update `/temp/pending_ticket_state.json` with the full Markdown content and the proposed filename.
**Status:** Draft
**Created:** [YYYY-MM-DD]
**Tags:** [comma, separated, tags]
### Stage 3: Execution & Persistence
- **Stage Name:** Finalization
- **Purpose:** Commit the approved ticket to the permanent `./tickets/` directory.
- **Inputs:** User confirmation (e.g., "Go," "Approved"); `/temp/pending_ticket_state.json`.
- **Actions:** 1. Write the finalized Markdown content to `./tickets/[filename]`.
2. Delete the temporary state file in `/temp/`.
- **Outputs:** Confirmation message containing the relative path to the new file.
- **Persistence Strategy:** Permanent write to `./tickets/`.
## 1. Context & User Story
* **As a:** [Role]
* **I want to:** [Action]
* **So that:** [Benefit/Value]
## 4. Data & File Contracts
- **State File:** `/temp/pending_ticket_state.json`
- Schema: `{ "original_input": string, "questions": string[], "answers": string[], "draft_content": string, "filename": string, "step": integer }`
- **Output File:** `./tickets/YYYYMMDD-[slug].md`
- Format: Markdown
- Sections: `# Title`, `## Context`, `## Acceptance Criteria`, `## Suggested Affected Files`, `## Technical Constraints`.
## 2. Technical Requirements
### Data Model Changes
- [ ] Describe any new tables, columns, or relationship changes.
- [ ] SQL migration required? (Yes/No)
## 5. Failure & Recovery Handling
- **Incomplete Inputs:** If the user fails to answer the 3 questions, the agent must politely restate that metrics are required for high-precision engineering and repeat the questions.
- **Inconsistencies:** If the users answers contradict the original dump, the agent must flag the contradiction and ask for a tie-break before drafting.
- **Missing Directory:** If `./tickets/` does not exist during Stage 3, the agent must attempt to create it before writing the file.
### 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: ...
## 6. Final Deliverable Specification
- **Format:** A valid Markdown file in the `./tickets/` folder.
- **Quality Bar:**
- Zero fluff in the Context section.
- All Acceptance Criteria must be binary (pass/fail) or metric-based.
- Filename must strictly follow `YYYYMMDD-slug.md` (e.g., `20240520-auth-refactor.md`).
- No "Status" or "Priority" fields.

View File

@@ -0,0 +1,89 @@
---
description: Analyzes the codebase to find dependencies and side effects related to a specific ticket.
---
# WORKFLOW: Dependency Architect & Blast Radius Analysis
## 1. High-Level Goal
Perform a deterministic "Blast Radius" analysis for a code change defined in a Jira/Linear-style ticket. The agent will identify direct consumers, side effects, and relevant test suites, then append a structured "Impact Analysis" section to the original ticket file to guide developers and ensure high-velocity execution without regressions.
## 2. Assumptions & Clarifications
- **Location:** Tickets are stored in the `./tickets/` directory as Markdown files.
- **Code Access:** The agent has full read access to the project root and subdirectories.
- **Scope:** Dependency tracing is limited to "one level deep" (direct imports/references) unless a global configuration or core database schema change is detected.
- **Ambiguity Handling:** If "Suggested Affected Files" are missing from the ticket, the agent will attempt to infer them from the "Acceptance Criteria" logic; if inference is impossible, the agent will halt and request the file list.
## 3. Stage Breakdown
### Stage 1: Ticket Parsing & Context Extraction
- **Purpose:** Extract the specific files and logic constraints requiring analysis.
- **Inputs:** A specific ticket filename (e.g., `./tickets/TASK-123.md`).
- **Actions:**
1. Read the ticket file.
2. Extract the list of "Suggested Affected Files".
3. Extract keywords and logic from the "Acceptance Criteria".
4. Validate that all "Suggested Affected Files" exist in the current codebase.
- **Outputs:** A JSON object containing the target file list and key logic requirements.
- **Persistence Strategy:** Save extracted data to `/temp/context.json`.
### Stage 2: Recursive Dependency Mapping
- **Purpose:** Identify which external modules rely on the target files.
- **Inputs:** `/temp/context.json`.
- **Actions:**
1. For each file in the target list, perform a search (e.g., `grep` or AST walk) for import statements or references in the rest of the codebase.
2. Filter out internal references within the same module (focus on external consumers).
3. Detect if the change involves shared utilities (e.g., `utils/`, `common/`) or database schemas (e.g., `prisma/schema.prisma`).
- **Outputs:** A list of unique consumer file paths and their specific usage context.
- **Persistence Strategy:** Save findings to `/temp/dependencies.json`.
### Stage 3: Test Suite Identification
- **Purpose:** Locate the specific test files required to validate the change.
- **Inputs:** `/temp/context.json` and `/temp/dependencies.json`.
- **Actions:**
1. Search for files following patterns: `[filename].test.ts`, `[filename].spec.js`, or within `__tests__` folders related to affected files.
2. Identify integration or E2E tests that cover the consumer paths identified in Stage 2.
- **Outputs:** A list of relevant test file paths.
- **Persistence Strategy:** Save findings to `/temp/tests.json`.
### Stage 4: Risk Hotspot Synthesis
- **Purpose:** Interpret raw dependency data into actionable risk warnings.
- **Inputs:** All files in `/temp/`.
- **Actions:**
1. Analyze the volume of consumers; if a file has >5 consumers, flag it as a "High Impact Hotspot."
2. Check for breaking contract changes (e.g., interface modifications) based on the "Acceptance Criteria".
3. Formulate specific "Risk Hotspot" warnings (e.g., "Changing Auth interface affects 12 files; consider a wrapper.").
- **Outputs:** A structured Markdown-ready report object.
- **Persistence Strategy:** Save final report data to `/temp/final_analysis.json`.
### Stage 5: Ticket Augmentation & Finalization
- **Purpose:** Update the physical ticket file with findings.
- **Inputs:** Original ticket file and `/temp/final_analysis.json`.
- **Actions:**
1. Read the current content of the ticket file.
2. Generate a Markdown section titled `## Impact Analysis (Generated: 2026-01-09)`.
3. Append the Direct Consumers, Test Coverage, and Risk Hotspots sections.
4. Write the combined content back to the original file path.
- **Outputs:** Updated Markdown ticket.
- **Persistence Strategy:** None (Final Action).
## 4. Data & File Contracts
- **State File (`/temp/state.json`):** - `affected_files`: string[]
- `consumers`: { path: string, context: string }[]
- `tests`: string[]
- `risks`: string[]
- **File Format:** All `/temp` files must be valid JSON.
- **Ticket Format:** Standard Markdown. Use `###` for sub-headers in the generated section.
## 5. Failure & Recovery Handling
- **Missing Ticket:** If the ticket path is invalid, exit immediately with error: "TICKET_NOT_FOUND".
- **Zero Consumers Found:** If no external consumers are found, state "No external dependencies detected" in the report; do not fail.
- **Broken Imports:** If AST parsing fails due to syntax errors in the codebase, fallback to `grep` for string-based matching.
- **Write Permission:** If the ticket file is read-only, output the final Markdown to the console and provide a warning.
## 6. Final Deliverable Specification
- **Format:** The original ticket file must be modified in-place.
- **Content:**
- **Direct Consumers:** Bulleted list of `[File Path]: [Usage description]`.
- **Test Coverage:** Bulleted list of `[File Path]`.
- **Risk Hotspots:** Clear, one-sentence warnings for high-risk areas.
- **Quality Bar:** No hallucinations. Every file path listed must exist in the repository. No deletions of original ticket content.

View File

@@ -1,53 +1,72 @@
---
description: Review the most recent changes critically.
description: Performs a high-intensity, "hostile" technical audit of the provided code.
---
### 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.
# WORKFLOW: HOSTILE TECHNICAL AUDIT & SECURITY REVIEW
### 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. High-Level Goal
Execute a multi-pass, hyper-critical technical audit of provided source code to identify fatal logic flaws, security vulnerabilities, and architectural debt. The agent acts as a hostile reviewer with a "guilty until proven innocent" mindset, aiming to justify a REJECTED verdict unless the code demonstrates exceptional robustness and simplicity.
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?
## 2. Assumptions & Clarifications
- **Assumption:** The user will provide either raw code snippets or paths to files within the agent's accessible environment.
- **Assumption:** The agent has access to `/temp/` for multi-stage state persistence.
- **Clarification:** If a "ticket description" or "requirement" is not provided, the agent will infer intent from the code but must flag "Lack of Context" as a potential risk.
- **Clarification:** "Hostile" refers to a rigorous, zero-tolerance standard, not unprofessional language.
### 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).
## 3. Stage Breakdown
### Phase 3: The Verdict
Output your review in the following strict format:
### Stage 1: Contextual Ingestion & Dependency Mapping
- **Purpose:** Map the attack surface and understand the logical flow before the audit.
- **Inputs:** Target source code files.
- **Actions:** - Identify all external dependencies and entry points.
- Map data flow from input to storage/output.
- Identify "High-Risk Zones" (e.g., auth logic, DB queries, memory management).
- **Outputs:** A structured map of the code's architecture.
- **Persistence Strategy:** Save `audit_map.json` to `/temp/` containing the file list and identified High-Risk Zones.
---
# 🛡️ Code Review Report
### Stage 2: Security & Logic Stress Test (The "Hostile" Pass)
- **Purpose:** Identify reasons to reject the code based on security and logical integrity.
- **Inputs:** `/temp/audit_map.json` and source code.
- **Actions:**
- Scan for injection, race conditions, and improper state handling.
- Simulate edge cases: null inputs, buffer overflows, and malformed data.
- Evaluate "Silent Failures": Does the code swallow exceptions or fail to log critical errors?
- **Outputs:** List of fatal flaws and security risks.
- **Persistence Strategy:** Save `vulnerabilities.json` to `/temp/`.
**Ticket ID:** [Ticket Name]
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
### Stage 3: Performance & Velocity Debt Assessment
- **Purpose:** Evaluate the "Pragmatic Performance" and maintainability of the implementation.
- **Inputs:** Source code and `/temp/vulnerabilities.json`.
- **Actions:**
- Identify redundant API calls or unnecessary allocations.
- Flag "Over-Engineering" (unnecessary abstractions) vs. "Lazy Code" (hardcoded values).
- Identify missing unit test scenarios for identified edge cases.
- **Outputs:** List of optimization debt and missing test scenarios.
- **Persistence Strategy:** Save `debt_and_tests.json` to `/temp/`.
## 🚨 Critical Issues (Must Fix)
*List logic bugs, security risks, or failing tests.*
1. ...
2. ...
### Stage 4: Synthesis & Verdict Generation
- **Purpose:** Compile all findings into the final "Hostile Audit" report.
- **Inputs:** `/temp/vulnerabilities.json` and `/temp/debt_and_tests.json`.
- **Actions:**
- Consolidate all findings into the mandated "Response Format."
- Apply the "Burden of Proof" rule: If any Fatal Flaws or Security Risks exist, the verdict is REJECTED.
- Ensure no sycophantic language is present.
- **Outputs:** Final Audit Report.
- **Persistence Strategy:** Final output is delivered to the user; `/temp/` files may be purged.
## ⚠️ Suggestions (Refactoring)
*List code style improvements, variable naming, or DRY opportunities.*
1. ...
## 4. Data & File Contracts
- **Filename:** `/temp/audit_context.json` | **Schema:** `{ "high_risk_zones": [], "entry_points": [] }`
- **Filename:** `/temp/findings.json` | **Schema:** `{ "fatal_flaws": [], "security_risks": [], "debt": [], "missing_tests": [] }`
- **Final Report Format:** Markdown with specific headers: `## 🛑 FATAL FLAWS`, `## ⚠️ SECURITY & VULNERABILITIES`, `## 📉 VELOCITY DEBT`, `## 🧪 MISSING TESTS`, and `### VERDICT`.
## 🧪 Test Coverage Gap Analysis
*List specific scenarios that are NOT currently tested but should be.*
- [ ] Scenario: ...
## 5. Failure & Recovery Handling
- **Incomplete Input:** If the code is snippet-based and missing context, the agent must assume the worst-case scenario for the missing parts and flag them as "Critical Unknowns."
- **Stage Failure:** If a specific file cannot be parsed, log the error in the `findings.json` and proceed with the remaining files.
- **Clarification:** The agent will NOT ask for clarification mid-audit. It will make a "hostile assumption" and document it as a risk.
## 6. Final Deliverable Specification
- **Tone:** Senior Security Auditor. Clinical, critical, and direct.
- **Acceptance Criteria:** - No "Good job" or introductory filler.
- Every flaw must include [Why it fails] and [How to fix it].
- Verdict must be REJECTED unless the code is "solid" (simple, robust, and secure).
- Must identify at least one specific edge case for the "Missing Tests" section.

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.

99
.agent/workflows/work.md Normal file
View File

@@ -0,0 +1,99 @@
---
description: Work on a ticket
---
# WORKFLOW: Automated Feature Implementation and Review Cycle
## 1. High-Level Goal
The objective of this workflow is to autonomously ingest a task from a local `/tickets` directory, establish a dedicated development environment via Git branching, implement the requested changes with incremental commits, validate the work through an internal review process, and finalize the lifecycle by cleaning up ticket artifacts and seeking user authorization for the final merge.
---
## 2. Assumptions & Clarifications
- **Assumptions:**
- The `/tickets` directory contains one or more files representing tasks (e.g., `.md` or `.txt`).
- The agent has authenticated access to the local Git repository.
- A "Review Workflow" exists as an executable command or internal process.
- The branch naming convention is `feature/[ticket-filename-slug]`.
- **Ambiguities:**
- If multiple tickets exist, the agent will select the one with the earliest "Last Modified" timestamp.
- "Regular commits" are defined as committing after every logically complete file change or functional milestone.
---
## 3. Stage Breakdown
### Stage 1: Ticket Selection and Branch Initialization
- **Purpose:** Identify the next task and prepare the workspace.
- **Inputs:** Contents of the `/tickets` directory.
- **Actions:**
1. Scan `/tickets` and select the oldest file.
2. Parse the ticket content to understand requirements.
3. Ensure the current working directory is a Git repository.
4. Create and switch to a new branch: `feature/[ticket-id]`.
- **Outputs:** Active feature branch.
- **Persistence Strategy:** Save `state.json` to `/temp` containing `ticket_path`, `branch_name`, and `start_time`.
### Stage 2: Implementation and Incremental Committing
- **Purpose:** Execute the technical requirements of the ticket.
- **Inputs:** `/temp/state.json`, Ticket requirements.
- **Actions:**
1. Modify codebase according to requirements.
2. For every distinct file change or logical unit of work:
- Run basic syntax checks.
- Execute `git add [file]`.
- Execute `git commit -m "feat: [brief description of change]"`
3. Repeat until the feature is complete.
- **Outputs:** Committed code changes on the feature branch.
- **Persistence Strategy:** Update `state.json` with `implementation_complete: true` and a list of `modified_files`.
### Stage 3: Review Workflow Execution
- **Purpose:** Validate the implementation against quality standards.
- **Inputs:** `/temp/state.json`, Modified codebase.
- **Actions:**
1. Trigger the "Review Workflow" (static analysis, tests, or linter).
2. If errors are found:
- Log errors to `/temp/review_log.txt`.
- Re-enter Stage 2 to apply fixes and commit.
3. If review passes:
- Proceed to Stage 4.
- **Outputs:** Review results/logs.
- **Persistence Strategy:** Update `state.json` with `review_passed: true`.
### Stage 4: Cleanup and User Handoff
- **Purpose:** Finalize the ticket lifecycle and request merge permission.
- **Inputs:** `/temp/state.json`.
- **Actions:**
1. Delete the ticket file from `/tickets` using the path stored in `state.json`.
2. Format a summary of changes and a request for merge.
- **Outputs:** Deletion of the ticket file; user-facing summary.
- **Persistence Strategy:** Clear `/temp/state.json` upon successful completion.
---
## 4. Data & File Contracts
- **State File:** `/temp/state.json`
- Format: JSON
- Schema: `{ "ticket_path": string, "branch_name": string, "implementation_complete": boolean, "review_passed": boolean }`
- **Ticket Files:** Located in `/tickets/*` (Markdown or Plain Text).
- **Logs:** `/temp/review_log.txt` (Plain Text) for capturing stderr from review tools.
---
## 5. Failure & Recovery Handling
- **Empty Ticket Directory:** If no files are found in `/tickets`, the agent will output "NO_TICKETS_FOUND" and terminate the workflow.
- **Commit Failures:** If a commit fails (e.g., pre-commit hooks), the agent must resolve the hook violation before retrying the commit.
- **Review Failure Loop:** If the review fails more than 3 times for the same issue, the agent must halt and output a "BLOCKER_REPORT" detailing the persistent errors to the user.
- **State Recovery:** On context reset, the agent must check `/temp/state.json` to resume the workflow from the last recorded stage.
---
## 6. Final Deliverable Specification
- **Final Output:** A clear message to the user in the following format:
> **Task Completed:** [Ticket Name]
> **Branch:** [Branch Name]
> **Changes:** [Brief list of modified files]
> **Review Status:** Passed
> **Cleanup:** Ticket file removed from /tickets.
> **Action Required:** Would you like me to merge [Branch Name] into `main`? (Yes/No)
- **Quality Bar:** Code must be committed with descriptive messages; the ticket file must be successfully deleted; the workspace must be left on the feature branch awaiting the merge command.

View File

@@ -7,24 +7,44 @@
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
![React](https://img.shields.io/badge/React-19-61DAFB)
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
## ✨ Features
### Discord Bot
* **Class System**: Users can join different classes.
* **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.
* **Quests**: Quest system with requirements and rewards.
* **Trading**: Secure trading system between users.
* **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management.
### Web Dashboard
* **Live Analytics**: View real-time activity charts (commands, transactions).
* **Configuration Management**: Update bot settings without restarting.
* **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
* **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/)
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
* **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
```
### Running the Bot
### Running the Bot & Dashboard
**Development Mode** (with hot reload):
```bash
bun run dev
```
* Bot: Online in Discord
* Dashboard: http://localhost:3000
**Production Mode**:
Build and run with Docker (recommended):
@@ -87,27 +109,46 @@ Build and run with Docker (recommended):
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
* `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 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 test`: Run tests.
## 📂 Project Structure
```
├── src
│ ├── commands # Slash commands
│ ├── events # Discord event handlers
│ ├── modules # Feature modules (Economy, Inventory, etc.)
│ ├── db # Database schema and connection
│ └── lib # Shared utilities
├── bot # Discord Bot logic & entry point
├── web # React Web Dashboard (Frontend + Server)
├── shared # Shared code (Database, Config, Types)
├── drizzle # Drizzle migration files
├── config # Configuration files
── scripts # Utility scripts
├── scripts # Utility scripts
── docker-compose.yml
└── package.json
```
## 🤝 Contributing

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 "@/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

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import type { Command } from "@shared/lib/types";
import { config } from "@shared/lib/config";
import type { LoadResult, LoadError } from "./types";
import type { Client } from "../BotClient";
/**
* Handles loading commands from the file system
@@ -71,6 +71,9 @@ export class CommandLoader {
if (this.isValidCommand(command)) {
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;
if (!isEnabled) {

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 "@/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

@@ -13,7 +13,13 @@ import {
bigserial,
check
} 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 ---

View File

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

View File

@@ -7,6 +7,7 @@ export enum TimerType {
EFFECT = 'EFFECT',
ACCESS = 'ACCESS',
EXAM_SYSTEM = 'EXAM_SYSTEM',
TRIVIA_COOLDOWN = 'TRIVIA_COOLDOWN',
}
export enum EffectType {
@@ -30,6 +31,8 @@ export enum TransactionType {
TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT',
QUEST_REWARD = 'QUEST_REWARD',
TRIVIA_ENTRY = 'TRIVIA_ENTRY',
TRIVIA_WIN = 'TRIVIA_WIN',
}
export enum ItemTransactionType {
@@ -63,3 +66,22 @@ export enum LootType {
XP = 'XP',
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,
}

View File

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

View File

@@ -9,3 +9,42 @@ import type { Command } from "./types";
export function createCommand(command: Command): 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 { dashboardService } from "./dashboard.service";
// Mock DrizzleClient
const mockSelect = mock(() => ({
from: mock(() => Promise.resolve([{ count: "5" }])),
}));
// Mock DrizzleClient before importing service
const mockFindMany = mock();
const mockLimit = mock();
const mockQuery = {
transactions: {
findMany: mock((): Promise<any[]> => Promise.resolve([])),
},
moderationCases: {
findMany: mock((): Promise<any[]> => Promise.resolve([])),
},
// Helper to support the chained calls in getLeaderboards
const mockChain = {
from: () => mockChain,
orderBy: () => mockChain,
limit: mockLimit
};
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
select: mockSelect,
query: mockQuery,
},
select: () => mockChain,
query: {
lootdrops: {
findMany: mockFindMany
}
}
}
}));
// Import service after mocking
import { dashboardService } from "./dashboard.service";
describe("dashboardService", () => {
beforeEach(() => {
mockSelect.mockClear();
mockQuery.transactions.findMany.mockClear();
mockQuery.moderationCases.findMany.mockClear();
// Reset default mock implementation
mockSelect.mockImplementation(() => ({
from: mock(() => Promise.resolve([{ count: "5" }])),
}));
mockFindMany.mockClear();
mockLimit.mockClear();
});
describe("getActiveUserCount", () => {
test("should return active user count from database", async () => {
mockSelect.mockImplementationOnce(() => ({
// @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 = [
describe("getActiveLootdrops", () => {
test("should return active lootdrops when found", async () => {
const mockDrops = [
{
type: "DAILY_REWARD",
description: "Daily reward",
messageId: "123",
channelId: "general",
rewardAmount: 100,
currency: "Gold",
createdAt: new Date(),
user: { username: "testuser" },
},
] 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",
}
expiresAt: new Date(Date.now() + 3600000),
claimedBy: null
}
}));
];
mockFindMany.mockResolvedValue(mockDrops);
await dashboardService.recordEvent({
type: 'info',
message: 'Test Event',
icon: '🚀'
});
const result = await dashboardService.getActiveLootdrops();
expect(result).toEqual(mockDrops);
expect(mockFindMany).toHaveBeenCalledTimes(1);
});
expect(mockEmit).toHaveBeenCalled();
const calls = mockEmit.mock.calls;
if (calls.length > 0 && calls[0]) {
expect(calls[0][0]).toBe("dashboard:new_event");
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
expect(() => new Date(data.timestamp).toISOString()).not.toThrow();
} else {
throw new Error("mockEmit was not called with expected arguments");
}
test("should return empty array if no active drops", async () => {
mockFindMany.mockResolvedValue([]);
const result = await dashboardService.getActiveLootdrops();
expect(result).toEqual([]);
});
});
describe("getLeaderboards", () => {
test("should combine top levels and wealth", async () => {
const mockTopLevels = [
{ 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 { users, transactions, moderationCases, inventory } from "@db/schema";
import { desc, sql, and, gte } from "drizzle-orm";
import type { RecentEvent } from "./dashboard.types";
import { users, transactions, moderationCases, inventory, lootdrops, items, type User } from "@db/schema";
import { desc, sql, gte, eq } from "drizzle-orm";
import type { RecentEvent, ActivityData } from "./dashboard.types";
import { TransactionType } from "@shared/lib/constants";
export const dashboardService = {
/**
@@ -38,18 +39,18 @@ export const dashboardService = {
const allUsers = await DrizzleClient.select().from(users);
const totalWealth = allUsers.reduce(
(acc: bigint, u: any) => acc + (u.balance || 0n),
(acc: bigint, u: User) => acc + (u.balance || 0n),
0n
);
const avgLevel = allUsers.length > 0
? 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;
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
);
@@ -82,7 +83,7 @@ export const dashboardService = {
},
});
return recentTx.map((tx: any) => ({
return recentTx.map((tx) => ({
type: 'info' as const,
message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`,
timestamp: tx.createdAt || new Date(),
@@ -102,11 +103,11 @@ export const dashboardService = {
where: gte(moderationCases.createdAt, oneDayAgo),
});
return recentCases.map((modCase: any) => ({
return recentCases.map((modCase) => ({
type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info',
message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`,
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);
}
},
/**
* 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({
total: z.number(),
active: z.number(),
disabled: z.number(),
changePercentFromLastMonth: z.number().optional(),
}),
ping: z.object({
@@ -35,8 +37,42 @@ export const DashboardStatsSchema = z.object({
totalWealth: z.string(),
avgLevel: z.number(),
topStreak: z.number(),
totalItems: z.number().optional(),
}),
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(),
lastCommandTimestamp: z.number().nullable(),
maintenanceMode: z.boolean(),
@@ -53,6 +89,7 @@ export const ClientStatsSchema = z.object({
ping: z.number(),
cachedUsers: z.number(),
commandsRegistered: z.number(),
commandsKnown: z.number(),
uptime: z.number(),
lastCommandTimestamp: z.number().nullable(),
});
@@ -74,3 +111,10 @@ export const WsMessageSchema = z.discriminatedUnion("type", [
]);
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

@@ -163,6 +163,43 @@ class LootdropService {
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() {
this.channelActivity.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

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

View File

@@ -5,11 +5,16 @@
"": {
"name": "bun-react-template",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@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-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
@@ -17,8 +22,12 @@
"lucide-react": "^0.562.0",
"react": "^19",
"react-dom": "^19",
"react-hook-form": "^7.70.0",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.3.5",
},
"devDependencies": {
"@types/bun": "latest",
@@ -38,6 +47,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8GvNtMo0NINM7Emk9cNAviCG3teEgr3BUX9be0+GD029zIagx2Sf54jMui1Eu1IpFm7nWHODuLEefGOQNaJ0gQ=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-r33eHQOHAwkuiBJIwmkXIyqONQOQMnd1GMTpDzaxx9vf9+svby80LZO9Hcm1ns6KT/TBRFyODC/0loA7FAaffg=="],
@@ -64,8 +75,12 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -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-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
@@ -122,14 +145,40 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="],
@@ -146,16 +195,54 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-hook-form": ["react-hook-form@7.70.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
@@ -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=="],
"recharts": ["recharts@3.6.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
@@ -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-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -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-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"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@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-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
@@ -21,8 +26,12 @@
"lucide-react": "^0.562.0",
"react": "^19",
"react-dom": "^19",
"react-hook-form": "^7.70.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": {
"@types/react": "^19",

View File

@@ -1,19 +1,18 @@
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 { Dashboard } from "./pages/Dashboard";
import { DesignSystem } from "./pages/DesignSystem";
import { Home } from "./pages/Home";
import { Toaster } from "sonner";
export function App() {
return (
<BrowserRouter>
<Toaster richColors position="top-right" theme="dark" />
<Routes>
<Route path="/" element={<DashboardLayout />}>
<Route index element={<Dashboard />} />
<Route path="activity" element={<Activity />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/design-system" element={<DesignSystem />} />
<Route path="/" element={<Home />} />
</Routes>
</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:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
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: {
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
data-slot="card"
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
)}
{...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}
data-slot="input"
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]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Activity, Server, Users, Zap } from "lucide-react";
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
import { ControlPanel } from "@/components/ControlPanel";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { useSocket } from "../hooks/use-socket";
import { Badge } from "../components/ui/badge";
import { StatCard } from "../components/stat-card";
import { RecentActivity } from "../components/recent-activity";
import { ActivityChart } from "../components/activity-chart";
import { LootdropCard } from "../components/lootdrop-card";
import { LeaderboardCard } from "../components/leaderboard-card";
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() {
const { stats, loading, error } = useDashboardStats();
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;
}
const { isConnected, stats } = useSocket();
const [commandsDrawerOpen, setCommandsDrawerOpen] = useState(false);
return (
<div className="space-y-8 animate-in fade-in duration-700">
<div className="flex flex-col gap-2">
<h2 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-white to-white/40">
{stats.bot.name} Overview
</h2>
<p className="text-white/40 font-medium">Monitoring real-time activity and core bot metrics.</p>
</div>
<div className="min-h-screen bg-aurora-page text-foreground font-outfit overflow-x-hidden">
{/* Navigation */}
<nav className="sticky top-0 z-50 glass-card border-b border-border/50 py-4 px-8 flex justify-between items-center">
<div className="flex items-center gap-3">
{/* Bot Avatar */}
{stats?.bot?.avatarUrl ? (
<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">
{/* 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>
<span className="text-xl font-bold tracking-tight text-primary">Aurora</span>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4 glass border-white/5">
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<div className="h-5 w-1 bg-primary rounded-full" />
Economy Overview
</CardTitle>
<CardDescription className="text-white/40">Global wealth and progression statistics</CardDescription>
</div>
<div className="bg-white/5 px-3 py-1.5 rounded-full border border-white/10 flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
<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>
</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 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">
{/* Administrative Control Panel */}
<ControlPanel maintenanceMode={stats.maintenanceMode} />
{/* Recent Events Feed */}
<Card className="glass border-white/5 overflow-hidden flex-1">
<CardHeader className="bg-white/[0.02] border-b border-white/5">
<CardTitle className="text-xl font-bold">Recent Events</CardTitle>
<CardDescription className="text-white/30">Live system activity feed</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-white/5">
{stats.recentEvents.length === 0 ? (
<div className="p-8 text-center bg-transparent">
<p className="text-sm text-white/20 font-medium">No activity recorded</p>
</div>
) : (
stats.recentEvents.slice(0, 6).map((event, i) => (
<div key={i} className="flex items-start gap-4 p-4 hover:bg-white/[0.03] transition-colors group">
<div className={`mt-1 p-2 rounded-lg ${event.type === 'success' ? 'bg-emerald-500/10 text-emerald-500' :
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>
{/* Live Status Badge */}
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full border transition-colors duration-500 ${isConnected
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500"
: "bg-red-500/10 border-red-500/20 text-red-500"
}`}>
<div className="relative flex h-2 w-2">
{isConnected && (
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75"></span>
)}
</CardContent>
</Card>
<span className={`relative inline-flex rounded-full h-2 w-2 ${isConnected ? "bg-emerald-500" : "bg-red-500"}`}></span>
</div>
<span className="text-[10px] font-bold tracking-wider uppercase">
{isConnected ? "Live" : "Offline"}
</span>
</div>
</div>
</div>
</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="/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>
{/* Dashboard Content */}
<main className="pt-8 px-8 pb-8 max-w-7xl mx-auto space-y-8">
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 animate-in fade-in slide-up">
<StatCard
title="Total Servers"
icon={Server}
isLoading={!stats}
value={stats?.guilds.count.toLocaleString()}
subtitle={stats?.guilds.changeFromLastMonth
? `${stats.guilds.changeFromLastMonth > 0 ? '+' : ''}${stats.guilds.changeFromLastMonth} from last month`
: "Active Guilds"
}
/>
<StatCard
title="Total Users"
icon={Users}
isLoading={!stats}
value={stats?.users.total.toLocaleString()}
subtitle={stats ? `${stats.users.active.toLocaleString()} active now` : undefined}
className="delay-100"
/>
<StatCard
title="Commands"
icon={Terminal}
isLoading={!stats}
value={stats?.commands.total.toLocaleString()}
subtitle={stats ? `${stats.commands.active} active · ${stats.commands.disabled} disabled` : undefined}
className="delay-200"
onClick={() => setCommandsDrawerOpen(true)}
/>
<StatCard
title="System Ping"
icon={Activity}
isLoading={!stats}
value={stats ? `${Math.round(stats.ping.avg)}ms` : undefined}
subtitle="Average latency"
className="delay-300"
valueClassName={stats ? cn(
"transition-colors duration-300",
stats.ping.avg < 100 ? "text-emerald-500" :
stats.ping.avg < 200 ? "text-yellow-500" : "text-red-500"
) : undefined}
/>
</div>
{/* Activity Chart */}
<div className="animate-in fade-in slide-up delay-400">
<ActivityChart />
</div>
<div className="grid gap-8 lg:grid-cols-3 animate-in fade-in slide-up delay-500">
{/* Economy Stats */}
<div className="lg:col-span-2 space-y-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>
</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 = {
where: mock(() => Promise.resolve([{ 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 = {
@@ -33,6 +35,7 @@ mock.module("@shared/db/DrizzleClient", () => {
findFirst: mock(() => Promise.resolve({ username: "test" })),
findMany: mock(() => Promise.resolve([])),
},
lootdrops: { findMany: mock(() => Promise.resolve([])) },
}
},
};
@@ -100,37 +103,20 @@ describe("WebServer Security & Limits", () => {
expect(data.status).toBe("ok");
});
describe("Administrative Actions Authorization", () => {
test("should reject administrative actions without token", async () => {
describe("Administrative Actions", () => {
test("should allow administrative actions without token", async () => {
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
method: "POST"
});
expect(response.status).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}` }
});
// Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
expect(response.status).not.toBe(401);
expect(response.status).toBe(200);
});
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`, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.ADMIN_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ not_enabled: true }) // Wrong field

View File

@@ -59,6 +59,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
// Interval for broadcasting stats to all connected WS clients
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({
port,
hostname,
@@ -97,17 +102,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) {
console.error("Error fetching activity stats:", error);
return Response.json(
{ error: "Failed to fetch activity statistics" },
{ status: 500 }
);
}
}
// Administrative Actions
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
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 { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types");
@@ -141,6 +168,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) {
console.error("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) {
console.error("Error fetching settings meta:", error);
return Response.json(
{ error: "Failed to fetch metadata" },
{ status: 500 }
);
}
}
// Static File Serving
let pathName = url.pathname;
if (pathName === "/") pathName = "/index.html";
@@ -158,10 +260,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
if (await fileRef.exists()) {
// If serving index.html, inject env vars for frontend
if (pathName === "/index.html") {
let 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>`);
const html = await fileRef.text();
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
return new Response(fileRef);
@@ -170,15 +269,25 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
// SPA Fallback: Serve index.html for unknown non-file routes
const parts = pathName.split("/");
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 });
}
const indexFile = Bun.file(join(distDir, "index.html"));
let indexHtml = await indexFile.text();
const { env: sharedEnv } = await import("@shared/lib/env");
const script = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${sharedEnv.ADMIN_TOKEN}" };</script>`;
indexHtml = indexHtml.replace("</head>", `${script}</head>`);
if (!(await indexFile.exists())) {
if (isDev) {
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>", {
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" } });
},
@@ -254,32 +363,78 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
async function getFullDashboardStats() {
// Import services (dynamic to avoid circular deps)
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");
// Fetch all data in parallel
const [clientStats, activeUsers, totalUsers, economyStats, recentEvents] = await Promise.all([
// Fetch all data in parallel with error isolation
const results = await Promise.allSettled([
Promise.resolve(getClientStats()),
dashboardService.getActiveUserCount(),
dashboardService.getTotalUserCount(),
dashboardService.getEconomyStats(),
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;
console.error(`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: [] }, 'leaderboards');
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
return {
bot: clientStats.bot,
guilds: { count: clientStats.guilds },
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 },
economy: {
totalWealth: economyStats.totalWealth.toString(),
avgLevel: economyStats.avgLevel,
topStreak: economyStats.topStreak,
totalItems,
},
recentEvents: recentEvents.map(event => ({
...event,
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,
lastCommandTimestamp: clientStats.lastCommandTimestamp,
maintenanceMode: (await import("../../bot/lib/BotClient")).AuroraClient.maintenanceMode,

View File

@@ -39,68 +39,242 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--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 {
--radius: 1rem;
--background: oklch(0.12 0.02 260);
--foreground: oklch(0.98 0.01 260);
--card: oklch(0.16 0.03 260 / 0.5);
--card-foreground: oklch(0.98 0.01 260);
--popover: oklch(0.14 0.02 260 / 0.8);
--popover-foreground: oklch(0.98 0.01 260);
--primary: oklch(0.65 0.18 250);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.25 0.04 260);
--secondary-foreground: oklch(0.98 0.01 260);
--muted: oklch(0.2 0.03 260 / 0.6);
--muted-foreground: oklch(0.7 0.02 260);
--accent: oklch(0.3 0.05 250 / 0.4);
--accent-foreground: oklch(0.98 0.01 260);
--destructive: oklch(0.6 0.18 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 5%);
--ring: oklch(0.65 0.18 250 / 50%);
--chart-1: oklch(0.6 0.18 250);
--chart-2: oklch(0.7 0.15 160);
--chart-3: oklch(0.8 0.12 80);
--chart-4: oklch(0.6 0.2 300);
--chart-5: oklch(0.6 0.25 20);
--sidebar: oklch(0.14 0.02 260 / 0.6);
--sidebar-foreground: oklch(0.98 0.01 260);
--sidebar-primary: oklch(0.65 0.18 250);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(1 0 0 / 5%);
--sidebar-accent-foreground: oklch(0.98 0.01 260);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.65 0.18 250 / 50%);
--step--2: clamp(0.5002rem, 0.449rem + 0.2273vw, 0.6252rem);
--step--1: clamp(0.7072rem, 0.6349rem + 0.3215vw, 0.884rem);
--step-0: clamp(1rem, 0.8977rem + 0.4545vw, 1.25rem);
--step-1: clamp(1.414rem, 1.2694rem + 0.6427vw, 1.7675rem);
--step-2: clamp(1.9994rem, 1.7949rem + 0.9088vw, 2.4992rem);
--step-3: clamp(2.8271rem, 2.538rem + 1.2851vw, 3.5339rem);
--step-4: clamp(3.9976rem, 3.5887rem + 1.8171vw, 4.997rem);
--step-5: clamp(5.6526rem, 5.0745rem + 2.5694vw, 7.0657rem);
--radius: 0.5rem;
--background: oklch(0.12 0.015 40);
--foreground: oklch(0.98 0.01 60);
--card: oklch(0.16 0.03 40 / 0.6);
--card-foreground: oklch(0.98 0.01 60);
--popover: oklch(0.14 0.02 40 / 0.85);
--popover-foreground: oklch(0.98 0.01 60);
--primary: oklch(0.82 0.18 85);
--primary-foreground: oklch(0.12 0.015 40);
--secondary: oklch(0.65 0.2 55);
--secondary-foreground: oklch(0.98 0.01 60);
--muted: oklch(0.22 0.02 40 / 0.6);
--muted-foreground: oklch(0.7 0.08 40);
--accent: oklch(0.75 0.15 70 / 0.15);
--accent-foreground: oklch(0.98 0.01 60);
--destructive: oklch(0.55 0.18 25);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 8%);
--ring: oklch(0.82 0.18 85 / 40%);
--chart-1: oklch(0.82 0.18 85);
--chart-2: oklch(0.65 0.2 55);
--chart-3: oklch(0.75 0.15 70);
--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 {
* {
@apply border-border outline-ring/50;
border-color: var(--border);
}
body {
@apply bg-background text-foreground selection:bg-primary/30;
font-family: 'Outfit', 'Inter', system-ui, sans-serif;
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%);
background-attachment: fixed;
background-color: var(--background);
color: var(--foreground);
}
/* Global Scrollbar Styling */
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 {
.glass {
@apply bg-card backdrop-blur-xl border border-white/10 shadow-2xl;
.bg-aurora-page {
background: radial-gradient(circle at 50% -20%, oklch(0.25 0.1 50) 0%, var(--background) 70%);
background-attachment: fixed;
}
.glass-sidebar {
@apply bg-sidebar backdrop-blur-2xl border-r border-white/5;
.bg-aurora {
background-image: linear-gradient(135deg, oklch(0.82 0.18 85) 0%, oklch(0.65 0.2 55) 100%);
}
.text-glow {
text-shadow: 0 0 10px oklch(var(--primary) / 0.5);
.glass-card {
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;
}
}