Compare commits
47 Commits
feat/dashb
...
afe82c449b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe82c449b | ||
|
|
3c1334b30e | ||
|
|
58f261562a | ||
|
|
4ecbffd617 | ||
|
|
5491551544 | ||
|
|
7d658bbef9 | ||
|
|
d117bcb697 | ||
|
|
94e332ba57 | ||
|
|
3ef9773990 | ||
|
|
d243a11bd3 | ||
|
|
47ce0f12e6 | ||
|
|
f2caa1a3ee | ||
|
|
2a72beb0ef | ||
|
|
2f73f38877 | ||
|
|
9e5c6b5ac3 | ||
|
|
eb108695d3 | ||
|
|
7d541825d8 | ||
|
|
52f8ab11f0 | ||
|
|
f8436e9755 | ||
|
|
194a032c7f | ||
|
|
94a5a183d0 | ||
|
|
c7730b9355 | ||
|
|
1e20a5a7a0 | ||
|
|
54944283a3 | ||
|
|
f79ee6fbc7 | ||
|
|
915f1bc4ad | ||
|
|
4af2690bab | ||
|
|
6e57ab07e4 | ||
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd | ||
|
|
1cd3dbcd72 | ||
|
|
c97249f2ca | ||
|
|
0d923491b5 | ||
|
|
d870ef69d5 | ||
|
|
682e9d208e | ||
|
|
4a691ac71d | ||
|
|
1b84dbd36d | ||
|
|
a5b8d922e3 | ||
|
|
238d9a8803 | ||
|
|
713ea07040 | ||
|
|
bea6c33024 | ||
|
|
8fe300c8a2 | ||
|
|
9caa95a0d8 | ||
|
|
c6fd23b5fa | ||
|
|
d46434de18 | ||
|
|
cf4c28e1df |
@@ -1,57 +0,0 @@
|
||||
---
|
||||
description: Create a new Ticket
|
||||
---
|
||||
|
||||
### Role
|
||||
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
|
||||
|
||||
### Task
|
||||
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
|
||||
1. Analyze the request for technical implications, edge cases, and architectural fit.
|
||||
2. Generate a new Markdown file.
|
||||
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
|
||||
|
||||
### File Naming Convention
|
||||
You must use the following naming convention strictly:
|
||||
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
|
||||
|
||||
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
|
||||
|
||||
### File Content Structure
|
||||
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
|
||||
|
||||
```markdown
|
||||
# [Ticket ID]: [Feature Title]
|
||||
|
||||
**Status:** Draft
|
||||
**Created:** [YYYY-MM-DD]
|
||||
**Tags:** [comma, separated, tags]
|
||||
|
||||
## 1. Context & User Story
|
||||
* **As a:** [Role]
|
||||
* **I want to:** [Action]
|
||||
* **So that:** [Benefit/Value]
|
||||
|
||||
## 2. Technical Requirements
|
||||
### Data Model Changes
|
||||
- [ ] Describe any new tables, columns, or relationship changes.
|
||||
- [ ] SQL migration required? (Yes/No)
|
||||
|
||||
### API / Interface
|
||||
- [ ] Define endpoints (method, path) or function signatures.
|
||||
- [ ] Payload definition (JSON structure or Types).
|
||||
|
||||
## 3. Constraints & Validations (CRITICAL)
|
||||
*This section must be exhaustive. Do not be vague.*
|
||||
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
|
||||
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
|
||||
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
|
||||
|
||||
## 4. Acceptance Criteria
|
||||
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
|
||||
1. [ ] Criteria 1
|
||||
2. [ ] Criteria 2
|
||||
|
||||
## 5. Implementation Plan
|
||||
- [ ] Step 1: ...
|
||||
- [ ] Step 2: ...
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
description: Review the most recent changes critically.
|
||||
---
|
||||
|
||||
### Role
|
||||
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
|
||||
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
|
||||
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
|
||||
|
||||
### Phase 1: The Security & Logic Audit
|
||||
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
|
||||
|
||||
1. **TypeScript Strictness:**
|
||||
* Flag any usage of `any`.
|
||||
* Flag any use of non-null assertions (`!`) unless strictly guarded.
|
||||
* Flag forced type casting (`as UnknownType`) without validation.
|
||||
2. **Bun/Runtime Specifics:**
|
||||
* Check for unhandled Promises (floating promises).
|
||||
* Ensure environment variables are not hardcoded.
|
||||
3. **Security Vectors:**
|
||||
* **Injection:** Check SQL/NoSQL queries for concatenation.
|
||||
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
|
||||
* **Auth:** Are sensitive routes actually protected by middleware?
|
||||
|
||||
### Phase 2: Test Quality Verification
|
||||
Do not just check if tests pass. Check if the tests are **valid**.
|
||||
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
|
||||
2. **Edge Case Coverage:**
|
||||
* Did the code handle the *Constraints & Validations* listed in the original ticket?
|
||||
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
|
||||
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
|
||||
|
||||
### Phase 3: The Verdict
|
||||
Output your review in the following strict format:
|
||||
|
||||
---
|
||||
# 🛡️ Code Review Report
|
||||
|
||||
**Ticket ID:** [Ticket Name]
|
||||
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
|
||||
|
||||
## 🚨 Critical Issues (Must Fix)
|
||||
*List logic bugs, security risks, or failing tests.*
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
## ⚠️ Suggestions (Refactoring)
|
||||
*List code style improvements, variable naming, or DRY opportunities.*
|
||||
1. ...
|
||||
|
||||
## 🧪 Test Coverage Gap Analysis
|
||||
*List specific scenarios that are NOT currently tested but should be.*
|
||||
- [ ] Scenario: ...
|
||||
@@ -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.
|
||||
@@ -7,7 +7,6 @@ DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
ADMIN_TOKEN=Ffeg4hgsdfvsnyms,kmeuy64sy5y
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
VPS_HOST=your-vps-ip
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.env
|
||||
node_modules
|
||||
docker-compose.override.yml
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/loga
|
||||
|
||||
238
AGENTS.md
Normal file
238
AGENTS.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# AGENTS.md - AI Coding Agent Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM.
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun --watch bot/index.ts # Run bot with hot reload
|
||||
bun --hot web/src/index.ts # Run web dashboard with hot reload
|
||||
|
||||
# Testing
|
||||
bun test # Run all tests
|
||||
bun test path/to/file.test.ts # Run single test file
|
||||
bun test --watch # Watch mode
|
||||
bun test shared/modules/economy # Run tests in directory
|
||||
|
||||
# Database
|
||||
bun run generate # Generate Drizzle migrations (Docker)
|
||||
bun run migrate # Run migrations (Docker)
|
||||
bun run db:push # Push schema changes (Docker)
|
||||
bun run db:push:local # Push schema changes (local)
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
|
||||
# Web Dashboard
|
||||
cd web && bun run build # Build production web assets
|
||||
cd web && bun run dev # Development server
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
bot/ # Discord bot
|
||||
├── commands/ # Slash commands by category
|
||||
├── events/ # Discord event handlers
|
||||
├── lib/ # Bot core (BotClient, handlers, loaders)
|
||||
├── modules/ # Feature modules (views, interactions)
|
||||
└── graphics/ # Canvas image generation
|
||||
|
||||
shared/ # Shared between bot and web
|
||||
├── db/ # Database schema and migrations
|
||||
├── lib/ # Utils, config, errors, types
|
||||
└── modules/ # Domain services (economy, user, etc.)
|
||||
|
||||
web/ # React dashboard
|
||||
├── src/pages/ # React pages
|
||||
├── src/components/ # UI components (ShadCN/Radix)
|
||||
└── src/hooks/ # React hooks
|
||||
```
|
||||
|
||||
## Import Conventions
|
||||
|
||||
Use path aliases defined in tsconfig.json:
|
||||
|
||||
```typescript
|
||||
// External packages first
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Path aliases second
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { users } from "@db/schema";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
||||
|
||||
// Relative imports last
|
||||
import { localHelper } from "./helper";
|
||||
```
|
||||
|
||||
**Available Aliases:**
|
||||
- `@/*` - bot/
|
||||
- `@shared/*` - shared/
|
||||
- `@db/*` - shared/db/
|
||||
- `@lib/*` - bot/lib/
|
||||
- `@modules/*` - bot/modules/
|
||||
- `@commands/*` - bot/commands/
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
|---------|------------|---------|
|
||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||
| Services | camelCase singleton | `economyService`, `userService` |
|
||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Command Definition
|
||||
|
||||
```typescript
|
||||
export const commandName = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("commandname")
|
||||
.setDescription("Description"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
// Implementation
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Service Pattern (Singleton Object)
|
||||
|
||||
```typescript
|
||||
export const serviceName = {
|
||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||
return await withTransaction(async (tx) => {
|
||||
// Database operations
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Module File Organization
|
||||
|
||||
- `*.view.ts` - Creates Discord embeds/components
|
||||
- `*.interaction.ts` - Handles button/select/modal interactions
|
||||
- `*.types.ts` - Module-specific TypeScript types
|
||||
- `*.service.ts` - Business logic (in shared/modules/)
|
||||
- `*.test.ts` - Test files (co-located with source)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Classes
|
||||
|
||||
```typescript
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
|
||||
// User-facing errors (shown to user)
|
||||
throw new UserError("You don't have enough coins!");
|
||||
|
||||
// System errors (logged, generic message shown)
|
||||
throw new SystemError("Database connection failed");
|
||||
```
|
||||
|
||||
### Standard Error Pattern
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Unexpected error:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
### Transaction Usage
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from "@/lib/db";
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, discordId)
|
||||
});
|
||||
|
||||
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, discordId));
|
||||
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||
|
||||
return user;
|
||||
}, existingTx); // Pass existing tx if in nested transaction
|
||||
```
|
||||
|
||||
### Schema Notes
|
||||
|
||||
- Use `bigint` mode for Discord IDs and currency amounts
|
||||
- Relations defined separately from table definitions
|
||||
- Schema location: `shared/db/schema.ts`
|
||||
|
||||
## Testing
|
||||
|
||||
### Test File Structure
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock modules BEFORE imports
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: { query: mockQuery }
|
||||
}));
|
||||
|
||||
describe("serviceName", () => {
|
||||
beforeEach(() => {
|
||||
mockFn.mockClear();
|
||||
});
|
||||
|
||||
it("should handle expected case", async () => {
|
||||
// Arrange
|
||||
mockFn.mockResolvedValue(testData);
|
||||
|
||||
// Act
|
||||
const result = await service.method(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun 1.0+
|
||||
- **Bot:** Discord.js 14.x
|
||||
- **Web:** React 19 + Bun HTTP Server
|
||||
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||
- **UI:** Tailwind CSS v4 + ShadCN/Radix
|
||||
- **Validation:** Zod
|
||||
- **Testing:** Bun Test
|
||||
- **Container:** Docker
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | File |
|
||||
|---------|------|
|
||||
| Bot entry | `bot/index.ts` |
|
||||
| DB schema | `shared/db/schema.ts` |
|
||||
| Error classes | `shared/lib/errors.ts` |
|
||||
| Config loader | `shared/lib/config.ts` |
|
||||
| Environment | `shared/lib/env.ts` |
|
||||
| Embed helpers | `bot/lib/embeds.ts` |
|
||||
| Command utils | `shared/lib/utils.ts` |
|
||||
67
README.md
67
README.md
@@ -7,24 +7,44 @@
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { items } from "@db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
@@ -65,10 +65,10 @@ export const listing = createCommand({
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ async function handleUpdate(interaction: any) {
|
||||
timestamp: Date.now(),
|
||||
runMigrations: requirements.needsMigrations,
|
||||
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
||||
buildWebAssets: requirements.needsWebBuild,
|
||||
previousCommit: previousCommit.substring(0, 7),
|
||||
newCommit: updateInfo.latestCommit
|
||||
});
|
||||
|
||||
@@ -3,13 +3,14 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
@@ -21,14 +22,14 @@ export const daily = createCommand({
|
||||
)
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { userTimers, users } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -25,105 +11,42 @@ export const exam = createCommand({
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
try {
|
||||
// 1. Fetch existing timer/exam data
|
||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
// First, try to take the exam or check status
|
||||
const result = await examService.takeExam(interaction.user.id);
|
||||
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.insert(userTimers).values({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||
// Register the user
|
||||
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
|
||||
// 3. Cooldown Check
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
// Calculate time remaining
|
||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||
|
||||
if (result.status === ExamStatus.COOLDOWN) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Day Check
|
||||
if (currentDay !== examDay) {
|
||||
// Calculate next correct exam day to correct the schedule
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
if (result.status === ExamStatus.MISSED) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
@@ -132,74 +55,21 @@ export const exam = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
// Calculate Reward
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
// Allow negative reward? existing description implies "difference", usually gain.
|
||||
// If diff is negative (lost XP?), reward might be 0.
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
||||
}
|
||||
|
||||
// 6. Update State
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.transaction(async (tx) => {
|
||||
// Update Timer
|
||||
await tx.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
});
|
||||
|
||||
// If it reached here with AVAILABLE, it means they passed
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
|
||||
117
bot/commands/economy/trivia.ts
Normal file
117
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TriviaCategory } from "@shared/lib/constants";
|
||||
|
||||
export const trivia = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("trivia")
|
||||
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||
.addStringOption(option =>
|
||||
option.setName('category')
|
||||
.setDescription('Select a specific category')
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
try {
|
||||
const categoryId = interaction.options.getString('category');
|
||||
|
||||
// Check if user can play BEFORE deferring
|
||||
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||
|
||||
if (!canPlay.canPlay) {
|
||||
// Cooldown error - ephemeral
|
||||
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||
)],
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User can play - defer publicly for trivia question
|
||||
await interaction.deferReply();
|
||||
|
||||
// Start trivia session (deducts entry fee)
|
||||
const session = await triviaService.startTrivia(
|
||||
interaction.user.id,
|
||||
interaction.user.username,
|
||||
categoryId ? parseInt(categoryId) : undefined
|
||||
);
|
||||
|
||||
// Generate Components v2 message
|
||||
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||
|
||||
// Reply with Components v2 question
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
|
||||
// Set up automatic timeout cleanup
|
||||
setTimeout(async () => {
|
||||
const stillActive = triviaService.getSession(session.sessionId);
|
||||
if (stillActive) {
|
||||
// User didn't answer - clean up session with no reward
|
||||
try {
|
||||
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||
} catch (error) {
|
||||
// Session already cleaned up, ignore
|
||||
}
|
||||
}
|
||||
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error("Error in trivia command:", error);
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
|
||||
@@ -1,25 +1,83 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@shared/modules/quest/quest.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getQuestListComponents,
|
||||
getAvailableQuestsComponents,
|
||||
getQuestActionRows
|
||||
} from "@/modules/quest/quest.view";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("quests")
|
||||
.setDescription("View your active quests"),
|
||||
.setDescription("View your active and available quests"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userQuests = await questService.getUserQuests(interaction.user.id);
|
||||
const userId = interaction.user.id;
|
||||
|
||||
if (!userQuests || userQuests.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
||||
return;
|
||||
const updateView = async (viewType: 'active' | 'available') => {
|
||||
const userQuests = await questService.getUserQuests(userId);
|
||||
const availableQuests = await questService.getAvailableQuests(userId);
|
||||
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests)
|
||||
: getAvailableQuestsComponents(availableQuests);
|
||||
|
||||
const actionRows = getQuestActionRows(viewType);
|
||||
|
||||
await interaction.editReply({
|
||||
content: null,
|
||||
embeds: null as any,
|
||||
components: [...containers, ...actionRows] as any,
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
};
|
||||
|
||||
// Initial view
|
||||
await updateView('active');
|
||||
|
||||
const collector = response.createMessageComponentCollector({
|
||||
time: 120000, // 2 minutes
|
||||
componentType: undefined // Allow buttons
|
||||
});
|
||||
|
||||
collector.on('collect', async (i) => {
|
||||
if (i.user.id !== interaction.user.id) return;
|
||||
|
||||
try {
|
||||
if (i.customId === "quest_view_active") {
|
||||
await i.deferUpdate();
|
||||
await updateView('active');
|
||||
} else if (i.customId === "quest_view_available") {
|
||||
await i.deferUpdate();
|
||||
await updateView('available');
|
||||
} else if (i.customId.startsWith("quest_accept:")) {
|
||||
const questIdStr = i.customId.split(":")[1];
|
||||
if (!questIdStr) return;
|
||||
const questId = parseInt(questIdStr);
|
||||
await questService.assignQuest(userId, questId);
|
||||
|
||||
await i.reply({
|
||||
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
await updateView('active');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Quest interaction error:", error);
|
||||
await i.followUp({
|
||||
content: "Something went wrong while processing your quest interaction.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const embed = getQuestListEmbed(userQuests);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
collector.on('end', () => {
|
||||
interaction.editReply({ components: [] }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "@shared/lib/types";
|
||||
import { env } from "@shared/lib/env";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -72,11 +74,33 @@ export class Client extends DiscordClient {
|
||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||
this.maintenanceMode = enabled;
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||
const { userId, quest, rewards } = data;
|
||||
try {
|
||||
const user = await this.users.fetch(userId);
|
||||
if (!user) return;
|
||||
|
||||
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||
const components = getQuestCompletionComponents(quest, rewards);
|
||||
|
||||
// Try to send to the user's DM
|
||||
await user.send({
|
||||
components: components as any,
|
||||
flags: [MessageFlags.IsComponentsV2]
|
||||
}).catch(async () => {
|
||||
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send quest completion notification:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.commands.clear();
|
||||
this.knownCommands.clear();
|
||||
console.log("♻️ Reloading commands...");
|
||||
}
|
||||
|
||||
@@ -173,4 +197,4 @@ export class Client extends DiscordClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
|
||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });
|
||||
@@ -23,11 +23,13 @@ export function getClientStats(): ClientStats {
|
||||
bot: {
|
||||
name: AuroraClient.user?.username || "Aurora",
|
||||
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
|
||||
},
|
||||
guilds: AuroraClient.guilds.cache.size,
|
||||
ping: AuroraClient.ws.ping,
|
||||
cachedUsers: AuroraClient.users.cache.size,
|
||||
commandsRegistered: AuroraClient.commands.size,
|
||||
commandsKnown: AuroraClient.knownCommands.size,
|
||||
uptime: process.uptime(),
|
||||
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
import { BRANDING } from "@shared/lib/constants";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
/**
|
||||
* Applies standard branding to an embed.
|
||||
*/
|
||||
function applyBranding(embed: EmbedBuilder): EmbedBuilder {
|
||||
return embed.setFooter({
|
||||
text: `${BRANDING.FOOTER_TEXT} v${pkg.version}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error embed.
|
||||
@@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
* @returns An EmbedBuilder instance configured as an error.
|
||||
*/
|
||||
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`❌ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe
|
||||
* @returns An EmbedBuilder instance configured as a warning.
|
||||
*/
|
||||
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚠️ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
|
||||
* @returns An EmbedBuilder instance configured as a success.
|
||||
*/
|
||||
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`✅ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
|
||||
* @returns An EmbedBuilder instance configured as info.
|
||||
*/
|
||||
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`ℹ️ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
||||
*/
|
||||
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTimestamp();
|
||||
.setTimestamp()
|
||||
.setColor(color ?? BRANDING.COLOR);
|
||||
|
||||
if (title) embed.setTitle(title);
|
||||
if (description) embed.setDescription(description);
|
||||
if (color) embed.setColor(color);
|
||||
|
||||
return embed;
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export class ApplicationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AutocompleteInteraction } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
|
||||
try {
|
||||
await command.autocomplete(interaction);
|
||||
} catch (error) {
|
||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
|
||||
/**
|
||||
@@ -13,7 +14,7 @@ export class CommandHandler {
|
||||
const command = AuroraClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,14 +29,14 @@ export class CommandHandler {
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
} catch (error) {
|
||||
console.error("Failed to ensure user exists:", error);
|
||||
logger.error("bot", "Failed to ensure user exists", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
AuroraClient.lastCommandTimestamp = Date.now();
|
||||
} catch (error) {
|
||||
console.error(String(error));
|
||||
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
|
||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||
|
||||
import { UserError } from "@lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.error(`Handler method ${route.method} not found in module`);
|
||||
logger.error("bot", `Handler method ${route.method} not found in module`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +53,7 @@ export class ComponentInteractionHandler {
|
||||
|
||||
// Log system errors (non-user errors) for debugging
|
||||
if (!isUserError) {
|
||||
console.error(`Error in ${handlerName}:`, error);
|
||||
logger.error("bot", `Error in ${handlerName}`, error);
|
||||
}
|
||||
|
||||
const errorEmbed = createErrorEmbed(errorMessage);
|
||||
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
|
||||
}
|
||||
} catch (replyError) {
|
||||
// If we can't send a reply, log it
|
||||
console.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||
logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface RestartContext {
|
||||
timestamp: number;
|
||||
runMigrations: boolean;
|
||||
installDependencies: boolean;
|
||||
buildWebAssets: boolean;
|
||||
previousCommit: string;
|
||||
newCommit: string;
|
||||
}
|
||||
@@ -12,6 +13,7 @@ export interface RestartContext {
|
||||
export interface UpdateCheckResult {
|
||||
needsRootInstall: boolean;
|
||||
needsWebInstall: boolean;
|
||||
needsWebBuild: boolean;
|
||||
needsMigrations: boolean;
|
||||
changedFiles: string[];
|
||||
error?: Error;
|
||||
|
||||
@@ -31,7 +31,7 @@ export function getUpdatesAvailableMessage(
|
||||
force: boolean
|
||||
) {
|
||||
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
||||
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
|
||||
const { needsRootInstall, needsWebInstall, needsWebBuild, needsMigrations } = requirements;
|
||||
|
||||
// Build commit list (max 5)
|
||||
const commitList = commits
|
||||
@@ -50,6 +50,7 @@ export function getUpdatesAvailableMessage(
|
||||
const reqs: string[] = [];
|
||||
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
||||
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
||||
if (needsWebBuild) reqs.push("🏗️ Build web dashboard");
|
||||
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
||||
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
||||
|
||||
@@ -124,6 +125,9 @@ export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
||||
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
||||
steps.push("📦 Dependencies will be installed after restart");
|
||||
}
|
||||
if (requirements.needsWebBuild) {
|
||||
steps.push("🏗️ Web dashboard will be rebuilt after restart");
|
||||
}
|
||||
if (requirements.needsMigrations) {
|
||||
steps.push("🗃️ Migrations will run after restart");
|
||||
}
|
||||
@@ -157,16 +161,19 @@ export function getErrorEmbed(error: unknown) {
|
||||
export interface PostRestartResult {
|
||||
installSuccess: boolean;
|
||||
installOutput: string;
|
||||
webBuildSuccess: boolean;
|
||||
webBuildOutput: string;
|
||||
migrationSuccess: boolean;
|
||||
migrationOutput: string;
|
||||
ranInstall: boolean;
|
||||
ranWebBuild: boolean;
|
||||
ranMigrations: boolean;
|
||||
previousCommit?: string;
|
||||
newCommit?: string;
|
||||
}
|
||||
|
||||
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
||||
const isSuccess = result.installSuccess && result.webBuildSuccess && result.migrationSuccess;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
||||
@@ -192,6 +199,13 @@ export function getPostRestartEmbed(result: PostRestartResult, hasRollback: bool
|
||||
);
|
||||
}
|
||||
|
||||
if (result.ranWebBuild) {
|
||||
results.push(result.webBuildSuccess
|
||||
? "✅ Web dashboard built"
|
||||
: "❌ Web dashboard build failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (result.ranMigrations) {
|
||||
results.push(result.migrationSuccess
|
||||
? "✅ Migrations applied"
|
||||
@@ -216,6 +230,14 @@ export function getPostRestartEmbed(result: PostRestartResult, hasRollback: bool
|
||||
});
|
||||
}
|
||||
|
||||
if (result.webBuildOutput && !result.webBuildSuccess) {
|
||||
embed.addFields({
|
||||
name: "Web Build Output",
|
||||
value: `\`\`\`\n${truncate(result.webBuildOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (result.migrationOutput && !result.migrationSuccess) {
|
||||
embed.addFields({
|
||||
name: "Migration Output",
|
||||
@@ -259,6 +281,66 @@ export function getRunningMigrationsEmbed() {
|
||||
);
|
||||
}
|
||||
|
||||
export function getBuildingWebEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🌐 Building web dashboard assets...\nThis may take a moment.",
|
||||
"⏳ Building Web Dashboard"
|
||||
);
|
||||
}
|
||||
|
||||
export interface PostRestartProgress {
|
||||
installDeps: boolean;
|
||||
buildWeb: boolean;
|
||||
runMigrations: boolean;
|
||||
currentStep: "starting" | "install" | "build" | "migrate" | "done";
|
||||
installDone?: boolean;
|
||||
buildDone?: boolean;
|
||||
migrateDone?: boolean;
|
||||
}
|
||||
|
||||
export function getPostRestartProgressEmbed(progress: PostRestartProgress) {
|
||||
const steps: string[] = [];
|
||||
|
||||
// Installation step
|
||||
if (progress.installDeps) {
|
||||
if (progress.currentStep === "install") {
|
||||
steps.push("⏳ Installing dependencies...");
|
||||
} else if (progress.installDone) {
|
||||
steps.push("✅ Dependencies installed");
|
||||
} else {
|
||||
steps.push("⬚ Install dependencies");
|
||||
}
|
||||
}
|
||||
|
||||
// Web build step
|
||||
if (progress.buildWeb) {
|
||||
if (progress.currentStep === "build") {
|
||||
steps.push("⏳ Building web dashboard...");
|
||||
} else if (progress.buildDone) {
|
||||
steps.push("✅ Web dashboard built");
|
||||
} else {
|
||||
steps.push("⬚ Build web dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
// Migrations step
|
||||
if (progress.runMigrations) {
|
||||
if (progress.currentStep === "migrate") {
|
||||
steps.push("⏳ Running migrations...");
|
||||
} else if (progress.migrateDone) {
|
||||
steps.push("✅ Migrations applied");
|
||||
} else {
|
||||
steps.push("⬚ Run migrations");
|
||||
}
|
||||
}
|
||||
|
||||
if (steps.length === 0) {
|
||||
steps.push("⚡ Quick restart (no extra steps needed)");
|
||||
}
|
||||
|
||||
return createInfoEmbed(steps.join("\n"), "🔄 Post-Update Tasks");
|
||||
}
|
||||
|
||||
export function getRollbackSuccessEmbed(commit: string) {
|
||||
return createSuccessEmbed(
|
||||
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { config } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
// Handle select menu for choosing feedback type
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ContainerBuilder,
|
||||
TextDisplayBuilder,
|
||||
SeparatorBuilder,
|
||||
SeparatorSpacingSize,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
|
||||
/**
|
||||
* Quest entry with quest details and progress
|
||||
@@ -7,12 +16,33 @@ interface QuestEntry {
|
||||
progress: number | null;
|
||||
completedAt: Date | null;
|
||||
quest: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
triggerEvent: string;
|
||||
requirements: any;
|
||||
rewards: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Available quest interface
|
||||
*/
|
||||
interface AvailableQuest {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rewards: any;
|
||||
requirements: any;
|
||||
}
|
||||
|
||||
// Color palette for containers
|
||||
const COLORS = {
|
||||
ACTIVE: 0x3498db, // Blue - in progress
|
||||
AVAILABLE: 0x2ecc71, // Green - available
|
||||
COMPLETED: 0xf1c40f // Gold - completed
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats quest rewards object into a human-readable string
|
||||
*/
|
||||
@@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
|
||||
const rewardStr: string[] = [];
|
||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||
return rewardStr.join(", ");
|
||||
return rewardStr.join(" • ") || "None";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the quest status display string
|
||||
* Renders a simple progress bar
|
||||
*/
|
||||
function getQuestStatus(completedAt: Date | null): string {
|
||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
||||
function renderProgressBar(current: number, total: number, size: number = 10): string {
|
||||
const percentage = Math.min(current / total, 1);
|
||||
const progress = Math.round(size * percentage);
|
||||
const empty = size - progress;
|
||||
|
||||
const progressText = "▰".repeat(progress);
|
||||
const emptyText = "▱".repeat(empty);
|
||||
|
||||
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed displaying a user's quest log
|
||||
* Creates Components v2 containers for the quest list (active quests only)
|
||||
*/
|
||||
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📜 Quest Log")
|
||||
.setColor(0x3498db); // Blue
|
||||
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||
// Filter to only show in-progress quests (not completed)
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.ACTIVE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||
new TextDisplayBuilder().setContent("-# Your active quests")
|
||||
);
|
||||
|
||||
if (activeQuests.length === 0) {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
|
||||
);
|
||||
return [container];
|
||||
}
|
||||
|
||||
activeQuests.forEach((entry) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
userQuests.forEach(entry => {
|
||||
const status = getQuestStatus(entry.completedAt);
|
||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
embed.addFields({
|
||||
name: `${entry.quest.name} (${status})`,
|
||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
||||
inline: false
|
||||
});
|
||||
const requirements = entry.quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
const progress = entry.progress || 0;
|
||||
const progressBar = renderProgressBar(progress, target);
|
||||
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||||
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||||
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||||
);
|
||||
});
|
||||
|
||||
return embed;
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Components v2 containers for available quests with inline accept buttons
|
||||
*/
|
||||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.AVAILABLE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
||||
);
|
||||
|
||||
if (availableQuests.length === 0) {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
|
||||
);
|
||||
return [container];
|
||||
}
|
||||
|
||||
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
||||
const questsToShow = availableQuests.slice(0, 10);
|
||||
|
||||
questsToShow.forEach((quest) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
const requirements = quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**${quest.name}**`),
|
||||
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
|
||||
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
|
||||
);
|
||||
|
||||
// Add accept button inline within the container
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`quest_accept:${quest.id}`)
|
||||
.setLabel("Accept Quest")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("✅")
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns action rows for navigation only
|
||||
*/
|
||||
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
||||
// Navigation row
|
||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_active")
|
||||
.setLabel("📜 Active")
|
||||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'active'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_available")
|
||||
.setLabel("🗺️ Available")
|
||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'available')
|
||||
);
|
||||
|
||||
return [navRow];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Components v2 celebratory message for quest completion
|
||||
*/
|
||||
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
|
||||
const rewardsText = formatQuestRewards({
|
||||
xp: Number(rewards.xp),
|
||||
balance: Number(rewards.balance)
|
||||
});
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.COMPLETED)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
|
||||
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
|
||||
)
|
||||
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
|
||||
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
|
||||
);
|
||||
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MessageFlags and allowedMentions for Components v2 messages
|
||||
*/
|
||||
export function getComponentsV2MessageFlags() {
|
||||
return {
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] as const }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||
|
||||
|
||||
|
||||
116
bot/modules/trivia/README.md
Normal file
116
bot/modules/trivia/README.md
Normal 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
|
||||
129
bot/modules/trivia/trivia.interaction.ts
Normal file
129
bot/modules/trivia/trivia.interaction.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||
import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export async function handleTriviaInteraction(interaction: ButtonInteraction) {
|
||||
const parts = interaction.customId.split('_');
|
||||
|
||||
// Check for "Give Up" button
|
||||
if (parts.length >= 3 && parts[0] === 'trivia' && parts[1] === 'giveup') {
|
||||
const sessionId = `${parts[2]}_${parts[3]}`;
|
||||
const session = triviaService.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
await interaction.reply({
|
||||
content: '❌ This trivia question has expired or already been answered.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ownership
|
||||
if (session.userId !== interaction.user.id) {
|
||||
await interaction.reply({
|
||||
content: '❌ This isn\'t your trivia question!',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
// Process as incorrect (user gave up)
|
||||
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, false);
|
||||
|
||||
// Show timeout view (since they gave up)
|
||||
const { components, flags } = getTriviaTimeoutView(
|
||||
session.question.question,
|
||||
session.question.correctAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle answer button
|
||||
if (parts.length < 5 || parts[0] !== 'trivia' || parts[1] !== 'answer') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = `${parts[2]}_${parts[3]}`;
|
||||
const answerIndexStr = parts[4];
|
||||
|
||||
if (!answerIndexStr) {
|
||||
throw new UserError('Invalid answer format.');
|
||||
}
|
||||
|
||||
const answerIndex = parseInt(answerIndexStr);
|
||||
|
||||
// Get session BEFORE deferring to check ownership
|
||||
const session = triviaService.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
// Session doesn't exist or expired
|
||||
await interaction.reply({
|
||||
content: '❌ This trivia question has expired or already been answered.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ownership BEFORE deferring
|
||||
if (session.userId !== interaction.user.id) {
|
||||
// Wrong user trying to answer - send ephemeral error
|
||||
await interaction.reply({
|
||||
content: '❌ This isn\'t your trivia question! Use `/trivia` to start your own game.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only defer if ownership is valid
|
||||
await interaction.deferUpdate();
|
||||
|
||||
// Check timeout
|
||||
if (new Date() > session.expiresAt) {
|
||||
const { components, flags } = getTriviaTimeoutView(
|
||||
session.question.question,
|
||||
session.question.correctAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
|
||||
// Clean up session
|
||||
await triviaService.submitAnswer(sessionId, interaction.user.id, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if correct
|
||||
const isCorrect = answerIndex === session.correctIndex;
|
||||
const userAnswer = session.allAnswers[answerIndex];
|
||||
|
||||
// Process result
|
||||
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, isCorrect);
|
||||
|
||||
// Update message with enhanced visual feedback
|
||||
const { components, flags } = getTriviaResultView(
|
||||
result,
|
||||
session.question.question,
|
||||
userAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
}
|
||||
336
bot/modules/trivia/trivia.view.ts
Normal file
336
bot/modules/trivia/trivia.view.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { config } from "@shared/lib/config";
|
||||
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||
import { classService } from "@shared/modules/class/class.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
||||
|
||||
2
bun.lock
2
bun.lock
@@ -9,12 +9,12 @@
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"postgres": "^3.4.7",
|
||||
"zod": "^4.1.13",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"postgres": "^3.4.7",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
|
||||
168
docs/main.md
Normal file
168
docs/main.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Aurora - Discord RPG Bot
|
||||
|
||||
A comprehensive, feature-rich Discord RPG bot built with modern technologies using a monorepo architecture.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and web dashboard in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
aurora-bot-discord/
|
||||
├── bot/ # Discord bot implementation
|
||||
│ ├── commands/ # Slash command implementations
|
||||
│ ├── events/ # Discord event handlers
|
||||
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
||||
│ └── index.ts # Bot entry point
|
||||
├── web/ # React web dashboard
|
||||
│ ├── src/ # React components and pages
|
||||
│ │ ├── pages/ # Dashboard pages (Admin, Settings, Home)
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ └── server.ts # Web server with API endpoints
|
||||
│ └── build.ts # Vite build configuration
|
||||
├── shared/ # Shared code between bot and web
|
||||
│ ├── db/ # Database schema and Drizzle ORM
|
||||
│ ├── lib/ # Utilities, config, logger, events
|
||||
│ ├── modules/ # Domain services (economy, admin, quest)
|
||||
│ └── config/ # Configuration files
|
||||
├── docker-compose.yml # Docker services (app, db)
|
||||
└── package.json # Root package manifest
|
||||
```
|
||||
|
||||
## Main Application Parts
|
||||
|
||||
### 1. Discord Bot (`bot/`)
|
||||
|
||||
The bot is built with Discord.js v14 and handles all Discord-related functionality.
|
||||
|
||||
**Core Components:**
|
||||
|
||||
- **BotClient** (`bot/lib/BotClient.ts`): Central client that manages commands, events, and Discord interactions
|
||||
- **Commands** (`bot/commands/`): Slash command implementations organized by category:
|
||||
- `admin/`: Server management commands (config, prune, warnings, notes)
|
||||
- `economy/`: Economy commands (balance, daily, pay, trade, trivia)
|
||||
- `inventory/`: Item management commands
|
||||
- `leveling/`: XP and level tracking
|
||||
- `quest/`: Quest commands
|
||||
- `user/`: User profile commands
|
||||
- **Events** (`bot/events/`): Discord event handlers:
|
||||
- `interactionCreate.ts`: Command interactions
|
||||
- `messageCreate.ts`: Message processing
|
||||
- `ready.ts`: Bot ready events
|
||||
- `guildMemberAdd.ts`: New member handling
|
||||
|
||||
### 2. Web Dashboard (`web/`)
|
||||
|
||||
A React 19 + Bun web application for bot administration and monitoring.
|
||||
|
||||
**Key Pages:**
|
||||
|
||||
- **Home** (`/`): Dashboard overview with live statistics
|
||||
- **Admin Overview** (`/admin/overview`): Real-time bot metrics
|
||||
- **Admin Quests** (`/admin/quests`): Quest management interface
|
||||
- **Settings** (`/settings/*`): Configuration pages for:
|
||||
- General settings
|
||||
- Economy settings
|
||||
- Systems settings
|
||||
- Roles settings
|
||||
|
||||
**Web Server Features:**
|
||||
|
||||
- Built with Bun's native HTTP server
|
||||
- WebSocket support for real-time updates
|
||||
- REST API endpoints for dashboard data
|
||||
- SPA fallback for client-side routing
|
||||
- Bun dev server with hot module replacement
|
||||
|
||||
### 3. Shared Core (`shared/`)
|
||||
|
||||
Shared code accessible by both bot and web applications.
|
||||
|
||||
**Database Layer (`shared/db/`):**
|
||||
|
||||
- **schema.ts**: Drizzle ORM schema definitions for:
|
||||
- `users`: User profiles with economy data
|
||||
- `items`: Item catalog with rarities and types
|
||||
- `inventory`: User item holdings
|
||||
- `transactions`: Economy transaction history
|
||||
- `classes`: RPG class system
|
||||
- `moderationCases`: Moderation logs
|
||||
- `quests`: Quest definitions
|
||||
|
||||
**Modules (`shared/modules/`):**
|
||||
|
||||
- **economy/**: Economy service, lootdrops, daily rewards, trading
|
||||
- **admin/**: Administrative actions (maintenance mode, cache clearing)
|
||||
- **quest/**: Quest creation and tracking
|
||||
- **dashboard/**: Dashboard statistics and real-time event bus
|
||||
- **leveling/**: XP and leveling logic
|
||||
|
||||
**Utilities (`shared/lib/`):**
|
||||
|
||||
- `config.ts`: Application configuration management
|
||||
- `logger.ts`: Structured logging system
|
||||
- `env.ts`: Environment variable handling
|
||||
- `events.ts`: Event bus for inter-module communication
|
||||
- `constants.ts`: Application-wide constants
|
||||
|
||||
## Main Use-Cases
|
||||
|
||||
### For Discord Users
|
||||
|
||||
1. **Class System**: Users can join different RPG classes with unique roles
|
||||
2. **Economy**:
|
||||
- View balance and net worth
|
||||
- Earn currency through daily rewards, trivia, and lootdrops
|
||||
- Send payments to other users
|
||||
3. **Trading**: Secure trading system between users
|
||||
4. **Inventory Management**: Collect, use, and trade items with rarities
|
||||
5. **Leveling**: XP-based progression system tied to activity
|
||||
6. **Quests**: Complete quests for rewards
|
||||
7. **Lootdrops**: Random currency drops in text channels
|
||||
|
||||
### For Server Administrators
|
||||
|
||||
1. **Bot Configuration**: Adjust economy rates, enable/disable features via dashboard
|
||||
2. **Moderation Tools**:
|
||||
- Warn, note, and track moderation cases
|
||||
- Mass prune inactive members
|
||||
- Role management
|
||||
3. **Quest Management**: Create and manage server-specific quests
|
||||
4. **Monitoring**:
|
||||
- Real-time dashboard with live statistics
|
||||
- Activity charts and event logs
|
||||
- Economy leaderboards
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Single Process Architecture**: Easy debugging with unified runtime
|
||||
2. **Type Safety**: Full TypeScript across all modules
|
||||
3. **Testing**: Bun test framework with unit tests for core services
|
||||
4. **Docker Support**: Production-ready containerization
|
||||
5. **Remote Access**: SSH tunneling scripts for production debugging
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ---------------- | --------------------------------- |
|
||||
| Runtime | Bun 1.0+ |
|
||||
| Bot Framework | Discord.js 14.x |
|
||||
| Web Framework | React 19 + Bun |
|
||||
| Database | PostgreSQL 17 |
|
||||
| ORM | Drizzle ORM |
|
||||
| Styling | Tailwind CSS v4 + ShadCN/Radix UI |
|
||||
| Validation | Zod |
|
||||
| Containerization | Docker |
|
||||
|
||||
## Running the Application
|
||||
|
||||
```bash
|
||||
# Database migrations
|
||||
bun run migrate
|
||||
|
||||
# Production (Docker)
|
||||
docker compose up
|
||||
```
|
||||
|
||||
The bot and dashboard process run on port 3000 and are accessible at `http://localhost:3000`.
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "1.1.3",
|
||||
"module": "bot/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# Command Reference
|
||||
|
||||
This document lists all available slash commands in Aurora, categorized by their function.
|
||||
|
||||
## Economy
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/balance` | View your or another user's balance. | `user` (Optional): The user to check. | Everyone |
|
||||
| `/daily` | Claim your daily currency reward and streak bonus. | None | Everyone |
|
||||
| `/pay` | Transfer currency to another user. | `user` (Required): Recipient.<br>`amount` (Required): Amount to send. | Everyone |
|
||||
| `/trade` | Start a trade session with another user. | `user` (Required): The user to trade with. | Everyone |
|
||||
| `/exam` | Take your weekly exam to earn rewards based on XP gain. | None | Everyone |
|
||||
|
||||
## Inventory & Items
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/inventory` | View your or another user's inventory. | `user` (Optional): The user to check. | Everyone |
|
||||
| `/use` | Use an item from your inventory. | `item` (Required): The item to use (Autocomplete). | Everyone |
|
||||
|
||||
## User & Social
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/profile` | View your or another user's Student ID card. | `user` (Optional): The user to view. | Everyone |
|
||||
| `/leaderboard` | View top players. | `type` (Required): 'Level / XP' or 'Balance'. | Everyone |
|
||||
| `/feedback` | Submit feedback, bug reports, or suggestions. | None | Everyone |
|
||||
| `/quests` | View your active quests. | None | Everyone |
|
||||
|
||||
## Admin
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These commands require Administrator permissions or specific roles as configured.
|
||||
|
||||
### General Management
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/config` | Manage bot configuration. | `group` (Req): Section.<br>`key` (Req): Setting.<br>`value` (Req): New value. |
|
||||
| `/refresh` | Refresh commands or configuration cache. | `type`: 'Commands' or 'Config'. |
|
||||
| `/update` | Update the bot from the repository. | None |
|
||||
| `/features` | Enable/Disable system features. | `feature` (Req): Feature name.<br>`enabled` (Req): True/False. |
|
||||
| `/webhook` | Send a message via webhook. | `payload` (Req): JSON payload. |
|
||||
|
||||
### Moderation
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/warn` | Warn a user. | `user` (Req): Target.<br>`reason` (Req): Reason. |
|
||||
| `/warnings` | View active warnings for a user. | `user` (Req): Target. |
|
||||
| `/clearwarning`| Clear a specific warning. | `case_id` (Req): Case ID. |
|
||||
| `/case` | View details of a specific moderation case. | `case_id` (Req): Case ID. |
|
||||
| `/cases` | View moderation history for a user. | `user` (Req): Target. |
|
||||
| `/note` | Add a note to a user. | `user` (Req): Target.<br>`note` (Req): Content. |
|
||||
| `/notes` | View notes for a user. | `user` (Req): Target. |
|
||||
| `/prune` | Bulk delete messages. | `amount` (Req): Number (1-100). |
|
||||
|
||||
### Game Admin
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/create_item` | Create a new item in the database. | (Modal based interaction) |
|
||||
| `/create_color`| Create a new color role. | `name` (Req): Role name.<br>`hex` (Req): Hex color code. |
|
||||
| `/listing` | Manage shop listings (Admin view). | None (Context sensitive?) |
|
||||
| `/terminal` | Control the terminal display channel. | `action`: 'setup', 'update', 'clear'. |
|
||||
@@ -1,160 +0,0 @@
|
||||
# Configuration Guide
|
||||
|
||||
This document outlines the structure and available options for the `config/config.json` file. The configuration is validated using Zod schemas at runtime (see `src/lib/config.ts`).
|
||||
|
||||
## Core Structure
|
||||
|
||||
### Leveling
|
||||
Configuration for the XP and leveling system.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `base` | `number` | The base XP required for the first level. |
|
||||
| `exponent` | `number` | The exponent used to calculate XP curves. |
|
||||
| `chat.cooldownMs` | `number` | Time in milliseconds between XP gains from chat. |
|
||||
| `chat.minXp` | `number` | Minimum XP awarded per message. |
|
||||
| `chat.maxXp` | `number` | Maximum XP awarded per message. |
|
||||
|
||||
### Economy
|
||||
Settings for currency, rewards, and transfers.
|
||||
|
||||
#### Daily
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `amount` | `integer` | Base amount granted by `/daily`. |
|
||||
| `streakBonus` | `integer` | Bonus amount per streak day. |
|
||||
| `weeklyBonus` | `integer` | Bonus amount for a 7-day streak. |
|
||||
| `cooldownMs` | `number` | Cooldown period for the command (usually 24h). |
|
||||
|
||||
#### Transfers
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `allowSelfTransfer` | `boolean` | Whether users can transfer money to themselves. |
|
||||
| `minAmount` | `integer` | Minimum amount required for a transfer. |
|
||||
|
||||
#### Exam
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `multMin` | `number` | Minimum multiplier for exam rewards. |
|
||||
| `multMax` | `number` | Maximum multiplier for exam rewards. |
|
||||
|
||||
### Inventory
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `maxStackSize` | `integer` | Maximum count of a single item in one slot. |
|
||||
| `maxSlots` | `number` | Total number of inventory slots available. |
|
||||
|
||||
### Lootdrop
|
||||
Settings for the random chat loot drop events.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `activityWindowMs` | `number` | Time window to track activity for spawning drops. |
|
||||
| `minMessages` | `number` | Minimum messages required in window to trigger drop. |
|
||||
| `spawnChance` | `number` | Probability (0-1) of a drop spawning when conditions met. |
|
||||
| `cooldownMs` | `number` | Minimum time between loot drops. |
|
||||
| `reward.min` | `number` | Minimum currency reward. |
|
||||
| `reward.max` | `number` | Maximum currency reward. |
|
||||
| `reward.currency` | `string` | The currency ID/Symbol used for rewards. |
|
||||
|
||||
### Roles
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `studentRole` | `string` | Discord Role ID for students. |
|
||||
| `visitorRole` | `string` | Discord Role ID for visitors. |
|
||||
| `colorRoles` | `string[]` | List of Discord Role IDs available as color roles. |
|
||||
|
||||
### Moderation
|
||||
Automated moderation settings.
|
||||
|
||||
#### Prune
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `maxAmount` | `number` | Maximum messages to delete in one go. |
|
||||
| `confirmThreshold` | `number` | Amount above which confirmation is required. |
|
||||
| `batchSize` | `number` | Size of delete batches. |
|
||||
| `batchDelayMs` | `number` | Delay between batches. |
|
||||
|
||||
#### Cases
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `dmOnWarn` | `boolean` | Whether to DM users when they are warned. |
|
||||
| `logChannelId` | `string` | (Optional) Channel ID for moderation logs. |
|
||||
| `autoTimeoutThreshold` | `number` | (Optional) Warn count to trigger auto-timeout. |
|
||||
|
||||
### System & Misc
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `commands` | `Object` | Map of command names (keys) to boolean (values) to enable/disable them. |
|
||||
| `welcomeChannelId` | `string` | (Optional) Channel ID for welcome messages. |
|
||||
| `welcomeMessage` | `string` | (Optional) Custom welcome message text. |
|
||||
| `feedbackChannelId` | `string` | (Optional) Channel ID where feedback is posted. |
|
||||
| `terminal.channelId` | `string` | (Optional) Channel ID for terminal display. |
|
||||
| `terminal.messageId` | `string` | (Optional) Message ID for terminal display. |
|
||||
|
||||
## Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"leveling": {
|
||||
"base": 100,
|
||||
"exponent": 1.5,
|
||||
"chat": {
|
||||
"cooldownMs": 60000,
|
||||
"minXp": 15,
|
||||
"maxXp": 25
|
||||
}
|
||||
},
|
||||
"economy": {
|
||||
"daily": {
|
||||
"amount": "100",
|
||||
"streakBonus": "10",
|
||||
"weeklyBonus": "500",
|
||||
"cooldownMs": 86400000
|
||||
},
|
||||
"transfers": {
|
||||
"allowSelfTransfer": false,
|
||||
"minAmount": "10"
|
||||
},
|
||||
"exam": {
|
||||
"multMin": 1.0,
|
||||
"multMax": 2.0
|
||||
}
|
||||
},
|
||||
"inventory": {
|
||||
"maxStackSize": "99",
|
||||
"maxSlots": 20
|
||||
},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 300000,
|
||||
"minMessages": 10,
|
||||
"spawnChance": 0.05,
|
||||
"cooldownMs": 3600000,
|
||||
"reward": {
|
||||
"min": 50,
|
||||
"max": 150,
|
||||
"currency": "CREDITS"
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"example": true
|
||||
},
|
||||
"studentRole": "123456789012345678",
|
||||
"visitorRole": "123456789012345678",
|
||||
"colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": {
|
||||
"maxAmount": 100,
|
||||
"confirmThreshold": 50,
|
||||
"batchSize": 100,
|
||||
"batchDelayMs": 1000
|
||||
},
|
||||
"cases": {
|
||||
"dmOnWarn": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Fields marked as `integer` or `bigint` in the types can often be provided as strings in the JSON to ensure precision, but the system handles parsing them.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Database Schema
|
||||
|
||||
This document outlines the database schema for the Aurora project. The database is PostgreSQL, managed via Drizzle ORM.
|
||||
|
||||
## Tables
|
||||
|
||||
### Users (`users`)
|
||||
Stores user data, economy, and progression.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` | Primary Key. Discord User ID. |
|
||||
| `class_id` | `bigint` | Foreign Key -> `classes.id`. |
|
||||
| `username` | `varchar(255)` | User's Discord username. |
|
||||
| `is_active` | `boolean` | Whether the user is active (default: true). |
|
||||
| `balance` | `bigint` | User's currency balance. |
|
||||
| `xp` | `bigint` | User's experience points. |
|
||||
| `level` | `integer` | User's level. |
|
||||
| `daily_streak` | `integer` | Current streak of daily command usage. |
|
||||
| `settings` | `jsonb` | User-specific settings. |
|
||||
| `created_at` | `timestamp` | Record creation time. |
|
||||
| `updated_at` | `timestamp` | Last update time. |
|
||||
|
||||
### Classes (`classes`)
|
||||
Available character classes.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` | Primary Key. Custom ID. |
|
||||
| `name` | `varchar(255)` | Class name (Unique). |
|
||||
| `balance` | `bigint` | Class bank balance (shared/flavor). |
|
||||
| `role_id` | `varchar(255)` | Discord Role ID associated with the class. |
|
||||
|
||||
### Items (`items`)
|
||||
Definitions of items available in the game.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `serial` | Primary Key. Auto-incrementing ID. |
|
||||
| `name` | `varchar(255)` | Item name (Unique). |
|
||||
| `description` | `text` | Item description. |
|
||||
| `rarity` | `varchar(20)` | Common, Rare, etc. Default: 'Common'. |
|
||||
| `type` | `varchar(50)` | MATERIAL, CONSUMABLE, EQUIPMENT, etc. |
|
||||
| `usage_data` | `jsonb` | Effect data for consumables/usables. |
|
||||
| `price` | `bigint` | Base value of the item. |
|
||||
| `icon_url` | `text` | URL for the item's icon. |
|
||||
| `image_url` | `text` | URL for the item's large image. |
|
||||
|
||||
### Inventory (`inventory`)
|
||||
Items held by users.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `item_id` | `integer` | PK/FK -> `items.id`. |
|
||||
| `quantity` | `bigint` | Amount held. Must be > 0. |
|
||||
|
||||
### Transactions (`transactions`)
|
||||
Currency transaction history.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `user_id` | `bigint` | FK -> `users.id`. The user affecting the balance. |
|
||||
| `related_user_id` | `bigint` | FK -> `users.id`. The other party (if any). |
|
||||
| `amount` | `bigint` | Amount transferred. |
|
||||
| `type` | `varchar(50)` | Transaction type identifier. |
|
||||
| `description` | `text` | Human-readable description. |
|
||||
| `created_at` | `timestamp` | Time of transaction. |
|
||||
|
||||
### Item Transactions (`item_transactions`)
|
||||
Item flow history.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `user_id` | `bigint` | FK -> `users.id`. |
|
||||
| `related_user_id` | `bigint` | FK -> `users.id`. |
|
||||
| `item_id` | `integer` | FK -> `items.id`. |
|
||||
| `quantity` | `bigint` | Amount gained (+) or lost (-). |
|
||||
| `type` | `varchar(50)` | TRADE, SHOP_BUY, DROP, etc. |
|
||||
| `description` | `text` | Description. |
|
||||
| `created_at` | `timestamp` | Time of transaction. |
|
||||
|
||||
### Quests (`quests`)
|
||||
Quest definitions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `serial` | Primary Key. |
|
||||
| `name` | `varchar(255)` | Quest title. |
|
||||
| `description` | `text` | Quest text. |
|
||||
| `trigger_event` | `varchar(50)` | Event that triggers progress checks. |
|
||||
| `requirements` | `jsonb` | Completion criteria. |
|
||||
| `rewards` | `jsonb` | Rewards for completion. |
|
||||
|
||||
### User Quests (`user_quests`)
|
||||
User progress on quests.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `quest_id` | `integer` | PK/FK -> `quests.id`. |
|
||||
| `progress` | `integer` | Current progress value. |
|
||||
| `completed_at` | `timestamp` | Completion time (null if active). |
|
||||
|
||||
### User Timers (`user_timers`)
|
||||
Generic timers for cooldowns, temporary effects, etc.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `type` | `varchar(50)` | PK. Timer type (COOLDOWN, EFFECT, ACCESS). |
|
||||
| `key` | `varchar(100)` | PK. specific ID (e.g. 'daily'). |
|
||||
| `expires_at` | `timestamp` | When the timer expires. |
|
||||
| `metadata` | `jsonb` | Extra data. |
|
||||
|
||||
### Lootdrops (`lootdrops`)
|
||||
Active chat loot drop events.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `message_id` | `varchar(255)` | Primary Key. Discord Message ID. |
|
||||
| `channel_id` | `varchar(255)` | Discord Channel ID. |
|
||||
| `reward_amount` | `integer` | Currency amount. |
|
||||
| `currency` | `varchar(50)` | Currency type constant. |
|
||||
| `claimed_by` | `bigint` | FK -> `users.id`. Null if unclaimed. |
|
||||
| `created_at` | `timestamp` | Spawn time. |
|
||||
| `expires_at` | `timestamp` | Despawn time. |
|
||||
|
||||
### Moderation Cases (`moderation_cases`)
|
||||
History of moderation actions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `case_id` | `varchar(50)` | Unique friendly ID. |
|
||||
| `type` | `varchar(20)` | warn, timeout, kick, ban, etc. |
|
||||
| `user_id` | `bigint` | Target user ID. |
|
||||
| `username` | `varchar(255)` | Target username snapshot. |
|
||||
| `moderator_id` | `bigint` | Acting moderator ID. |
|
||||
| `moderator_name` | `varchar(255)` | Moderator username snapshot. |
|
||||
| `reason` | `text` | Reason for action. |
|
||||
| `metadata` | `jsonb` | Extra data. |
|
||||
| `active` | `boolean` | Is this case active? |
|
||||
| `created_at` | `timestamp` | Creation time. |
|
||||
| `resolved_at` | `timestamp` | Resolution/Expiration time. |
|
||||
| `resolved_by` | `bigint` | User ID who resolved it. |
|
||||
| `resolved_reason` | `text` | Reason for resolution. |
|
||||
@@ -1,127 +0,0 @@
|
||||
# Lootbox Creation Guide
|
||||
|
||||
Currently, the Item Wizard does not support creating **Lootbox** items directly. Instead, they must be inserted manually into the database. This guide details the required JSON structure for the `LOOTBOX` effect.
|
||||
|
||||
## Item Structure
|
||||
|
||||
To create a lootbox, you need to insert a row into the `items` table. The critical part is the `usageData` JSON column.
|
||||
|
||||
```json
|
||||
{
|
||||
"consume": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "LOOTBOX",
|
||||
"pool": [ ... ]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Loot Table Structure
|
||||
|
||||
The `pool` property is an array of `LootTableItem` objects. A random item is selected based on the total `weight` of all items in the pool.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `string` | One of: `CURRENCY`, `ITEM`, `XP`, `NOTHING`. |
|
||||
| `weight` | `number` | The relative probability weight of this outcome. |
|
||||
| `message` | `string` | (Optional) Custom message to display when this outcome is selected. |
|
||||
|
||||
### Outcome Types
|
||||
|
||||
#### 1. Currency
|
||||
Gives the user coins.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "CURRENCY",
|
||||
"weight": 50,
|
||||
"amount": 100, // Fixed amount OR
|
||||
"minAmount": 50, // Minimum random amount
|
||||
"maxAmount": 150 // Maximum random amount
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. XP
|
||||
Gives the user Experience Points.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "XP",
|
||||
"weight": 30,
|
||||
"amount": 500 // Fixed amount OR range (minAmount/maxAmount)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Item
|
||||
Gives the user another item (by ID).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ITEM",
|
||||
"weight": 10,
|
||||
"itemId": 42, // The ID of the item to give
|
||||
"amount": 1 // (Optional) Quantity to give, default 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Nothing
|
||||
An empty roll.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "NOTHING",
|
||||
"weight": 10,
|
||||
"message": "The box was empty! Better luck next time."
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here is a full SQL insert example (using a hypothetical SQL client or Drizzle studio) for a "Basic Lootbox":
|
||||
|
||||
**Name**: Basic Lootbox
|
||||
**Type**: CONSUMABLE
|
||||
**Effect**:
|
||||
- 50% chance for 100-200 Coins
|
||||
- 30% chance for 500 XP
|
||||
- 10% chance for Item ID 5 (e.g. Rare Gem)
|
||||
- 10% chance for Nothing
|
||||
|
||||
**JSON for `usageData`**:
|
||||
```json
|
||||
{
|
||||
"consume": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "LOOTBOX",
|
||||
"pool": [
|
||||
{
|
||||
"type": "CURRENCY",
|
||||
"weight": 50,
|
||||
"minAmount": 100,
|
||||
"maxAmount": 200
|
||||
},
|
||||
{
|
||||
"type": "XP",
|
||||
"weight": 30,
|
||||
"amount": 500
|
||||
},
|
||||
{
|
||||
"type": "ITEM",
|
||||
"weight": 10,
|
||||
"itemId": 5,
|
||||
"amount": 1,
|
||||
"message": "Startstruck! You found a Rare Gem!"
|
||||
},
|
||||
{
|
||||
"type": "NOTHING",
|
||||
"weight": 10,
|
||||
"message": "It's empty..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1,72 +0,0 @@
|
||||
# Aurora Module Structure Guide
|
||||
|
||||
This guide documents the standard module organization patterns used in the Aurora codebase. Following these patterns ensures consistency, maintainability, and clear separation of concerns.
|
||||
|
||||
## Module Anatomy
|
||||
|
||||
A typical module in `@modules/` is organized into several files, each with a specific responsibility.
|
||||
|
||||
Example: `trade` module
|
||||
- `trade.service.ts`: Business logic and data access.
|
||||
- `trade.view.ts`: Discord UI components (embeds, modals, select menus).
|
||||
- `trade.interaction.ts`: Handler for interaction events (buttons, modals, etc.).
|
||||
- `trade.types.ts`: TypeScript interfaces and types.
|
||||
- `trade.service.test.ts`: Unit tests for the service logic.
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
### 1. Service (`*.service.ts`)
|
||||
The core of the module. It contains the business logic, database interactions (using Drizzle), and state management.
|
||||
- **Rules**:
|
||||
- Export a singleton instance: `export const tradeService = new TradeService();`
|
||||
- Should not contain Discord-specific rendering logic (return data, not embeds).
|
||||
- Throw `UserError` for validation issues that should be shown to the user.
|
||||
|
||||
### 2. View (`*.view.ts`)
|
||||
Handles the creation of Discord-specific UI elements like `EmbedBuilder`, `ActionRowBuilder`, and `ModalBuilder`.
|
||||
- **Rules**:
|
||||
- Focus on formatting and presentation.
|
||||
- Takes raw data (from services) and returns Discord components.
|
||||
|
||||
### 3. Interaction Handler (`*.interaction.ts`)
|
||||
The entry point for Discord component interactions (buttons, select menus, modals).
|
||||
- **Rules**:
|
||||
- Export a single handler function: `export async function handleTradeInteraction(interaction: Interaction) { ... }`
|
||||
- Routes internal `customId` patterns to specific logic.
|
||||
- Relies on `ComponentInteractionHandler` for centralized error handling.
|
||||
- **No local try-catch** for standard validation errors; let them bubble up as `UserError`.
|
||||
|
||||
### 4. Types (`*.types.ts`)
|
||||
Central location for module-specific TypeScript types and constants.
|
||||
- **Rules**:
|
||||
- Define interfaces for complex data structures.
|
||||
- Use enums or literal types for states and custom IDs.
|
||||
|
||||
## Interaction Routing
|
||||
|
||||
All interaction handlers must be registered in `src/lib/interaction.routes.ts`.
|
||||
|
||||
```typescript
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("module_"),
|
||||
handler: () => import("@/modules/module/module.interaction"),
|
||||
method: 'handleModuleInteraction'
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling Standards
|
||||
|
||||
Aurora uses a centralized error handling pattern in `ComponentInteractionHandler`.
|
||||
|
||||
1. **UserError**: Use this for validation errors or issues the user can fix (e.g., "Insufficient funds").
|
||||
- `throw new UserError("You need more coins!");`
|
||||
2. **SystemError / Generic Error**: Use this for unexpected system failures.
|
||||
- These are logged to the console/logger and show a generic "Unexpected error" message to the user.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Directory Name**: Lowercase, singular (e.g., `trade`, `inventory`).
|
||||
- **File Names**: `moduleName.type.ts` (e.g., `trade.service.ts`).
|
||||
- **Class Names**: PascalCase (e.g., `TradeService`).
|
||||
- **Service Instances**: camelCase (e.g., `tradeService`).
|
||||
- **Interaction Method**: `handle[ModuleName]Interaction`.
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,28 @@ 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,
|
||||
}
|
||||
|
||||
export const BRANDING = {
|
||||
COLOR: 0x00d4ff as const,
|
||||
FOOTER_TEXT: 'Aurora' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,5 +17,8 @@ export const EVENTS = {
|
||||
RELOAD_COMMANDS: "actions:reload_commands",
|
||||
CLEAR_CACHE: "actions:clear_cache",
|
||||
MAINTENANCE_MODE: "actions:maintenance_mode",
|
||||
},
|
||||
QUEST: {
|
||||
COMPLETED: "quest:completed",
|
||||
}
|
||||
} as const;
|
||||
|
||||
118
shared/lib/logger.test.ts
Normal file
118
shared/lib/logger.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect, test, describe, beforeAll, afterAll, spyOn } from "bun:test";
|
||||
import { logger } from "./logger";
|
||||
import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
describe("Logger", () => {
|
||||
const logDir = join(process.cwd(), "logs");
|
||||
const logFile = join(logDir, "error.log");
|
||||
|
||||
beforeAll(() => {
|
||||
// Cleanup if exists
|
||||
try {
|
||||
if (existsSync(logFile)) unlinkSync(logFile);
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
test("should log info messages to console with correct format", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
const message = "Formatting test";
|
||||
logger.info("system", message);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const callArgs = spy.mock.calls[0]?.[0];
|
||||
expect(callArgs).toBeDefined();
|
||||
if (callArgs) {
|
||||
// Strict regex check for ISO timestamp and format
|
||||
const regex = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] \[SYSTEM\] Formatting test$/;
|
||||
expect(callArgs).toMatch(regex);
|
||||
}
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("should write error logs to file with stack trace", async () => {
|
||||
const errorMessage = "Test error message";
|
||||
const testError = new Error("Source error");
|
||||
|
||||
logger.error("system", errorMessage, testError);
|
||||
|
||||
// Polling for file write instead of fixed timeout
|
||||
let content = "";
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (existsSync(logFile)) {
|
||||
content = readFileSync(logFile, "utf-8");
|
||||
if (content.includes("Source error")) break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[ERROR\] \[SYSTEM\] Test error message: Source error/);
|
||||
expect(content).toContain("Stack Trace:");
|
||||
expect(content).toContain("Error: Source error");
|
||||
expect(content).toContain("logger.test.ts");
|
||||
});
|
||||
|
||||
test("should handle log directory creation failures gracefully", async () => {
|
||||
const consoleSpy = spyOn(console, "error");
|
||||
|
||||
// We trigger an error by trying to use a path that is a file where a directory should be
|
||||
const triggerFile = join(process.cwd(), "logs_fail_trigger");
|
||||
|
||||
try {
|
||||
writeFileSync(triggerFile, "not a directory");
|
||||
|
||||
// Manually override paths for this test instance
|
||||
const originalLogDir = (logger as any).logDir;
|
||||
const originalLogPath = (logger as any).errorLogPath;
|
||||
|
||||
(logger as any).logDir = triggerFile;
|
||||
(logger as any).errorLogPath = join(triggerFile, "error.log");
|
||||
(logger as any).initialized = false;
|
||||
|
||||
logger.error("system", "This should fail directory creation");
|
||||
|
||||
// Wait for async initialization attempt
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
expect(consoleSpy.mock.calls.some(call =>
|
||||
String(call[0]).includes("Failed to initialize logger directory")
|
||||
)).toBe(true);
|
||||
|
||||
// Reset logger state
|
||||
(logger as any).logDir = originalLogDir;
|
||||
(logger as any).errorLogPath = originalLogPath;
|
||||
(logger as any).initialized = false;
|
||||
} finally {
|
||||
if (existsSync(triggerFile)) unlinkSync(triggerFile);
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("should include complex data objects in logs", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
const data = { userId: "123", tags: ["test"] };
|
||||
logger.info("bot", "Message with data", data);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const callArgs = spy.mock.calls[0]?.[0];
|
||||
expect(callArgs).toBeDefined();
|
||||
if (callArgs) {
|
||||
expect(callArgs).toContain(` | Data: ${JSON.stringify(data)}`);
|
||||
}
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("should handle circular references in data objects", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
const data: any = { name: "circular" };
|
||||
data.self = data;
|
||||
|
||||
logger.info("bot", "Circular test", data);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const callArgs = spy.mock.calls[0]?.[0];
|
||||
expect(callArgs).toContain("[Circular]");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
162
shared/lib/logger.ts
Normal file
162
shared/lib/logger.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { join, resolve } from "path";
|
||||
import { appendFile, mkdir, stat } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
const LogLevelNames = {
|
||||
[LogLevel.DEBUG]: "DEBUG",
|
||||
[LogLevel.INFO]: "INFO",
|
||||
[LogLevel.WARN]: "WARN",
|
||||
[LogLevel.ERROR]: "ERROR",
|
||||
};
|
||||
|
||||
export type LogSource = "bot" | "web" | "shared" | "system";
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
source: LogSource;
|
||||
message: string;
|
||||
data?: any;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private logDir: string;
|
||||
private errorLogPath: string;
|
||||
private initialized: boolean = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Use resolve with __dirname or process.cwd() but make it more robust
|
||||
// Since this is in shared/lib/, we can try to find the project root
|
||||
// For now, let's stick to a resolved path from process.cwd() or a safer alternative
|
||||
this.logDir = resolve(process.cwd(), "logs");
|
||||
this.errorLogPath = join(this.logDir, "error.log");
|
||||
}
|
||||
|
||||
private async ensureInitialized() {
|
||||
if (this.initialized) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
await mkdir(this.logDir, { recursive: true });
|
||||
this.initialized = true;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST" || err.code === "ENOTDIR") {
|
||||
try {
|
||||
const stats = await stat(this.logDir);
|
||||
if (stats.isDirectory()) {
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
} catch (statErr) {}
|
||||
}
|
||||
console.error(`[SYSTEM] Failed to initialize logger directory at ${this.logDir}:`, err);
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private safeStringify(data: any): string {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch (err) {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(data, (key, value) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) return "[Circular]";
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(entry: LogEntry): string {
|
||||
const dataStr = entry.data ? ` | Data: ${this.safeStringify(entry.data)}` : "";
|
||||
const stackStr = entry.stack ? `\nStack Trace:\n${entry.stack}` : "";
|
||||
return `[${entry.timestamp}] [${entry.level}] [${entry.source.toUpperCase()}] ${entry.message}${dataStr}${stackStr}`;
|
||||
}
|
||||
|
||||
private async writeToErrorLog(formatted: string) {
|
||||
await this.ensureInitialized();
|
||||
try {
|
||||
await appendFile(this.errorLogPath, formatted + "\n");
|
||||
} catch (err) {
|
||||
console.error("[SYSTEM] Failed to write to error log file:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private log(level: LogLevel, source: LogSource, message: string, errorOrData?: any) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const levelName = LogLevelNames[level];
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp,
|
||||
level: levelName,
|
||||
source,
|
||||
message,
|
||||
};
|
||||
|
||||
if (level === LogLevel.ERROR && errorOrData instanceof Error) {
|
||||
entry.stack = errorOrData.stack;
|
||||
entry.message = `${message}: ${errorOrData.message}`;
|
||||
} else if (errorOrData !== undefined) {
|
||||
entry.data = errorOrData;
|
||||
}
|
||||
|
||||
const formatted = this.formatMessage(entry);
|
||||
|
||||
// Print to console
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(formatted);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.log(formatted);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
console.warn(formatted);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
console.error(formatted);
|
||||
break;
|
||||
}
|
||||
|
||||
// Persistent error logging
|
||||
if (level === LogLevel.ERROR) {
|
||||
this.writeToErrorLog(formatted).catch(() => {
|
||||
// Silently fail to avoid infinite loops
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
debug(source: LogSource, message: string, data?: any) {
|
||||
this.log(LogLevel.DEBUG, source, message, data);
|
||||
}
|
||||
|
||||
info(source: LogSource, message: string, data?: any) {
|
||||
this.log(LogLevel.INFO, source, message, data);
|
||||
}
|
||||
|
||||
warn(source: LogSource, message: string, data?: any) {
|
||||
this.log(LogLevel.WARN, source, message, data);
|
||||
}
|
||||
|
||||
error(source: LogSource, message: string, error?: any) {
|
||||
this.log(LogLevel.ERROR, source, message, error);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { writeFile, readFile, unlink } from "fs/promises";
|
||||
import { Client, TextChannel } from "discord.js";
|
||||
import { getPostRestartEmbed, getInstallingDependenciesEmbed, getRunningMigrationsEmbed } from "@/modules/admin/update.view";
|
||||
import { getPostRestartEmbed, getPostRestartProgressEmbed, type PostRestartProgress } from "@/modules/admin/update.view";
|
||||
import type { PostRestartResult } from "@/modules/admin/update.view";
|
||||
import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "@/modules/admin/update.types";
|
||||
|
||||
@@ -70,6 +70,14 @@ export class UpdateService {
|
||||
file === "web/package.json" || file === "web/bun.lock"
|
||||
);
|
||||
|
||||
// Detect if web source files changed (requires rebuild)
|
||||
const needsWebBuild = changedFiles.some(file =>
|
||||
file.startsWith("web/src/") ||
|
||||
file === "web/build.ts" ||
|
||||
file === "web/tailwind.config.ts" ||
|
||||
file === "web/tsconfig.json"
|
||||
);
|
||||
|
||||
const needsMigrations = changedFiles.some(file =>
|
||||
file.includes("schema.ts") || file.startsWith("drizzle/")
|
||||
);
|
||||
@@ -77,6 +85,7 @@ export class UpdateService {
|
||||
return {
|
||||
needsRootInstall,
|
||||
needsWebInstall,
|
||||
needsWebBuild,
|
||||
needsMigrations,
|
||||
changedFiles
|
||||
};
|
||||
@@ -85,6 +94,7 @@ export class UpdateService {
|
||||
return {
|
||||
needsRootInstall: false,
|
||||
needsWebInstall: false,
|
||||
needsWebBuild: false,
|
||||
needsMigrations: false,
|
||||
changedFiles: [],
|
||||
error: e instanceof Error ? e : new Error(String(e))
|
||||
@@ -259,43 +269,100 @@ export class UpdateService {
|
||||
const result: PostRestartResult = {
|
||||
installSuccess: true,
|
||||
installOutput: "",
|
||||
webBuildSuccess: true,
|
||||
webBuildOutput: "",
|
||||
migrationSuccess: true,
|
||||
migrationOutput: "",
|
||||
ranInstall: context.installDependencies,
|
||||
ranWebBuild: context.buildWebAssets,
|
||||
ranMigrations: context.runMigrations,
|
||||
previousCommit: context.previousCommit,
|
||||
newCommit: context.newCommit
|
||||
};
|
||||
|
||||
// Track progress for consolidated message
|
||||
const progress: PostRestartProgress = {
|
||||
installDeps: context.installDependencies,
|
||||
buildWeb: context.buildWebAssets,
|
||||
runMigrations: context.runMigrations,
|
||||
currentStep: "starting"
|
||||
};
|
||||
|
||||
// Only send progress message if there are tasks to run
|
||||
const hasTasks = context.installDependencies || context.buildWebAssets || context.runMigrations;
|
||||
let progressMessage = hasTasks
|
||||
? await channel.send({ embeds: [getPostRestartProgressEmbed(progress)] })
|
||||
: null;
|
||||
|
||||
// Helper to update progress message
|
||||
const updateProgress = async () => {
|
||||
if (progressMessage) {
|
||||
await progressMessage.edit({ embeds: [getPostRestartProgressEmbed(progress)] });
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Install Dependencies if needed
|
||||
if (context.installDependencies) {
|
||||
try {
|
||||
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
|
||||
progress.currentStep = "install";
|
||||
await updateProgress();
|
||||
|
||||
const { stdout: rootOutput } = await execAsync("bun install");
|
||||
const { stdout: webOutput } = await execAsync("cd web && bun install");
|
||||
|
||||
result.installOutput = `📦 Root: ${rootOutput.trim() || "Done"}\n🌐 Web: ${webOutput.trim() || "Done"}`;
|
||||
progress.installDone = true;
|
||||
} catch (err: unknown) {
|
||||
result.installSuccess = false;
|
||||
result.installOutput = err instanceof Error ? err.message : String(err);
|
||||
progress.installDone = true; // Mark as done even on failure
|
||||
console.error("Dependency Install Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Run Migrations
|
||||
// 2. Build Web Assets if needed
|
||||
if (context.buildWebAssets) {
|
||||
try {
|
||||
progress.currentStep = "build";
|
||||
await updateProgress();
|
||||
|
||||
const { stdout } = await execAsync("cd web && bun run build");
|
||||
result.webBuildOutput = stdout.trim() || "Build completed successfully";
|
||||
progress.buildDone = true;
|
||||
} catch (err: unknown) {
|
||||
result.webBuildSuccess = false;
|
||||
result.webBuildOutput = err instanceof Error ? err.message : String(err);
|
||||
progress.buildDone = true;
|
||||
console.error("Web Build Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Run Migrations
|
||||
if (context.runMigrations) {
|
||||
try {
|
||||
await channel.send({ embeds: [getRunningMigrationsEmbed()] });
|
||||
progress.currentStep = "migrate";
|
||||
await updateProgress();
|
||||
|
||||
const { stdout } = await execAsync("bun x drizzle-kit migrate");
|
||||
result.migrationOutput = stdout;
|
||||
progress.migrateDone = true;
|
||||
} catch (err: unknown) {
|
||||
result.migrationSuccess = false;
|
||||
result.migrationOutput = err instanceof Error ? err.message : String(err);
|
||||
progress.migrateDone = true;
|
||||
console.error("Migration Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete progress message before final result
|
||||
if (progressMessage) {
|
||||
try {
|
||||
await progressMessage.delete();
|
||||
} catch {
|
||||
// Message may already be deleted, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,292 +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("getActivityAggregation", () => {
|
||||
test("should return exactly 24 data points representing the last 24 hours", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours(), 0, 0, 0);
|
||||
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 }
|
||||
];
|
||||
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([
|
||||
{
|
||||
hour: now.toISOString(),
|
||||
transactions: "10",
|
||||
commands: "5"
|
||||
}
|
||||
]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
// Mock sequential calls to limit()
|
||||
// First call is topLevels, second is topWealth
|
||||
mockLimit
|
||||
.mockResolvedValueOnce(mockTopLevels)
|
||||
.mockResolvedValueOnce(mockTopWealth);
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
const result = await dashboardService.getLeaderboards();
|
||||
|
||||
expect(activity).toHaveLength(24);
|
||||
|
||||
// Check if the current hour matches our mock
|
||||
const currentHourData = activity.find(a => new Date(a.hour).getTime() === now.getTime());
|
||||
expect(currentHourData).toBeDefined();
|
||||
expect(currentHourData?.transactions).toBe(10);
|
||||
expect(currentHourData?.commands).toBe(5);
|
||||
|
||||
// Check if missing hours are filled with 0
|
||||
const otherHour = activity.find(a => new Date(a.hour).getTime() !== now.getTime());
|
||||
expect(otherHour?.transactions).toBe(0);
|
||||
expect(otherHour?.commands).toBe(0);
|
||||
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 return 24 hours of zeros if database is empty", async () => {
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
test("should handle empty leaderboards", async () => {
|
||||
mockLimit.mockResolvedValue([]);
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
expect(activity).toHaveLength(24);
|
||||
expect(activity.every(a => a.transactions === 0 && a.commands === 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return 24 hours of zeros if database returns rows with null hours", async () => {
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([{ hour: null, transactions: "10", commands: "5" }]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
expect(activity).toHaveLength(24);
|
||||
expect(activity.every(a => a.transactions === 0 && a.commands === 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("should correctly map hours regardless of input sort order", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours(), 0, 0, 0);
|
||||
const hourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([
|
||||
{ hour: now.toISOString(), transactions: "10", commands: "5" },
|
||||
{ hour: hourAgo.toISOString(), transactions: "20", commands: "10" }
|
||||
]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
const current = activity.find(a => a.hour === now.toISOString());
|
||||
const past = activity.find(a => a.hour === hourAgo.toISOString());
|
||||
|
||||
expect(current?.transactions).toBe(10);
|
||||
expect(past?.transactions).toBe(20);
|
||||
const result = await dashboardService.getLeaderboards();
|
||||
expect(result.topLevels).toEqual([]);
|
||||
expect(result.topWealth).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions, moderationCases, inventory, type User } from "@db/schema";
|
||||
import { desc, sql, gte } from "drizzle-orm";
|
||||
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";
|
||||
|
||||
@@ -201,6 +201,58 @@ export const dashboardService = {
|
||||
|
||||
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() }))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ export const DashboardStatsSchema = z.object({
|
||||
bot: z.object({
|
||||
name: z.string(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
status: z.string().nullable(),
|
||||
}),
|
||||
guilds: z.object({
|
||||
count: z.number(),
|
||||
@@ -25,6 +26,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 +38,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(),
|
||||
@@ -48,11 +85,13 @@ export const ClientStatsSchema = z.object({
|
||||
bot: z.object({
|
||||
name: z.string(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
status: z.string().nullable(),
|
||||
}),
|
||||
guilds: z.number(),
|
||||
ping: z.number(),
|
||||
cachedUsers: z.number(),
|
||||
commandsRegistered: z.number(),
|
||||
commandsKnown: z.number(),
|
||||
uptime: z.number(),
|
||||
lastCommandTimestamp: z.number().nullable(),
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export const economyService = {
|
||||
});
|
||||
|
||||
if (cooldown && cooldown.expiresAt > now) {
|
||||
throw new UserError(`Daily already claimed today. Next claim <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F>`);
|
||||
throw new UserError(`You have already claimed your daily reward today.\nNext claim available: <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F> (<t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:R>)`);
|
||||
}
|
||||
|
||||
// Get user for streak logic
|
||||
@@ -196,6 +196,10 @@ export const economyService = {
|
||||
description: description,
|
||||
});
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(id, type, 1, txFn);
|
||||
|
||||
return user;
|
||||
}, tx);
|
||||
},
|
||||
|
||||
237
shared/modules/economy/exam.service.test.ts
Normal file
237
shared/modules/economy/exam.service.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
|
||||
// Chainable mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({ returning: mockReturning });
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
transaction: async (cb: any) => {
|
||||
return cb(createMockTx());
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock withTransaction
|
||||
mock.module("@/lib/db", () => ({
|
||||
withTransaction: async (cb: any, tx?: any) => {
|
||||
if (tx) return cb(tx);
|
||||
return cb({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock Config
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: {
|
||||
economy: {
|
||||
exam: {
|
||||
multMin: 1.0,
|
||||
multMax: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock User Service
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getOrCreateUser: mock()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock Dashboard Service
|
||||
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||
dashboardService: {
|
||||
recordEvent: mock()
|
||||
}
|
||||
}));
|
||||
|
||||
describe("ExamService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
});
|
||||
|
||||
describe("getExamStatus", () => {
|
||||
it("should return NOT_REGISTERED if no timer exists", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
});
|
||||
|
||||
it("should return COOLDOWN if now < expiresAt", async () => {
|
||||
const now = new Date("2024-01-10T12:00:00Z");
|
||||
setSystemTime(now);
|
||||
const future = new Date("2024-01-11T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: future,
|
||||
metadata: { examDay: 3, lastXp: "100" }
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.COOLDOWN);
|
||||
expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0));
|
||||
});
|
||||
|
||||
it("should return MISSED if it is the wrong day", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.MISSED);
|
||||
expect(status.examDay).toBe(3);
|
||||
});
|
||||
|
||||
it("should return AVAILABLE if it is the correct day", async () => {
|
||||
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "100" }
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.AVAILABLE);
|
||||
expect(status.examDay).toBe(3);
|
||||
expect(status.lastXp).toBe(100n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerForExam", () => {
|
||||
it("should create user and timer correctly", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
(userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n });
|
||||
|
||||
const result = await examService.registerForExam("1", "testuser");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
expect(result.examDay).toBe(1);
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockInsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeExam", () => {
|
||||
it("should return NOT_REGISTERED if not registered", async () => {
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check
|
||||
.mockResolvedValueOnce(undefined); // timer check
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
});
|
||||
|
||||
it("should handle missed exam and schedule for next exam day", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user
|
||||
.mockResolvedValueOnce({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday
|
||||
}); // timer
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.MISSED);
|
||||
expect(result.examDay).toBe(3);
|
||||
|
||||
// Should set next exam to next Wednesday
|
||||
// Monday (1) + 2 days = Wednesday (3)
|
||||
const expected = new Date("2024-01-17T00:00:00Z");
|
||||
expect(result.nextExamAt!.getTime()).toBe(expected.getTime());
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
|
||||
});
|
||||
|
||||
it("should calculate rewards and update state when passed", async () => {
|
||||
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user
|
||||
.mockResolvedValueOnce({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "500" }
|
||||
}); // timer
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.AVAILABLE);
|
||||
expect(result.xpDiff).toBe(500n);
|
||||
// Multiplier is between 1.0 and 2.0 based on mock config
|
||||
expect(result.multiplier).toBeGreaterThanOrEqual(1.0);
|
||||
expect(result.multiplier).toBeLessThanOrEqual(2.0);
|
||||
expect(result.reward).toBeGreaterThanOrEqual(500n);
|
||||
expect(result.reward).toBeLessThanOrEqual(1000n);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
|
||||
// Verify transaction
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amount: result.reward,
|
||||
userId: 1n,
|
||||
type: expect.anything()
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
262
shared/modules/economy/exam.service.ts
Normal file
262
shared/modules/economy/exam.service.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { TimerType, TransactionType } from "@shared/lib/constants";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
export interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
|
||||
export enum ExamStatus {
|
||||
NOT_REGISTERED = 'NOT_REGISTERED',
|
||||
COOLDOWN = 'COOLDOWN',
|
||||
MISSED = 'MISSED',
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
}
|
||||
|
||||
export interface ExamActionResult {
|
||||
status: ExamStatus;
|
||||
nextExamAt?: Date;
|
||||
reward?: bigint;
|
||||
xpDiff?: bigint;
|
||||
multiplier?: number;
|
||||
examDay?: number;
|
||||
}
|
||||
|
||||
export const examService = {
|
||||
/**
|
||||
* Get the current exam status for a user.
|
||||
*/
|
||||
async getExamStatus(userId: string, tx?: Transaction) {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const timer = await txFn.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
|
||||
if (!timer) {
|
||||
return { status: ExamStatus.NOT_REGISTERED };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
return {
|
||||
status: ExamStatus.COOLDOWN,
|
||||
nextExamAt: expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const currentDay = now.getDay();
|
||||
|
||||
if (currentDay !== metadata.examDay) {
|
||||
return {
|
||||
status: ExamStatus.MISSED,
|
||||
nextExamAt: expiresAt,
|
||||
examDay: metadata.examDay
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: ExamStatus.AVAILABLE,
|
||||
examDay: metadata.examDay,
|
||||
lastXp: BigInt(metadata.lastXp || "0")
|
||||
};
|
||||
}, tx);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a user for the first time.
|
||||
*/
|
||||
async registerForExam(userId: string, username: string, tx?: Transaction): Promise<ExamActionResult> {
|
||||
return await withTransaction(async (txFn) => {
|
||||
// Ensure user exists
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const user = await userService.getOrCreateUser(userId, username, txFn);
|
||||
if (!user) throw new Error("Failed to get or create user.");
|
||||
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
// Set next exam to next week
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await txFn.insert(userTimers).values({
|
||||
userId: BigInt(userId),
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
return {
|
||||
status: ExamStatus.NOT_REGISTERED,
|
||||
nextExamAt: nextExamDate,
|
||||
examDay: currentDay
|
||||
};
|
||||
}, tx);
|
||||
},
|
||||
|
||||
/**
|
||||
* Take the exam. Handles missed exams and reward calculations.
|
||||
*/
|
||||
async takeExam(userId: string, tx?: Transaction): Promise<ExamActionResult> {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const user = await txFn.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(userId))
|
||||
});
|
||||
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const timer = await txFn.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
|
||||
if (!timer) {
|
||||
return { status: ExamStatus.NOT_REGISTERED };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
return {
|
||||
status: ExamStatus.COOLDOWN,
|
||||
nextExamAt: expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
const currentDay = now.getDay();
|
||||
|
||||
if (currentDay !== examDay) {
|
||||
// Missed exam logic
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await txFn.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
return {
|
||||
status: ExamStatus.MISSED,
|
||||
nextExamAt: nextExamDate,
|
||||
examDay: examDay
|
||||
};
|
||||
}
|
||||
|
||||
// Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0");
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
// Use scaled BigInt arithmetic to avoid precision loss with large XP values
|
||||
const scaledMultiplier = BigInt(Math.round(multiplier * 10000));
|
||||
reward = (diff * scaledMultiplier) / 10000n;
|
||||
}
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
// Update Timer
|
||||
await txFn.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await txFn.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, BigInt(userId)));
|
||||
|
||||
// Add Transaction Record
|
||||
await txFn.insert(transactions).values({
|
||||
userId: BigInt(userId),
|
||||
amount: reward,
|
||||
type: TransactionType.EXAM_REWARD,
|
||||
description: `Weekly exam reward (XP Diff: ${diff})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`,
|
||||
icon: '🎓'
|
||||
});
|
||||
|
||||
return {
|
||||
status: ExamStatus.AVAILABLE,
|
||||
nextExamAt: nextExamDate,
|
||||
reward,
|
||||
xpDiff: diff,
|
||||
multiplier,
|
||||
examDay
|
||||
};
|
||||
}, tx);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -37,6 +37,11 @@ export const inventoryService = {
|
||||
eq(inventory.itemId, itemId)
|
||||
))
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn);
|
||||
|
||||
return entry;
|
||||
} else {
|
||||
// Check Slot Limit
|
||||
@@ -60,6 +65,11 @@ export const inventoryService = {
|
||||
quantity: quantity,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn);
|
||||
|
||||
return entry;
|
||||
}
|
||||
}, tx);
|
||||
@@ -179,6 +189,10 @@ export const inventoryService = {
|
||||
await inventoryService.removeItem(userId, itemId, 1n, txFn);
|
||||
}
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, txFn);
|
||||
|
||||
return { success: true, results, usageData, item };
|
||||
}, tx);
|
||||
},
|
||||
|
||||
@@ -68,6 +68,10 @@ export const levelingService = {
|
||||
.where(eq(users.id, BigInt(id)))
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(id, 'XP_GAIN', Number(amount), txFn);
|
||||
|
||||
return { user: updatedUser, levelUp, currentLevel: newLevel };
|
||||
}, tx);
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ mock.module("@shared/db/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
|
||||
quests: { findMany: mockFindMany },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
@@ -148,4 +149,147 @@ describe("questService", () => {
|
||||
expect(result).toEqual(mockData as any);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableQuests", () => {
|
||||
it("should return quests not yet accepted by user", async () => {
|
||||
// First call to findMany (userQuests) returns accepted quest IDs
|
||||
// Second call to findMany (quests) returns available quests
|
||||
mockFindMany
|
||||
.mockResolvedValueOnce([{ questId: 1 }]) // userQuests
|
||||
.mockResolvedValueOnce([{ id: 2, name: "New Quest" }]); // quests
|
||||
|
||||
const result = await questService.getAvailableQuests("1");
|
||||
|
||||
expect(result).toEqual([{ id: 2, name: "New Quest" }] as any);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should return all quests if user has no assigned quests", async () => {
|
||||
mockFindMany
|
||||
.mockResolvedValueOnce([]) // userQuests
|
||||
.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); // quests
|
||||
|
||||
const result = await questService.getAvailableQuests("1");
|
||||
|
||||
expect(result).toEqual([{ id: 1 }, { id: 2 }] as any);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEvent", () => {
|
||||
it("should progress a quest with sub-events", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 101,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_USE:101", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 1 }]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_USE:101", 1);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalled();
|
||||
expect(mockSet).toHaveBeenCalledWith({ progress: 1 });
|
||||
});
|
||||
|
||||
it("should complete a quest when target reached using sub-events", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 101,
|
||||
progress: 4,
|
||||
completedAt: null,
|
||||
quest: {
|
||||
triggerEvent: "ITEM_COLLECT:505",
|
||||
requirements: { target: 5 },
|
||||
rewards: { balance: 100 }
|
||||
}
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
mockFindFirst.mockResolvedValue(mockUserQuest); // For completeQuest
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT:505", 1);
|
||||
|
||||
// Verify completeQuest was called (it will update completedAt)
|
||||
expect(mockUpdate).toHaveBeenCalled();
|
||||
expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it("should progress a quest with generic events", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 102,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
mockReturning.mockResolvedValue([{ userId: 1n, questId: 102, progress: 1 }]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT:505", 1);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalled();
|
||||
expect(mockSet).toHaveBeenCalledWith({ progress: 1 });
|
||||
});
|
||||
|
||||
it("should ignore events that are not prefix matches", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 103,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT_UNRELATED", 1);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not progress a specific quest with a different specific event", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 104,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT:202", 1);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not progress a specific quest with a generic event", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 105,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT", 1);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore irrelevant events", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 101,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "DIFFERENT_EVENT", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
|
||||
await questService.handleEvent("1", "TEST_EVENT", 1);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { userQuests } from "@db/schema";
|
||||
import { userQuests, quests } from "@db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
@@ -7,6 +7,7 @@ import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
|
||||
export const questService = {
|
||||
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||
@@ -34,6 +35,40 @@ export const questService = {
|
||||
}, tx);
|
||||
},
|
||||
|
||||
handleEvent: async (userId: string, eventName: string, weight: number = 1, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
// 1. Fetch active user quests for this event
|
||||
const activeUserQuests = await txFn.query.userQuests.findMany({
|
||||
where: and(
|
||||
eq(userQuests.userId, BigInt(userId)),
|
||||
),
|
||||
with: {
|
||||
quest: true
|
||||
}
|
||||
});
|
||||
|
||||
const relevant = activeUserQuests.filter(uq => {
|
||||
const trigger = uq.quest.triggerEvent;
|
||||
// Exact match or prefix match (e.g. ITEM_COLLECT matches ITEM_COLLECT:101)
|
||||
const isMatch = eventName === trigger || eventName.startsWith(trigger + ":");
|
||||
return isMatch && !uq.completedAt;
|
||||
});
|
||||
|
||||
for (const uq of relevant) {
|
||||
const requirements = uq.quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
|
||||
const newProgress = (uq.progress || 0) + weight;
|
||||
|
||||
if (newProgress >= target) {
|
||||
await questService.completeQuest(userId, uq.questId, txFn);
|
||||
} else {
|
||||
await questService.updateProgress(userId, uq.questId, newProgress, txFn);
|
||||
}
|
||||
}
|
||||
}, tx);
|
||||
},
|
||||
|
||||
completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const userQuest = await txFn.query.userQuests.findFirst({
|
||||
@@ -73,6 +108,14 @@ export const questService = {
|
||||
results.xp = xp;
|
||||
}
|
||||
|
||||
// Emit completion event for the bot to handle notifications
|
||||
systemEvents.emit(EVENTS.QUEST.COMPLETED, {
|
||||
userId,
|
||||
questId,
|
||||
quest: userQuest.quest,
|
||||
rewards: results
|
||||
});
|
||||
|
||||
return { success: true, rewards: results };
|
||||
}, tx);
|
||||
},
|
||||
@@ -84,5 +127,75 @@ export const questService = {
|
||||
quest: true,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async getAvailableQuests(userId: string) {
|
||||
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
|
||||
where: eq(userQuests.userId, BigInt(userId)),
|
||||
columns: {
|
||||
questId: true
|
||||
}
|
||||
})).map(uq => uq.questId);
|
||||
|
||||
return await DrizzleClient.query.quests.findMany({
|
||||
where: (quests, { notInArray }) => userQuestIds.length > 0
|
||||
? notInArray(quests.id, userQuestIds)
|
||||
: undefined
|
||||
});
|
||||
},
|
||||
|
||||
async createQuest(data: {
|
||||
name: string;
|
||||
description: string;
|
||||
triggerEvent: string;
|
||||
requirements: { target: number };
|
||||
rewards: { xp: number; balance: number };
|
||||
}, tx?: Transaction) {
|
||||
return await withTransaction(async (txFn) => {
|
||||
return await txFn.insert(quests)
|
||||
.values({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
triggerEvent: data.triggerEvent,
|
||||
requirements: data.requirements,
|
||||
rewards: data.rewards,
|
||||
})
|
||||
.returning();
|
||||
}, tx);
|
||||
},
|
||||
|
||||
async getAllQuests() {
|
||||
return await DrizzleClient.query.quests.findMany({
|
||||
orderBy: (quests, { asc }) => [asc(quests.id)],
|
||||
});
|
||||
},
|
||||
|
||||
async deleteQuest(id: number, tx?: Transaction) {
|
||||
return await withTransaction(async (txFn) => {
|
||||
return await txFn.delete(quests)
|
||||
.where(eq(quests.id, id))
|
||||
.returning();
|
||||
}, tx);
|
||||
},
|
||||
|
||||
async updateQuest(id: number, data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
triggerEvent?: string;
|
||||
requirements?: { target?: number };
|
||||
rewards?: { xp?: number; balance?: number };
|
||||
}, tx?: Transaction) {
|
||||
return await withTransaction(async (txFn) => {
|
||||
return await txFn.update(quests)
|
||||
.set({
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description }),
|
||||
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
|
||||
...(data.requirements !== undefined && { requirements: data.requirements }),
|
||||
...(data.rewards !== undefined && { rewards: data.rewards }),
|
||||
})
|
||||
.where(eq(quests.id, id))
|
||||
.returning();
|
||||
}, tx);
|
||||
}
|
||||
};
|
||||
|
||||
241
shared/modules/trivia/trivia.service.test.ts
Normal file
241
shared/modules/trivia/trivia.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
313
shared/modules/trivia/trivia.service.ts
Normal file
313
shared/modules/trivia/trivia.service.ts
Normal 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();
|
||||
@@ -1,61 +0,0 @@
|
||||
# DASH-003: Visual Analytics & Activity Charts
|
||||
|
||||
**Status:** Done
|
||||
**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
|
||||
- [x] No new tables.
|
||||
- [x] Requires complex aggregation queries on the `transactions` table.
|
||||
|
||||
### API / Interface
|
||||
- [x] `GET /api/stats/activity`: Returns an array of data points for the last 24 hours (hourly granularity).
|
||||
- [x] 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. [x] **Given** a 24-hour history of transactions, **When** the dashboard loads, **Then** a line or area chart displays the command volume over time.
|
||||
2. [x] **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. [x] **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
|
||||
- [x] Step 1: Add an aggregation method to `dashboard.service.ts` to fetch hourly counts from the `transactions` table.
|
||||
- [x] Step 2: Create the `/api/stats/activity` endpoint.
|
||||
- [x] Step 3: Install a charting library (`recharts`).
|
||||
- [x] Step 4: Implement the `ActivityChart` component into the middle column of the dashboard.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented a comprehensive activity analytics system for the Aurora dashboard:
|
||||
|
||||
### Backend Changes
|
||||
- **Service Layer**: Added `getActivityAggregation` to `dashboard.service.ts`. It performs a hourly aggregation on the `transactions` table using Postgres `date_trunc` and `FILTER` clauses to differentiate between "commands" and "total transactions". Missing hours in the 24h window are automatically filled with zero-values.
|
||||
- **API**: Implemented `GET /api/stats/activity` in `web/src/server.ts` with a 5-minute in-memory cache to maintain server performance.
|
||||
|
||||
### Frontend Changes
|
||||
- **Library**: Added `recharts` for high-performance SVG charting.
|
||||
- **Hooks**: Created `use-activity-stats.ts` to manage the lifecycle and polling of analytics data.
|
||||
- **Components**: Developed `ActivityChart.tsx` featuring:
|
||||
- Premium glassmorphic styling (backdrop blur, subtle borders).
|
||||
- Responsive `AreaChart` with brand-matching gradients.
|
||||
- Custom glassmorphic tooltip with precise data point values.
|
||||
- Smooth entry animations.
|
||||
- **Integration**: Placed the new analytics card prominently in the `Dashboard.tsx` layout.
|
||||
|
||||
### Verification
|
||||
- **Unit Tests**: Added comprehensive test cases to `dashboard.service.test.ts` verifying the 24-point guaranteed response and correct data mapping.
|
||||
- **Type Safety**: Passed `bun x tsc --noEmit` with zero errors.
|
||||
- **Runtime**: All tests passing.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -13,6 +13,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
|
||||
85
web/build.ts
85
web/build.ts
@@ -135,8 +135,9 @@ const build = async () => {
|
||||
minify: true,
|
||||
target: "browser",
|
||||
sourcemap: "linked",
|
||||
publicPath: "/", // Use absolute paths for SPA routing compatibility
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
"process.env.NODE_ENV": JSON.stringify((cliConfig as any).watch ? "development" : "production"),
|
||||
},
|
||||
...cliConfig,
|
||||
});
|
||||
@@ -159,14 +160,86 @@ console.log(`\n✅ Build completed in ${buildTime}ms\n`);
|
||||
|
||||
if ((cliConfig as any).watch) {
|
||||
console.log("👀 Watching for changes...\n");
|
||||
// Keep the process alive for watch mode
|
||||
// Bun.build with watch:true handles the watching,
|
||||
// we just need to make sure the script doesn't exit.
|
||||
process.stdin.resume();
|
||||
|
||||
// Also, handle manual exit
|
||||
// Polling-based file watcher for Docker compatibility
|
||||
// Docker volumes don't propagate filesystem events (inotify) reliably
|
||||
const srcDir = path.join(process.cwd(), "src");
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
let lastMtimes = new Map<string, number>();
|
||||
let isRebuilding = false;
|
||||
|
||||
// Collect all file mtimes in src directory
|
||||
const collectMtimes = async (): Promise<Map<string, number>> => {
|
||||
const mtimes = new Map<string, number>();
|
||||
const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,css,html}");
|
||||
|
||||
for await (const file of glob.scan({ cwd: srcDir, absolute: true })) {
|
||||
try {
|
||||
const stat = await Bun.file(file).stat();
|
||||
if (stat) {
|
||||
mtimes.set(file, stat.mtime.getTime());
|
||||
}
|
||||
} catch {
|
||||
// File may have been deleted, skip
|
||||
}
|
||||
}
|
||||
return mtimes;
|
||||
};
|
||||
|
||||
// Initial collection
|
||||
lastMtimes = await collectMtimes();
|
||||
|
||||
// Polling loop
|
||||
const poll = async () => {
|
||||
if (isRebuilding) return;
|
||||
|
||||
const currentMtimes = await collectMtimes();
|
||||
const changedFiles: string[] = [];
|
||||
|
||||
// Check for new or modified files
|
||||
for (const [file, mtime] of currentMtimes) {
|
||||
const lastMtime = lastMtimes.get(file);
|
||||
if (lastMtime === undefined || lastMtime < mtime) {
|
||||
changedFiles.push(path.relative(srcDir, file));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for deleted files
|
||||
for (const file of lastMtimes.keys()) {
|
||||
if (!currentMtimes.has(file)) {
|
||||
changedFiles.push(path.relative(srcDir, file) + " (deleted)");
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.length > 0) {
|
||||
isRebuilding = true;
|
||||
console.log(`\n🔄 Changes detected:`);
|
||||
changedFiles.forEach(f => console.log(` • ${f}`));
|
||||
console.log("");
|
||||
|
||||
try {
|
||||
const rebuildStart = performance.now();
|
||||
await build();
|
||||
const rebuildEnd = performance.now();
|
||||
console.log(`\n✅ Rebuild completed in ${(rebuildEnd - rebuildStart).toFixed(2)}ms\n`);
|
||||
} catch (err) {
|
||||
console.error("❌ Rebuild failed:", err);
|
||||
}
|
||||
|
||||
lastMtimes = currentMtimes;
|
||||
isRebuilding = false;
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(poll, POLL_INTERVAL_MS);
|
||||
|
||||
// Handle manual exit
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(interval);
|
||||
console.log("\n👋 Stopping build watcher...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
41
web/bun.lock
41
web/bun.lock
@@ -5,21 +5,32 @@
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.3.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
@@ -39,6 +50,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=="],
|
||||
@@ -65,8 +78,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=="],
|
||||
@@ -79,6 +96,8 @@
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
@@ -87,6 +106,8 @@
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
@@ -95,12 +116,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=="],
|
||||
@@ -211,10 +240,14 @@
|
||||
|
||||
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.70.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw=="],
|
||||
|
||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||
|
||||
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||
@@ -241,6 +274,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -261,12 +296,16 @@
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
@@ -9,21 +9,32 @@
|
||||
"build": "bun run build.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.6.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
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 { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import { DesignSystem } from "./pages/DesignSystem";
|
||||
import { AdminQuests } from "./pages/AdminQuests";
|
||||
import { AdminOverview } from "./pages/admin/Overview";
|
||||
|
||||
import { Home } from "./pages/Home";
|
||||
import { Toaster } from "sonner";
|
||||
import { NavigationProvider } from "./contexts/navigation-context";
|
||||
import { MainLayout } from "./components/layout/main-layout";
|
||||
|
||||
import { SettingsLayout } from "./pages/settings/SettingsLayout";
|
||||
import { GeneralSettings } from "./pages/settings/General";
|
||||
import { EconomySettings } from "./pages/settings/Economy";
|
||||
import { SystemsSettings } from "./pages/settings/Systems";
|
||||
import { RolesSettings } from "./pages/settings/Roles";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<NavigationProvider>
|
||||
<Toaster richColors position="top-right" theme="dark" />
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
|
||||
<Route path="/design-system" element={<DesignSystem />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/overview" replace />} />
|
||||
<Route path="/admin/overview" element={<AdminOverview />} />
|
||||
<Route path="/admin/quests" element={<AdminQuests />} />
|
||||
|
||||
|
||||
<Route path="/settings" element={<SettingsLayout />}>
|
||||
<Route index element={<Navigate to="/settings/general" replace />} />
|
||||
<Route path="general" element={<GeneralSettings />} />
|
||||
<Route path="economy" element={<EconomySettings />} />
|
||||
<Route path="systems" element={<SystemsSettings />} />
|
||||
<Route path="roles" element={<RolesSettings />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Home />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</NavigationProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import type { ActivityData } from '../hooks/use-activity-stats';
|
||||
|
||||
interface ActivityChartProps {
|
||||
data: ActivityData[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<div className="glass p-3 rounded-lg border border-white/10 text-sm shadow-xl animate-in fade-in zoom-in duration-200">
|
||||
<p className="font-semibold text-white/90 border-b border-white/10 pb-1 mb-2">
|
||||
{data.displayTime}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center justify-between gap-4">
|
||||
<span className="text-primary font-medium">Commands</span>
|
||||
<span className="font-mono">{payload[0].value}</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-between gap-4">
|
||||
<span className="text-[var(--chart-2)] font-medium">Transactions</span>
|
||||
<span className="font-mono">{payload[1].value}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ActivityChart: React.FC<ActivityChartProps> = ({ data, loading }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full h-[300px] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground animate-pulse text-sm">Aggregating stats...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format hour for XAxis (e.g., "HH:00")
|
||||
const chartData = data.map(item => ({
|
||||
...item,
|
||||
displayTime: new Date(item.hour).getHours() + ':00'
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full h-[300px] mt-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorCommands" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-primary)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorTransactions" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--chart-2)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--chart-2)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
fontSize={10}
|
||||
tickFormatter={(str) => {
|
||||
const date = new Date(str);
|
||||
return date.getHours() % 4 === 0 ? `${date.getHours()}:00` : '';
|
||||
}}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
stroke="var(--muted-foreground)"
|
||||
minTickGap={30}
|
||||
/>
|
||||
<YAxis
|
||||
fontSize={10}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
stroke="var(--muted-foreground)"
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="commands"
|
||||
stroke="var(--color-primary)"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCommands)"
|
||||
animationDuration={1500}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="transactions"
|
||||
stroke="var(--chart-2)"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorTransactions)"
|
||||
animationDuration={1500}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
164
web/src/components/activity-chart.tsx
Normal file
164
web/src/components/activity-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
web/src/components/commands-drawer.tsx
Normal file
235
web/src/components/commands-drawer.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "./ui/sheet";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Loader2, Terminal, Sparkles, Coins, Shield, Backpack, TrendingUp, MessageSquare, User } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Command {
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface CommandsDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// Category metadata for visual styling
|
||||
const CATEGORY_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
|
||||
admin: { label: "Admin", color: "bg-red-500/20 text-red-400 border-red-500/30", icon: Shield },
|
||||
economy: { label: "Economy", color: "bg-amber-500/20 text-amber-400 border-amber-500/30", icon: Coins },
|
||||
leveling: { label: "Leveling", color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", icon: TrendingUp },
|
||||
inventory: { label: "Inventory", color: "bg-blue-500/20 text-blue-400 border-blue-500/30", icon: Backpack },
|
||||
quest: { label: "Quests", color: "bg-purple-500/20 text-purple-400 border-purple-500/30", icon: Sparkles },
|
||||
feedback: { label: "Feedback", color: "bg-cyan-500/20 text-cyan-400 border-cyan-500/30", icon: MessageSquare },
|
||||
user: { label: "User", color: "bg-pink-500/20 text-pink-400 border-pink-500/30", icon: User },
|
||||
uncategorized: { label: "Other", color: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30", icon: Terminal },
|
||||
};
|
||||
|
||||
export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) {
|
||||
const [commands, setCommands] = useState<Command[]>([]);
|
||||
const [enabledState, setEnabledState] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
// Fetch commands and their enabled state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetch("/api/settings/meta").then(res => res.json()),
|
||||
fetch("/api/settings").then(res => res.json()),
|
||||
]).then(([meta, config]) => {
|
||||
setCommands(meta.commands || []);
|
||||
// Build enabled state from config.commands (undefined = enabled by default)
|
||||
const state: Record<string, boolean> = {};
|
||||
for (const cmd of meta.commands || []) {
|
||||
state[cmd.name] = config.commands?.[cmd.name] !== false;
|
||||
}
|
||||
setEnabledState(state);
|
||||
}).catch(err => {
|
||||
toast.error("Failed to load commands", {
|
||||
description: "Unable to fetch command list. Please try again."
|
||||
});
|
||||
console.error(err);
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Group commands by category
|
||||
const groupedCommands = useMemo(() => {
|
||||
const groups: Record<string, Command[]> = {};
|
||||
for (const cmd of commands) {
|
||||
const cat = cmd.category || "uncategorized";
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(cmd);
|
||||
}
|
||||
// Sort categories: admin first, then alphabetically
|
||||
const sortedCategories = Object.keys(groups).sort((a, b) => {
|
||||
if (a === "admin") return -1;
|
||||
if (b === "admin") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return sortedCategories.map(cat => ({ category: cat, commands: groups[cat]! }));
|
||||
}, [commands]);
|
||||
|
||||
// Toggle command enabled state
|
||||
const toggleCommand = async (commandName: string, enabled: boolean) => {
|
||||
setSaving(commandName);
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
commands: {
|
||||
[commandName]: enabled,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to save");
|
||||
|
||||
setEnabledState(prev => ({ ...prev, [commandName]: enabled }));
|
||||
toast.success(`/${commandName} ${enabled ? "enabled" : "disabled"}`, {
|
||||
description: `Command has been ${enabled ? "enabled" : "disabled"} successfully.`,
|
||||
duration: 2000,
|
||||
id: "command-toggle",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to toggle command", {
|
||||
description: "Unable to update command status. Please try again."
|
||||
});
|
||||
console.error(error);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
||||
<SheetContent side="right" className="w-[800px] sm:max-w-[800px] p-0 flex flex-col gap-0 border-l border-border/50 glass-card bg-background/95 text-foreground">
|
||||
<SheetHeader className="p-6 border-b border-border/50">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
Command Manager
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Enable or disable commands. Changes take effect immediately.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 p-6 pb-8">
|
||||
{groupedCommands.map(({ category, commands: cmds }) => {
|
||||
const config = (CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.uncategorized)!;
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div key={category} className="space-y-3">
|
||||
{/* Category Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{config.label}
|
||||
</h3>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{cmds.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Commands Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{cmds.map(cmd => {
|
||||
const isEnabled = enabledState[cmd.name] !== false;
|
||||
const isSaving = saving === cmd.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cmd.name}
|
||||
className={cn(
|
||||
"group relative rounded-lg overflow-hidden transition-all duration-300",
|
||||
"bg-gradient-to-r from-card/80 to-card/40",
|
||||
"border border-border/20 hover:border-border/40",
|
||||
"hover:shadow-lg hover:shadow-primary/5",
|
||||
"hover:translate-x-1",
|
||||
!isEnabled && "opacity-40 grayscale",
|
||||
isSaving && "animate-pulse"
|
||||
)}
|
||||
>
|
||||
{/* Category color accent bar */}
|
||||
<div className={cn(
|
||||
"absolute left-0 top-0 bottom-0 w-1 transition-all duration-300",
|
||||
config.color.split(' ')[0],
|
||||
"group-hover:w-1.5"
|
||||
)} />
|
||||
|
||||
<div className="p-3 pl-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Icon with glow effect */}
|
||||
<div className={cn(
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center",
|
||||
"bg-gradient-to-br",
|
||||
config.color,
|
||||
"shadow-sm",
|
||||
isEnabled && "group-hover:shadow-md group-hover:scale-105",
|
||||
"transition-all duration-300"
|
||||
)}>
|
||||
<IconComponent className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className={cn(
|
||||
"font-mono text-sm font-semibold tracking-tight",
|
||||
"transition-colors duration-300",
|
||||
isEnabled ? "text-foreground" : "text-muted-foreground"
|
||||
)}>
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/70 uppercase tracking-wider">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => toggleCommand(cmd.name, checked)}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
"transition-opacity duration-300",
|
||||
!isEnabled && "opacity-60"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{groupedCommands.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No commands found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
48
web/src/components/feature-card.tsx
Normal file
48
web/src/components/feature-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
web/src/components/info-card.tsx
Normal file
30
web/src/components/info-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
web/src/components/layout/app-sidebar.tsx
Normal file
220
web/src/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubItem,
|
||||
SidebarMenuSubButton,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarGroupContent,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useNavigation, type NavItem } from "@/contexts/navigation-context"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useSocket } from "@/hooks/use-socket"
|
||||
|
||||
function NavItemWithSubMenu({ item }: { item: NavItem }) {
|
||||
const { state } = useSidebar()
|
||||
const isCollapsed = state === "collapsed"
|
||||
|
||||
// When collapsed, show a dropdown menu on hover/click
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
className={cn(
|
||||
"transition-all duration-200 ease-in-out font-medium py-4 min-h-10",
|
||||
item.isActive
|
||||
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="min-w-[180px] bg-background/95 backdrop-blur-xl border-border/50"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{item.title}
|
||||
</div>
|
||||
{item.subItems?.map((subItem) => (
|
||||
<DropdownMenuItem key={subItem.title} asChild className="group/dropitem">
|
||||
<Link
|
||||
to={subItem.url}
|
||||
className={cn(
|
||||
"cursor-pointer py-4 min-h-10 flex items-center gap-2",
|
||||
subItem.isActive
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-inherit"
|
||||
)}
|
||||
>
|
||||
<subItem.icon className={cn("size-4", subItem.isActive ? "text-primary" : "text-muted-foreground group-hover/dropitem:text-inherit")} />
|
||||
{subItem.title}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// When expanded, show collapsible sub-menu
|
||||
return (
|
||||
<Collapsible defaultOpen={item.isActive} className="group/collapsible">
|
||||
<SidebarMenuItem className="flex flex-col">
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
className={cn(
|
||||
"transition-all duration-200 ease-in-out font-medium py-4 min-h-10",
|
||||
item.isActive
|
||||
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
|
||||
<span className={cn("group-data-[collapsible=icon]:hidden", item.isActive && "text-primary")}>
|
||||
{item.title}
|
||||
</span>
|
||||
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
||||
<SidebarMenuSub>
|
||||
{item.subItems?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={subItem.isActive}
|
||||
className={cn(
|
||||
"transition-all duration-200 py-4 min-h-10 group/subitem",
|
||||
subItem.isActive
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-inherit"
|
||||
)}
|
||||
>
|
||||
<Link to={subItem.url}>
|
||||
<subItem.icon className={cn("size-4", subItem.isActive ? "text-primary" : "text-muted-foreground group-hover/subitem:text-inherit")} />
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItemLink({ item }: { item: NavItem }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={item.isActive}
|
||||
tooltip={item.title}
|
||||
className={cn(
|
||||
"transition-all duration-200 ease-in-out font-medium",
|
||||
item.isActive
|
||||
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Link to={item.url} className="flex items-center gap-3 py-4 min-h-10 group-data-[collapsible=icon]:justify-center">
|
||||
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
|
||||
<span className={cn("group-data-[collapsible=icon]:hidden", item.isActive && "text-primary")}>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const { navItems } = useNavigation()
|
||||
const { stats } = useSocket()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" className="border-r border-border/50 bg-background/60 backdrop-blur-xl supports-backdrop-filter:bg-background/60">
|
||||
<SidebarHeader className="pb-4 pt-4">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild className="hover:bg-primary/10 transition-colors">
|
||||
<Link to="/">
|
||||
{stats?.bot?.avatarUrl ? (
|
||||
<img
|
||||
src={stats.bot.avatarUrl}
|
||||
alt={stats.bot.name}
|
||||
className="size-10 rounded-full group-data-[collapsible=icon]:size-8 object-cover shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-square size-10 items-center justify-center rounded-full bg-aurora sun-flare shadow-lg group-data-[collapsible=icon]:size-8">
|
||||
<span className="sr-only">Aurora</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid flex-1 text-left text-sm leading-tight ml-2 group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-bold text-primary text-base">Aurora</span>
|
||||
<span className="truncate text-xs text-muted-foreground font-medium">
|
||||
{stats?.bot?.status || "Online"}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="px-2 group-data-[collapsible=icon]:px-0">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-muted-foreground/70 uppercase tracking-wider text-xs font-bold mb-2 px-2 group-data-[collapsible=icon]:hidden">
|
||||
Menu
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-2 group-data-[collapsible=icon]:items-center">
|
||||
{navItems.map((item) => (
|
||||
item.subItems ? (
|
||||
<NavItemWithSubMenu key={item.title} item={item} />
|
||||
) : (
|
||||
<NavItemLink key={item.title} item={item} />
|
||||
)
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
56
web/src/components/layout/main-layout.tsx
Normal file
56
web/src/components/layout/main-layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { AppSidebar } from "./app-sidebar"
|
||||
import { MobileNav } from "@/components/navigation/mobile-nav"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useNavigation } from "@/contexts/navigation-context"
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function MainLayout({ children }: MainLayoutProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const { breadcrumbs, currentTitle } = useNavigation()
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
{/* Header with breadcrumbs */}
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/50 bg-background/60 backdrop-blur-xl supports-backdrop-filter:bg-background/60 transition-all duration-300 ease-in-out">
|
||||
<div className="flex items-center gap-2 px-4 w-full">
|
||||
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-primary transition-colors" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4 bg-border/50" />
|
||||
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm bg-muted/30 px-3 py-1.5 rounded-full border border-border/30">
|
||||
{breadcrumbs.length === 0 ? (
|
||||
<span className="text-sm font-medium text-primary px-1">{currentTitle}</span>
|
||||
) : (
|
||||
breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.url} className="flex items-center gap-1">
|
||||
{index > 0 && (
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
)}
|
||||
{index === breadcrumbs.length - 1 ? (
|
||||
<span className="text-sm font-medium text-primary px-1">{crumb.title}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground hover:text-foreground transition-colors px-1">{crumb.title}</span>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom navigation */}
|
||||
{isMobile && <MobileNav />}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
170
web/src/components/leaderboard-card.tsx
Normal file
170
web/src/components/leaderboard-card.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
128
web/src/components/lootdrop-card.tsx
Normal file
128
web/src/components/lootdrop-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
web/src/components/navigation/mobile-nav.tsx
Normal file
41
web/src/components/navigation/mobile-nav.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { useNavigation } from "@/contexts/navigation-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function MobileNav() {
|
||||
const { navItems } = useNavigation()
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-4 left-4 right-4 z-50 rounded-2xl border border-border/40 bg-background/60 backdrop-blur-xl supports-backdrop-filter:bg-background/60 md:hidden shadow-lg shadow-black/5">
|
||||
<div className="flex h-16 items-center justify-around px-2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
to={item.url}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 rounded-xl px-4 py-2 text-xs font-medium transition-all duration-200",
|
||||
"min-w-[48px] min-h-[48px]",
|
||||
item.isActive
|
||||
? "text-primary bg-primary/10 shadow-[inset_0_2px_4px_rgba(0,0,0,0.05)]"
|
||||
: "text-muted-foreground/80 hover:text-foreground hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn(
|
||||
"size-5 transition-transform duration-200",
|
||||
item.isActive && "scale-110 fill-primary/20"
|
||||
)} />
|
||||
<span className={cn(
|
||||
"truncate max-w-[60px] text-[10px]",
|
||||
item.isActive && "font-bold"
|
||||
)}>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.isActive && (
|
||||
<span className="absolute bottom-1 h-0.5 w-4 rounded-full bg-primary/50 blur-[1px]" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
297
web/src/components/quest-form.tsx
Normal file
297
web/src/components/quest-form.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./ui/card";
|
||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Star, Coins } from "lucide-react";
|
||||
|
||||
interface QuestListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
triggerEvent: string;
|
||||
requirements: { target?: number };
|
||||
rewards: { xp?: number; balance?: number };
|
||||
}
|
||||
|
||||
const questSchema = z.object({
|
||||
name: z.string().min(3, "Name must be at least 3 characters"),
|
||||
description: z.string().optional(),
|
||||
triggerEvent: z.string().min(1, "Trigger event is required"),
|
||||
target: z.number().min(1, "Target must be at least 1"),
|
||||
xpReward: z.number().min(0).optional(),
|
||||
balanceReward: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
type QuestFormValues = z.infer<typeof questSchema>;
|
||||
|
||||
interface QuestFormProps {
|
||||
initialData?: QuestListItem;
|
||||
onUpdate?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const TRIGGER_EVENTS = [
|
||||
{ label: "XP Gain", value: "XP_GAIN" },
|
||||
{ label: "Item Collect", value: "ITEM_COLLECT" },
|
||||
{ label: "Item Use", value: "ITEM_USE" },
|
||||
{ label: "Daily Reward", value: "DAILY_REWARD" },
|
||||
{ label: "Lootbox Currency Reward", value: "LOOTBOX" },
|
||||
{ label: "Exam Reward", value: "EXAM_REWARD" },
|
||||
{ label: "Purchase", value: "PURCHASE" },
|
||||
{ label: "Transfer In", value: "TRANSFER_IN" },
|
||||
{ label: "Transfer Out", value: "TRANSFER_OUT" },
|
||||
{ label: "Trade In", value: "TRADE_IN" },
|
||||
{ label: "Trade Out", value: "TRADE_OUT" },
|
||||
{ label: "Quest Reward", value: "QUEST_REWARD" },
|
||||
{ label: "Trivia Entry", value: "TRIVIA_ENTRY" },
|
||||
{ label: "Trivia Win", value: "TRIVIA_WIN" },
|
||||
];
|
||||
|
||||
export function QuestForm({ initialData, onUpdate, onCancel }: QuestFormProps) {
|
||||
const isEditMode = initialData !== undefined;
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const form = useForm<QuestFormValues>({
|
||||
resolver: zodResolver(questSchema),
|
||||
defaultValues: {
|
||||
name: initialData?.name || "",
|
||||
description: initialData?.description || "",
|
||||
triggerEvent: initialData?.triggerEvent || "XP_GAIN",
|
||||
target: (initialData?.requirements as { target?: number })?.target || 1,
|
||||
xpReward: (initialData?.rewards as { xp?: number })?.xp || 100,
|
||||
balanceReward: (initialData?.rewards as { balance?: number })?.balance || 500,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialData) {
|
||||
form.reset({
|
||||
name: initialData.name || "",
|
||||
description: initialData.description || "",
|
||||
triggerEvent: initialData.triggerEvent || "XP_GAIN",
|
||||
target: (initialData.requirements as { target?: number })?.target || 1,
|
||||
xpReward: (initialData.rewards as { xp?: number })?.xp || 100,
|
||||
balanceReward: (initialData.rewards as { balance?: number })?.balance || 500,
|
||||
});
|
||||
}
|
||||
}, [initialData, form]);
|
||||
|
||||
const onSubmit = async (data: QuestFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const url = isEditMode ? `/api/quests/${initialData.id}` : "/api/quests";
|
||||
const method = isEditMode ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || (isEditMode ? "Failed to update quest" : "Failed to create quest"));
|
||||
}
|
||||
|
||||
toast.success(isEditMode ? "Quest updated successfully!" : "Quest created successfully!", {
|
||||
description: `${data.name} has been ${isEditMode ? "updated" : "added to the database"}.`,
|
||||
});
|
||||
form.reset({
|
||||
name: "",
|
||||
description: "",
|
||||
triggerEvent: "XP_GAIN",
|
||||
target: 1,
|
||||
xpReward: 100,
|
||||
balanceReward: 500,
|
||||
});
|
||||
onUpdate?.();
|
||||
} catch (error) {
|
||||
console.error("Submission error:", error);
|
||||
toast.error(isEditMode ? "Failed to update quest" : "Failed to create quest", {
|
||||
description: error instanceof Error ? error.message : "An unknown error occurred",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="glass-card overflow-hidden">
|
||||
<div className="h-1.5 bg-primary w-full" />
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-primary">{isEditMode ? "Edit Quest" : "Create New Quest"}</CardTitle>
|
||||
<CardDescription>
|
||||
{isEditMode ? "Update the quest configuration." : "Configure a new quest for the Aurora RPG academy."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Quest Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Collector's Journey" {...field} className="bg-background/50" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="triggerEvent"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Trigger Event</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="bg-background/50">
|
||||
<SelectValue placeholder="Select an event" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="glass-card border-border/50">
|
||||
<ScrollArea className="h-48">
|
||||
{TRIGGER_EVENTS.map((event) => (
|
||||
<SelectItem key={event.value} value={event.value}>
|
||||
{event.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Assigns a task to the student..."
|
||||
{...field}
|
||||
className="min-h-[100px] bg-background/50"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="target"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={e => field.onChange(parseInt(e.target.value))}
|
||||
className="bg-background/50"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="xpReward"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
XP Reward
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={e => field.onChange(parseInt(e.target.value))}
|
||||
className="bg-background/50"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="balanceReward"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Coins className="w-4 h-4 text-amber-500" />
|
||||
AU Reward
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={e => field.onChange(parseInt(e.target.value))}
|
||||
className="bg-background/50"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditMode ? (
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 bg-primary text-primary-foreground hover:glow-primary active-press py-6 text-lg font-bold"
|
||||
>
|
||||
{isSubmitting ? "Updating..." : "Update Quest"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1 py-6 text-lg font-bold"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-primary text-primary-foreground hover:glow-primary active-press py-6 text-lg font-bold"
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Quest"}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
288
web/src/components/quest-table.tsx
Normal file
288
web/src/components/quest-table.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
import { cn } from "../lib/utils";
|
||||
import { FileText, RefreshCw, Trash2, Pencil, Star, Coins } from "lucide-react";
|
||||
|
||||
interface QuestListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
triggerEvent: string;
|
||||
requirements: { target?: number };
|
||||
rewards: { xp?: number; balance?: number };
|
||||
}
|
||||
|
||||
interface QuestTableProps {
|
||||
quests: QuestListItem[];
|
||||
isInitialLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
onRefresh?: () => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onEdit?: (id: number) => void;
|
||||
}
|
||||
|
||||
const TRIGGER_EVENT_LABELS: Record<string, string> = {
|
||||
XP_GAIN: "XP Gain",
|
||||
ITEM_COLLECT: "Item Collect",
|
||||
ITEM_USE: "Item Use",
|
||||
DAILY_REWARD: "Daily Reward",
|
||||
LOOTBOX: "Lootbox Currency Reward",
|
||||
EXAM_REWARD: "Exam Reward",
|
||||
PURCHASE: "Purchase",
|
||||
TRANSFER_IN: "Transfer In",
|
||||
TRANSFER_OUT: "Transfer Out",
|
||||
TRADE_IN: "Trade In",
|
||||
TRADE_OUT: "Trade Out",
|
||||
QUEST_REWARD: "Quest Reward",
|
||||
TRIVIA_ENTRY: "Trivia Entry",
|
||||
TRIVIA_WIN: "Trivia Win",
|
||||
};
|
||||
|
||||
function getTriggerEventLabel(triggerEvent: string): string {
|
||||
return TRIGGER_EVENT_LABELS[triggerEvent] || triggerEvent;
|
||||
}
|
||||
|
||||
function TruncatedText({ text, maxLength = 100 }: { text: string; maxLength?: number }) {
|
||||
if (!text || text.length <= maxLength) {
|
||||
return <span>{text || "-"}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help border-b border-dashed border-muted-foreground/50">
|
||||
{text.slice(0, maxLength)}...
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md">
|
||||
<p>{text}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestTableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="grid grid-cols-8 gap-4 px-4 py-2 text-sm font-medium text-muted-foreground">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="grid grid-cols-8 gap-4 px-4 py-3 border-t border-border/50">
|
||||
<Skeleton className="h-5 w-8" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyQuestState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center animate-in fade-in duration-500">
|
||||
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
||||
<FileText className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">No quests available</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm">
|
||||
There are no quests in the database yet. Create your first quest using the form below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestTableContent({ quests, onDelete, onEdit }: { quests: QuestListItem[]; onDelete?: (id: number) => void; onEdit?: (id: number) => void }) {
|
||||
if (quests.length === 0) {
|
||||
return <EmptyQuestState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-16">
|
||||
ID
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-40">
|
||||
Name
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-64">
|
||||
Description
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-36">
|
||||
Trigger Event
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-20">
|
||||
Target
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
|
||||
XP Reward
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
|
||||
AU Reward
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-24">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quests.map((quest) => {
|
||||
const requirements = quest.requirements as { target?: number };
|
||||
const rewards = quest.rewards as { xp?: number; balance?: number };
|
||||
const target = requirements?.target || 1;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={quest.id}
|
||||
id={`quest-row-${quest.id}`}
|
||||
className="border-b border-border/30 hover:bg-muted/20 transition-colors animate-in fade-in slide-in-from-left-2 duration-300"
|
||||
>
|
||||
<td className="py-3 px-4 text-sm text-muted-foreground font-mono">
|
||||
#{quest.id}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm font-medium text-foreground">
|
||||
{quest.name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-muted-foreground">
|
||||
<TruncatedText text={quest.description || ""} maxLength={50} />
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="outline" className="text-xs border-border/50">
|
||||
{getTriggerEventLabel(quest.triggerEvent)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-foreground font-mono">
|
||||
{target}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-foreground">
|
||||
{rewards?.xp ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
<span className="font-mono">{rewards.xp}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-foreground">
|
||||
{rewards?.balance ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Coins className="w-4 h-4 text-amber-500" />
|
||||
<span className="font-mono">{rewards.balance}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onEdit?.(quest.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Edit quest"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast("Delete this quest?", {
|
||||
description: "This action cannot be undone.",
|
||||
action: {
|
||||
label: "Delete",
|
||||
onClick: () => onDelete?.(quest.id)
|
||||
},
|
||||
cancel: {
|
||||
label: "Cancel",
|
||||
onClick: () => {}
|
||||
},
|
||||
style: {
|
||||
background: "var(--destructive)",
|
||||
color: "var(--destructive-foreground)"
|
||||
},
|
||||
actionButtonStyle: {
|
||||
background: "var(--destructive)",
|
||||
color: "var(--destructive-foreground)"
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-muted/50 transition-colors text-muted-foreground hover:text-destructive"
|
||||
title="Delete quest"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh, onDelete, onEdit }: QuestTableProps) {
|
||||
const showSkeleton = isInitialLoading && quests.length === 0;
|
||||
|
||||
return (
|
||||
<Card className="glass-card overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
{showSkeleton ? (
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
Loading...
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-border/50">
|
||||
{quests.length} quest{quests.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={cn(
|
||||
"p-2 rounded-md hover:bg-muted/50 transition-colors",
|
||||
isRefreshing && "cursor-wait"
|
||||
)}
|
||||
title="Refresh quests"
|
||||
>
|
||||
<RefreshCw className={cn(
|
||||
"w-[18px] h-[18px] text-muted-foreground transition-transform",
|
||||
isRefreshing && "animate-spin"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showSkeleton ? (
|
||||
<QuestTableSkeleton />
|
||||
) : (
|
||||
<QuestTableContent quests={quests} onDelete={onDelete} onEdit={onEdit} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
98
web/src/components/recent-activity.tsx
Normal file
98
web/src/components/recent-activity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
web/src/components/section-header.tsx
Normal file
39
web/src/components/section-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
web/src/components/stat-card.tsx
Normal file
77
web/src/components/stat-card.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { type LucideIcon, ChevronRight } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
icon: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
valueClassName?: string;
|
||||
iconClassName?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
isLoading = false,
|
||||
className,
|
||||
valueClassName,
|
||||
iconClassName,
|
||||
onClick,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"glass-card border-none bg-card/40 hover-glow group transition-all duration-300",
|
||||
onClick && "cursor-pointer hover:bg-card/60 hover:scale-[1.02] active:scale-[0.98]",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative overflow-hidden">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{onClick && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-primary opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300 flex items-center gap-1">
|
||||
Manage <ChevronRight className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center ring-1 ring-primary/20">
|
||||
<Icon className={cn(
|
||||
"h-4 w-4 transition-all duration-300 text-primary",
|
||||
onClick && "group-hover:scale-110",
|
||||
iconClassName
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[60px]" />
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={cn("text-2xl font-bold", valueClassName)}>{value}</div>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card >
|
||||
);
|
||||
}
|
||||
41
web/src/components/testimonial-card.tsx
Normal file
41
web/src/components/testimonial-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
web/src/components/ui/accordion.tsx
Normal file
66
web/src/components/ui/accordion.tsx
Normal 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 }
|
||||
37
web/src/components/ui/badge.tsx
Normal file
37
web/src/components/ui/badge.tsx
Normal 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 hover-scale shadow-sm",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:opacity-80 hover-scale",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 hover-scale",
|
||||
outline: "text-foreground border-border hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
aurora: "border-transparent bg-aurora text-primary-foreground shadow-sm hover-scale",
|
||||
glass: "glass-card border-border/50 text-foreground hover:bg-accent/50 backdrop-blur-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> { }
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -9,16 +9,18 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 hover-glow active-press shadow-md",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 active-press shadow-sm",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 active-press",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 active-press shadow-sm",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 active-press",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
aurora: "bg-aurora text-primary-foreground shadow-sm hover:opacity-90 hover-glow active-press",
|
||||
glass: "glass-card border-border/50 text-foreground hover:bg-accent/50 hover-lift active-press",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
|
||||
@@ -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",
|
||||
"glass-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm transition-all",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
34
web/src/components/ui/collapsible.tsx
Normal file
34
web/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
255
web/src/components/ui/dropdown-menu.tsx
Normal file
255
web/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
165
web/src/components/ui/form.tsx
Normal file
165
web/src/components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
@@ -8,8 +8,8 @@ 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",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/20 border-input/50 h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base text-foreground shadow-xs transition-all outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm backdrop-blur-sm",
|
||||
"focus-visible:border-primary/50 focus-visible:bg-input/40 focus-visible:ring-2 focus-visible:ring-primary/20",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
|
||||
22
web/src/components/ui/label.tsx
Normal file
22
web/src/components/ui/label.tsx
Normal 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 }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user