This commit is contained in:
@@ -60,15 +60,17 @@ export class RoomManager {
|
|||||||
const room = this.rooms.get(roomId);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return { ok: false, error: "Room not found" };
|
if (!room) return { ok: false, error: "Room not found" };
|
||||||
|
|
||||||
|
// Reconnecting player: must be checked before the in-progress spectator guard.
|
||||||
|
if (preferAs !== "spectator" && room.players.includes(playerId)) {
|
||||||
|
room.spectators.delete(playerId);
|
||||||
|
return { ok: true, joinedAs: "player", started: room.status === "playing" };
|
||||||
|
}
|
||||||
|
|
||||||
if (preferAs === "spectator" || room.status !== "waiting") {
|
if (preferAs === "spectator" || room.status !== "waiting") {
|
||||||
room.spectators.add(playerId);
|
room.spectators.add(playerId);
|
||||||
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
|
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (room.players.includes(playerId)) {
|
|
||||||
return { ok: true, joinedAs: "player", started: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
const isAdmin = role === "admin";
|
const isAdmin = role === "admin";
|
||||||
|
|
||||||
|
|||||||
492
docs/api.md
492
docs/api.md
@@ -1,492 +0,0 @@
|
|||||||
# Aurora API Reference
|
|
||||||
|
|
||||||
REST API server for Aurora bot management. Base URL: `http://localhost:3000`
|
|
||||||
|
|
||||||
## Common Response Formats
|
|
||||||
|
|
||||||
**Success Responses:**
|
|
||||||
- Single resource: `{ ...resource }` or `{ success: true, resource: {...} }`
|
|
||||||
- List operations: `{ items: [...], total: number }`
|
|
||||||
- Mutations: `{ success: true, resource: {...} }`
|
|
||||||
|
|
||||||
**Error Responses:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Brief error message",
|
|
||||||
"details": "Optional detailed error information"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**HTTP Status Codes:**
|
|
||||||
| Code | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| 200 | Success |
|
|
||||||
| 201 | Created |
|
|
||||||
| 204 | No Content (successful DELETE) |
|
|
||||||
| 400 | Bad Request (validation error) |
|
|
||||||
| 404 | Not Found |
|
|
||||||
| 409 | Conflict (e.g., duplicate name) |
|
|
||||||
| 429 | Too Many Requests |
|
|
||||||
| 500 | Internal Server Error |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Health
|
|
||||||
|
|
||||||
### `GET /api/health`
|
|
||||||
Returns server health status.
|
|
||||||
|
|
||||||
**Response:** `{ "status": "ok", "timestamp": 1234567890 }`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Items
|
|
||||||
|
|
||||||
### `GET /api/items`
|
|
||||||
List all items with optional filtering.
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `search` | string | Filter by name/description |
|
|
||||||
| `type` | string | Filter by item type |
|
|
||||||
| `rarity` | string | Filter by rarity (C, R, SR, SSR) |
|
|
||||||
| `limit` | number | Max results (default: 100) |
|
|
||||||
| `offset` | number | Pagination offset |
|
|
||||||
|
|
||||||
**Response:** `{ "items": [...], "total": number }`
|
|
||||||
|
|
||||||
### `GET /api/items/:id`
|
|
||||||
Get single item by ID.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Health Potion",
|
|
||||||
"description": "Restores HP",
|
|
||||||
"type": "CONSUMABLE",
|
|
||||||
"rarity": "C",
|
|
||||||
"price": "100",
|
|
||||||
"iconUrl": "/assets/items/1.png",
|
|
||||||
"imageUrl": "/assets/items/1.png",
|
|
||||||
"usageData": { "consume": true, "effects": [] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/items`
|
|
||||||
Create new item. Supports JSON or multipart/form-data with image.
|
|
||||||
|
|
||||||
**Body (JSON):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Health Potion",
|
|
||||||
"description": "Restores HP",
|
|
||||||
"type": "CONSUMABLE",
|
|
||||||
"rarity": "C",
|
|
||||||
"price": "100",
|
|
||||||
"iconUrl": "/assets/items/placeholder.png",
|
|
||||||
"imageUrl": "/assets/items/placeholder.png",
|
|
||||||
"usageData": { "consume": true, "effects": [] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Body (Multipart):**
|
|
||||||
- `data`: JSON string with item fields
|
|
||||||
- `image`: Image file (PNG, JPEG, WebP, GIF, max 15MB)
|
|
||||||
|
|
||||||
### `PUT /api/items/:id`
|
|
||||||
Update existing item.
|
|
||||||
|
|
||||||
### `DELETE /api/items/:id`
|
|
||||||
Delete item and associated asset.
|
|
||||||
|
|
||||||
### `POST /api/items/:id/icon`
|
|
||||||
Upload/replace item image. Accepts multipart/form-data with `image` field.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Users
|
|
||||||
|
|
||||||
### `GET /api/users`
|
|
||||||
List all users with optional filtering and sorting.
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `search` | string | Filter by username (partial match) |
|
|
||||||
| `sortBy` | string | Sort field: `balance`, `level`, `xp`, `username` (default: `balance`) |
|
|
||||||
| `sortOrder` | string | Sort order: `asc`, `desc` (default: `desc`) |
|
|
||||||
| `limit` | number | Max results (default: 50) |
|
|
||||||
| `offset` | number | Pagination offset |
|
|
||||||
|
|
||||||
**Response:** `{ "users": [...], "total": number }`
|
|
||||||
|
|
||||||
### `GET /api/users/:id`
|
|
||||||
Get single user by Discord ID.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "123456789012345678",
|
|
||||||
"username": "Player1",
|
|
||||||
"balance": "1000",
|
|
||||||
"xp": "500",
|
|
||||||
"level": 5,
|
|
||||||
"dailyStreak": 3,
|
|
||||||
"isActive": true,
|
|
||||||
"classId": "1",
|
|
||||||
"class": { "id": "1", "name": "Warrior", "balance": "5000" },
|
|
||||||
"settings": {},
|
|
||||||
"createdAt": "2024-01-01T00:00:00Z",
|
|
||||||
"updatedAt": "2024-01-15T12:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `PUT /api/users/:id`
|
|
||||||
Update user fields.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"username": "NewName",
|
|
||||||
"balance": "2000",
|
|
||||||
"xp": "750",
|
|
||||||
"level": 10,
|
|
||||||
"dailyStreak": 5,
|
|
||||||
"classId": "1",
|
|
||||||
"isActive": true,
|
|
||||||
"settings": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/users/:id/inventory`
|
|
||||||
Get user's inventory with item details.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"inventory": [
|
|
||||||
{
|
|
||||||
"userId": "123456789012345678",
|
|
||||||
"itemId": 1,
|
|
||||||
"quantity": "5",
|
|
||||||
"item": { "id": 1, "name": "Health Potion", ... }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/users/:id/inventory`
|
|
||||||
Add item to user inventory.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"itemId": 1,
|
|
||||||
"quantity": "5"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `DELETE /api/users/:id/inventory/:itemId`
|
|
||||||
Remove item from user inventory. Use query param `amount` to specify quantity (default: 1).
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `amount` | number | Amount to remove (default: 1) |
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Classes
|
|
||||||
|
|
||||||
### `GET /api/classes`
|
|
||||||
List all classes.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"classes": [
|
|
||||||
{ "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/classes`
|
|
||||||
Create new class.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "2",
|
|
||||||
"name": "Mage",
|
|
||||||
"balance": "0",
|
|
||||||
"roleId": "987654321"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `PUT /api/classes/:id`
|
|
||||||
Update class.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Updated Name",
|
|
||||||
"balance": "10000",
|
|
||||||
"roleId": "111222333"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `DELETE /api/classes/:id`
|
|
||||||
Delete class.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Moderation
|
|
||||||
|
|
||||||
### `GET /api/moderation`
|
|
||||||
List moderation cases with optional filtering.
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `userId` | string | Filter by target user ID |
|
|
||||||
| `moderatorId` | string | Filter by moderator ID |
|
|
||||||
| `type` | string | Filter by case type: `warn`, `timeout`, `kick`, `ban`, `note`, `prune` |
|
|
||||||
| `active` | boolean | Filter by active status |
|
|
||||||
| `limit` | number | Max results (default: 50) |
|
|
||||||
| `offset` | number | Pagination offset |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cases": [
|
|
||||||
{
|
|
||||||
"id": "1",
|
|
||||||
"caseId": "CASE-0001",
|
|
||||||
"type": "warn",
|
|
||||||
"userId": "123456789",
|
|
||||||
"username": "User1",
|
|
||||||
"moderatorId": "987654321",
|
|
||||||
"moderatorName": "Mod1",
|
|
||||||
"reason": "Spam",
|
|
||||||
"metadata": {},
|
|
||||||
"active": true,
|
|
||||||
"createdAt": "2024-01-15T12:00:00Z",
|
|
||||||
"resolvedAt": null,
|
|
||||||
"resolvedBy": null,
|
|
||||||
"resolvedReason": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/moderation/:caseId`
|
|
||||||
Get single case by case ID (e.g., `CASE-0001`).
|
|
||||||
|
|
||||||
### `POST /api/moderation`
|
|
||||||
Create new moderation case.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "warn",
|
|
||||||
"userId": "123456789",
|
|
||||||
"username": "User1",
|
|
||||||
"moderatorId": "987654321",
|
|
||||||
"moderatorName": "Mod1",
|
|
||||||
"reason": "Rule violation",
|
|
||||||
"metadata": { "duration": "24h" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `PUT /api/moderation/:caseId/clear`
|
|
||||||
Clear/resolve a moderation case.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"clearedBy": "987654321",
|
|
||||||
"clearedByName": "Mod1",
|
|
||||||
"reason": "Appeal accepted"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Transactions
|
|
||||||
|
|
||||||
### `GET /api/transactions`
|
|
||||||
List economy transactions.
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `userId` | string | Filter by user ID |
|
|
||||||
| `type` | string | Filter by transaction type |
|
|
||||||
| `limit` | number | Max results (default: 50) |
|
|
||||||
| `offset` | number | Pagination offset |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"transactions": [
|
|
||||||
{
|
|
||||||
"id": "1",
|
|
||||||
"userId": "123456789",
|
|
||||||
"relatedUserId": null,
|
|
||||||
"amount": "100",
|
|
||||||
"type": "DAILY_REWARD",
|
|
||||||
"description": "Daily reward (Streak: 3)",
|
|
||||||
"createdAt": "2024-01-15T12:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Transaction Types:**
|
|
||||||
- `DAILY_REWARD` - Daily claim reward
|
|
||||||
- `TRANSFER_IN` - Received from another user
|
|
||||||
- `TRANSFER_OUT` - Sent to another user
|
|
||||||
- `LOOTDROP_CLAIM` - Claimed lootdrop
|
|
||||||
- `SHOP_BUY` - Item purchase
|
|
||||||
- `QUEST_REWARD` - Quest completion reward
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lootdrops
|
|
||||||
|
|
||||||
### `GET /api/lootdrops`
|
|
||||||
List lootdrops (default limit 50, sorted by newest).
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `limit` | number | Max results (default: 50) |
|
|
||||||
|
|
||||||
**Response:** `{ "lootdrops": [...] }`
|
|
||||||
|
|
||||||
### `POST /api/lootdrops`
|
|
||||||
Spawn a lootdrop in a channel.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channelId": "1234567890",
|
|
||||||
"amount": 100,
|
|
||||||
"currency": "Gold"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `DELETE /api/lootdrops/:messageId`
|
|
||||||
Cancel and delete a lootdrop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quests
|
|
||||||
|
|
||||||
### `GET /api/quests`
|
|
||||||
List all quests.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Daily Login",
|
|
||||||
"description": "Login once",
|
|
||||||
"triggerEvent": "login",
|
|
||||||
"requirements": { "target": 1 },
|
|
||||||
"rewards": { "xp": 50, "balance": 100 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/quests`
|
|
||||||
Create new quest.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Daily Login",
|
|
||||||
"description": "Login once",
|
|
||||||
"triggerEvent": "login",
|
|
||||||
"target": 1,
|
|
||||||
"xpReward": 50,
|
|
||||||
"balanceReward": 100
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `PUT /api/quests/:id`
|
|
||||||
Update quest.
|
|
||||||
|
|
||||||
### `DELETE /api/quests/:id`
|
|
||||||
Delete quest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings
|
|
||||||
|
|
||||||
### `GET /api/settings`
|
|
||||||
Get current bot configuration.
|
|
||||||
|
|
||||||
### `POST /api/settings`
|
|
||||||
Update configuration (partial merge supported).
|
|
||||||
|
|
||||||
### `GET /api/settings/meta`
|
|
||||||
Get Discord metadata (roles, channels, commands).
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"roles": [{ "id": "123", "name": "Admin", "color": "#FF0000" }],
|
|
||||||
"channels": [{ "id": "456", "name": "general", "type": 0 }],
|
|
||||||
"commands": [{ "name": "daily", "category": "economy" }]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin Actions
|
|
||||||
|
|
||||||
### `POST /api/actions/reload-commands`
|
|
||||||
Reload bot slash commands.
|
|
||||||
|
|
||||||
### `POST /api/actions/clear-cache`
|
|
||||||
Clear internal caches.
|
|
||||||
|
|
||||||
### `POST /api/actions/maintenance-mode`
|
|
||||||
Toggle maintenance mode.
|
|
||||||
|
|
||||||
**Body:** `{ "enabled": true, "reason": "Updating..." }`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stats
|
|
||||||
|
|
||||||
### `GET /api/stats`
|
|
||||||
Get full dashboard statistics.
|
|
||||||
|
|
||||||
### `GET /api/stats/activity`
|
|
||||||
Get activity aggregation (cached 5 min).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
### `GET /assets/items/:filename`
|
|
||||||
Serve item images. Cached 24 hours.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## WebSocket
|
|
||||||
|
|
||||||
### `ws://localhost:3000/ws`
|
|
||||||
Real-time dashboard updates.
|
|
||||||
|
|
||||||
**Messages:**
|
|
||||||
- `STATS_UPDATE` - Periodic stats broadcast (every 5s when clients connected)
|
|
||||||
- `NEW_EVENT` - Real-time system events
|
|
||||||
- `PING/PONG` - Heartbeat
|
|
||||||
|
|
||||||
**Limits:** Max 10 concurrent connections, 16KB max payload, 60s idle timeout.
|
|
||||||
@@ -1,769 +0,0 @@
|
|||||||
# Aurora Admin Panel - Design Guidelines
|
|
||||||
|
|
||||||
## Design Philosophy
|
|
||||||
|
|
||||||
The Aurora Admin Panel embodies the intersection of celestial mystique and institutional precision. It is a command center for academy administration—powerful, sophisticated, and unmistakably authoritative. Every interface element should communicate control, clarity, and prestige.
|
|
||||||
|
|
||||||
**Core Principles:**
|
|
||||||
- **Authority over Friendliness**: This is an administrative tool, not a consumer app
|
|
||||||
- **Data Clarity**: Information density balanced with elegant presentation
|
|
||||||
- **Celestial Aesthetic**: Subtle cosmic theming that doesn't compromise functionality
|
|
||||||
- **Institutional Grade**: Professional, trustworthy, built to manage complex systems
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Visual Foundation
|
|
||||||
|
|
||||||
### Color System
|
|
||||||
|
|
||||||
**Background Hierarchy**
|
|
||||||
```
|
|
||||||
Level 0 (Base) #0A0A0F Eclipse Void - Deepest background
|
|
||||||
Level 1 (Container) #151520 Midnight Canvas - Cards, panels, modals
|
|
||||||
Level 2 (Surface) #1E1B4B Nebula Surface - Elevated elements
|
|
||||||
Level 3 (Raised) #2D2A5F Stellar Overlay - Hover states, dropdowns
|
|
||||||
```
|
|
||||||
|
|
||||||
**Text Hierarchy**
|
|
||||||
```
|
|
||||||
Primary Text #F9FAFB Starlight White - Headings, key data
|
|
||||||
Secondary Text #E5E7EB Stardust Silver - Body text, labels
|
|
||||||
Tertiary Text #9CA3AF Cosmic Gray - Helper text, timestamps
|
|
||||||
Disabled Text #6B7280 Void Gray - Inactive elements
|
|
||||||
```
|
|
||||||
|
|
||||||
**Brand Accents**
|
|
||||||
```
|
|
||||||
Primary (Action) #8B5CF6 Aurora Purple - Primary buttons, links, active states
|
|
||||||
Secondary (Info) #3B82F6 Nebula Blue - Informational elements
|
|
||||||
Success #10B981 Emerald - Confirmations, positive indicators
|
|
||||||
Warning #F59E0B Amber - Cautions, alerts
|
|
||||||
Danger #DC2626 Crimson - Errors, destructive actions
|
|
||||||
Gold (Prestige) #FCD34D Celestial Gold - Premium features, highlights
|
|
||||||
```
|
|
||||||
|
|
||||||
**Constellation Tier Colors** (for data visualization)
|
|
||||||
```
|
|
||||||
Constellation A #FCD34D Celestial Gold
|
|
||||||
Constellation B #8B5CF6 Aurora Purple
|
|
||||||
Constellation C #3B82F6 Nebula Blue
|
|
||||||
Constellation D #6B7280 Slate Gray
|
|
||||||
```
|
|
||||||
|
|
||||||
**Semantic Colors**
|
|
||||||
```
|
|
||||||
Currency (AU) #FCD34D Gold - Astral Units indicators
|
|
||||||
Currency (CU) #8B5CF6 Purple - Constellation Units indicators
|
|
||||||
XP/Progress #3B82F6 Blue - Experience and progression
|
|
||||||
Activity #10B981 Green - Active users, live events
|
|
||||||
```
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
|
|
||||||
**Font Stack**
|
|
||||||
|
|
||||||
Primary (UI Text):
|
|
||||||
```css
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
```
|
|
||||||
- Clean, highly legible, modern
|
|
||||||
- Excellent at small sizes for data-dense interfaces
|
|
||||||
- Professional without being sterile
|
|
||||||
|
|
||||||
Display (Headings):
|
|
||||||
```css
|
|
||||||
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
|
||||||
```
|
|
||||||
- Geometric, slightly futuristic
|
|
||||||
- Use for page titles, section headers
|
|
||||||
- Reinforces celestial/institutional theme
|
|
||||||
|
|
||||||
Monospace (Data):
|
|
||||||
```css
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
|
||||||
```
|
|
||||||
- For numerical data, timestamps, IDs
|
|
||||||
- Improves scanability of tabular data
|
|
||||||
- Technical credibility
|
|
||||||
|
|
||||||
**Type Scale**
|
|
||||||
```
|
|
||||||
Display Large 48px / 3rem font-weight: 700 (Dashboard headers)
|
|
||||||
Display 36px / 2.25rem font-weight: 700 (Page titles)
|
|
||||||
Heading 1 30px / 1.875rem font-weight: 600 (Section titles)
|
|
||||||
Heading 2 24px / 1.5rem font-weight: 600 (Card headers)
|
|
||||||
Heading 3 20px / 1.25rem font-weight: 600 (Subsections)
|
|
||||||
Body Large 16px / 1rem font-weight: 400 (Emphasized body)
|
|
||||||
Body 14px / 0.875rem font-weight: 400 (Default text)
|
|
||||||
Body Small 13px / 0.8125rem font-weight: 400 (Secondary info)
|
|
||||||
Caption 12px / 0.75rem font-weight: 400 (Labels, hints)
|
|
||||||
Overline 11px / 0.6875rem font-weight: 600 (Uppercase labels)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Font Weight Usage**
|
|
||||||
- **700 (Bold)**: Display text, critical metrics
|
|
||||||
- **600 (Semibold)**: Headings, emphasized data
|
|
||||||
- **500 (Medium)**: Buttons, active tabs, selected items
|
|
||||||
- **400 (Regular)**: Body text, form inputs
|
|
||||||
- **Never use weights below 400** - maintain readability
|
|
||||||
|
|
||||||
### Spacing & Layout
|
|
||||||
|
|
||||||
**Base Unit**: 4px
|
|
||||||
|
|
||||||
**Spacing Scale**
|
|
||||||
```
|
|
||||||
xs 4px 0.25rem Tight spacing, icon gaps
|
|
||||||
sm 8px 0.5rem Form element spacing
|
|
||||||
md 16px 1rem Default component spacing
|
|
||||||
lg 24px 1.5rem Section spacing
|
|
||||||
xl 32px 2rem Major section breaks
|
|
||||||
2xl 48px 3rem Page section dividers
|
|
||||||
3xl 64px 4rem Major layout divisions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Container Widths**
|
|
||||||
```
|
|
||||||
Full Bleed 100% Full viewport width
|
|
||||||
Wide 1600px Wide dashboards, data tables
|
|
||||||
Standard 1280px Default content width
|
|
||||||
Narrow 960px Forms, focused content
|
|
||||||
Reading 720px Long-form text (documentation)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Grid System**
|
|
||||||
- 12-column grid for flexible layouts
|
|
||||||
- 24px gutters between columns
|
|
||||||
- Responsive breakpoints: 640px, 768px, 1024px, 1280px, 1536px
|
|
||||||
|
|
||||||
### Borders & Dividers
|
|
||||||
|
|
||||||
**Border Widths**
|
|
||||||
```
|
|
||||||
Hairline 0.5px Subtle dividers
|
|
||||||
Thin 1px Default borders
|
|
||||||
Medium 2px Emphasized borders, focus states
|
|
||||||
Thick 4px Accent bars, category indicators
|
|
||||||
```
|
|
||||||
|
|
||||||
**Border Colors**
|
|
||||||
```
|
|
||||||
Default #2D2A5F 15% opacity - Standard dividers
|
|
||||||
Subtle #2D2A5F 8% opacity - Very light separation
|
|
||||||
Emphasized #8B5CF6 30% opacity - Highlighted borders
|
|
||||||
Interactive #8B5CF6 60% opacity - Hover/focus states
|
|
||||||
```
|
|
||||||
|
|
||||||
**Border Radius**
|
|
||||||
```
|
|
||||||
None 0px Data tables, strict layouts
|
|
||||||
sm 4px Buttons, badges, pills
|
|
||||||
md 8px Cards, inputs, panels
|
|
||||||
lg 12px Large cards, modals
|
|
||||||
xl 16px Feature cards, images
|
|
||||||
2xl 24px Hero elements
|
|
||||||
full 9999px Circular elements, avatars
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI Patterns
|
|
||||||
|
|
||||||
### Cards & Containers
|
|
||||||
|
|
||||||
**Standard Card**
|
|
||||||
```
|
|
||||||
Background: #151520 (Midnight Canvas)
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
Border Radius: 8px
|
|
||||||
Padding: 24px
|
|
||||||
Shadow: 0 4px 16px rgba(0, 0, 0, 0.4)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Elevated Card** (hover/focus)
|
|
||||||
```
|
|
||||||
Background: #1E1B4B (Nebula Surface)
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.3)
|
|
||||||
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6)
|
|
||||||
Transform: translateY(-2px)
|
|
||||||
Transition: all 200ms ease
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stat Card** (metrics, KPIs)
|
|
||||||
```
|
|
||||||
Background: Linear gradient from #151520 to #1E1B4B
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.2)
|
|
||||||
Accent Border: 4px left border in tier/category color
|
|
||||||
Icon: Celestial icon in accent color
|
|
||||||
Typography: Large number (Display), small label (Overline)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Tables
|
|
||||||
|
|
||||||
**Table Structure**
|
|
||||||
```
|
|
||||||
Header Background: #1E1B4B
|
|
||||||
Header Text: #E5E7EB, 11px uppercase, 600 weight
|
|
||||||
Row Background: Alternating #0A0A0F / #151520
|
|
||||||
Row Hover: #2D2A5F with 40% opacity
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.1) between rows
|
|
||||||
Cell Padding: 12px 16px
|
|
||||||
```
|
|
||||||
|
|
||||||
**Column Styling**
|
|
||||||
- Left-align text columns
|
|
||||||
- Right-align numerical columns
|
|
||||||
- Monospace font for numbers, IDs, timestamps
|
|
||||||
- Icon + text combinations for status indicators
|
|
||||||
|
|
||||||
**Interactive Elements**
|
|
||||||
- Sortable headers with subtle arrow icons
|
|
||||||
- Hover state on entire row
|
|
||||||
- Click/select highlight with Aurora Purple tint
|
|
||||||
- Pagination in Nebula Blue
|
|
||||||
|
|
||||||
### Forms & Inputs
|
|
||||||
|
|
||||||
**Input Fields**
|
|
||||||
```
|
|
||||||
Background: #1E1B4B
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.2)
|
|
||||||
Border Radius: 6px
|
|
||||||
Padding: 10px 14px
|
|
||||||
Font Size: 14px
|
|
||||||
Text Color: #F9FAFB
|
|
||||||
|
|
||||||
Focus State:
|
|
||||||
Border: 2px solid #8B5CF6
|
|
||||||
Glow: 0 0 0 3px rgba(139, 92, 246, 0.2)
|
|
||||||
|
|
||||||
Error State:
|
|
||||||
Border: 1px solid #DC2626
|
|
||||||
Text: #DC2626 helper text below
|
|
||||||
|
|
||||||
Disabled State:
|
|
||||||
Background: #0A0A0F
|
|
||||||
Text: #6B7280
|
|
||||||
Cursor: not-allowed
|
|
||||||
```
|
|
||||||
|
|
||||||
**Labels**
|
|
||||||
```
|
|
||||||
Font Size: 12px
|
|
||||||
Font Weight: 600
|
|
||||||
Text Color: #E5E7EB
|
|
||||||
Margin Bottom: 6px
|
|
||||||
```
|
|
||||||
|
|
||||||
**Select Dropdowns**
|
|
||||||
```
|
|
||||||
Same base styling as inputs
|
|
||||||
Dropdown Icon: Chevron in #9CA3AF
|
|
||||||
Menu Background: #2D2A5F
|
|
||||||
Menu Border: 1px solid rgba(139, 92, 246, 0.3)
|
|
||||||
Option Hover: #3B82F6 background
|
|
||||||
Selected: #8B5CF6 with checkmark icon
|
|
||||||
```
|
|
||||||
|
|
||||||
**Checkboxes & Radio Buttons**
|
|
||||||
```
|
|
||||||
Size: 18px × 18px
|
|
||||||
Border: 2px solid rgba(139, 92, 246, 0.4)
|
|
||||||
Border Radius: 4px (checkbox) / 50% (radio)
|
|
||||||
Checked: #8B5CF6 background with white checkmark
|
|
||||||
Hover: Glow effect rgba(139, 92, 246, 0.2)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
|
|
||||||
**Primary Button**
|
|
||||||
```
|
|
||||||
Background: #8B5CF6 (Aurora Purple)
|
|
||||||
Text: #FFFFFF
|
|
||||||
Padding: 10px 20px
|
|
||||||
Border Radius: 6px
|
|
||||||
Font Weight: 500
|
|
||||||
Shadow: 0 2px 8px rgba(139, 92, 246, 0.3)
|
|
||||||
|
|
||||||
Hover:
|
|
||||||
Background: #7C3AED (lighter purple)
|
|
||||||
Shadow: 0 4px 12px rgba(139, 92, 246, 0.4)
|
|
||||||
|
|
||||||
Active:
|
|
||||||
Background: #6D28D9 (darker purple)
|
|
||||||
Transform: scale(0.98)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Secondary Button**
|
|
||||||
```
|
|
||||||
Background: transparent
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.5)
|
|
||||||
Text: #8B5CF6
|
|
||||||
Padding: 10px 20px
|
|
||||||
|
|
||||||
Hover:
|
|
||||||
Background: rgba(139, 92, 246, 0.1)
|
|
||||||
Border: 1px solid #8B5CF6
|
|
||||||
```
|
|
||||||
|
|
||||||
**Destructive Button**
|
|
||||||
```
|
|
||||||
Background: #DC2626
|
|
||||||
Text: #FFFFFF
|
|
||||||
(Same structure as Primary)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ghost Button**
|
|
||||||
```
|
|
||||||
Background: transparent
|
|
||||||
Text: #E5E7EB
|
|
||||||
Padding: 8px 16px
|
|
||||||
|
|
||||||
Hover:
|
|
||||||
Background: rgba(139, 92, 246, 0.1)
|
|
||||||
Text: #8B5CF6
|
|
||||||
```
|
|
||||||
|
|
||||||
**Button Sizes**
|
|
||||||
```
|
|
||||||
Small 8px 12px 12px text
|
|
||||||
Medium 10px 20px 14px text (default)
|
|
||||||
Large 12px 24px 16px text
|
|
||||||
```
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
**Sidebar Navigation**
|
|
||||||
```
|
|
||||||
Background: #0A0A0F with subtle gradient
|
|
||||||
Width: 260px (expanded) / 64px (collapsed)
|
|
||||||
Border Right: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
|
|
||||||
Nav Item:
|
|
||||||
Padding: 12px 16px
|
|
||||||
Border Radius: 6px
|
|
||||||
Font Size: 14px
|
|
||||||
Font Weight: 500
|
|
||||||
Icon Size: 20px
|
|
||||||
Gap: 12px between icon and text
|
|
||||||
|
|
||||||
Active State:
|
|
||||||
Background: rgba(139, 92, 246, 0.15)
|
|
||||||
Border Left: 4px solid #8B5CF6
|
|
||||||
Text: #8B5CF6
|
|
||||||
Icon: #8B5CF6
|
|
||||||
|
|
||||||
Hover State:
|
|
||||||
Background: rgba(139, 92, 246, 0.08)
|
|
||||||
Text: #F9FAFB
|
|
||||||
```
|
|
||||||
|
|
||||||
**Top Bar / Header**
|
|
||||||
```
|
|
||||||
Background: #0A0A0F with backdrop blur
|
|
||||||
Height: 64px
|
|
||||||
Border Bottom: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
Position: Sticky
|
|
||||||
Z-index: 100
|
|
||||||
|
|
||||||
Contains:
|
|
||||||
- Logo / Academy name
|
|
||||||
- Global search
|
|
||||||
- Quick actions
|
|
||||||
- User profile dropdown
|
|
||||||
- Notification bell
|
|
||||||
```
|
|
||||||
|
|
||||||
**Breadcrumbs**
|
|
||||||
```
|
|
||||||
Font Size: 13px
|
|
||||||
Text Color: #9CA3AF
|
|
||||||
Separator: "/" or "›" in #6B7280
|
|
||||||
Current Page: #F9FAFB, 600 weight
|
|
||||||
Links: #9CA3AF, hover to #8B5CF6
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modals & Overlays
|
|
||||||
|
|
||||||
**Modal Structure**
|
|
||||||
```
|
|
||||||
Backdrop: rgba(0, 0, 0, 0.8) with backdrop blur
|
|
||||||
Modal Container: #151520
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.2)
|
|
||||||
Border Radius: 12px
|
|
||||||
Shadow: 0 24px 48px rgba(0, 0, 0, 0.9)
|
|
||||||
Max Width: 600px (standard) / 900px (wide)
|
|
||||||
Padding: 32px
|
|
||||||
|
|
||||||
Header:
|
|
||||||
Border Bottom: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
Padding: 0 0 20px 0
|
|
||||||
Font Size: 24px
|
|
||||||
Font Weight: 600
|
|
||||||
|
|
||||||
Footer:
|
|
||||||
Border Top: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
Padding: 20px 0 0 0
|
|
||||||
Buttons: Right-aligned, 12px gap
|
|
||||||
```
|
|
||||||
|
|
||||||
**Toast Notifications**
|
|
||||||
```
|
|
||||||
Position: Top-right, 24px margin
|
|
||||||
Background: #2D2A5F
|
|
||||||
Border: 1px solid (color based on type)
|
|
||||||
Border Radius: 8px
|
|
||||||
Padding: 16px 20px
|
|
||||||
Max Width: 400px
|
|
||||||
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6)
|
|
||||||
|
|
||||||
Success: #10B981 border, green icon
|
|
||||||
Warning: #F59E0B border, amber icon
|
|
||||||
Error: #DC2626 border, red icon
|
|
||||||
Info: #3B82F6 border, blue icon
|
|
||||||
|
|
||||||
Animation: Slide in from right, fade out
|
|
||||||
Duration: 4 seconds (dismissible)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Visualization
|
|
||||||
|
|
||||||
**Charts & Graphs**
|
|
||||||
```
|
|
||||||
Background: #151520 or transparent
|
|
||||||
Grid Lines: rgba(139, 92, 246, 0.1)
|
|
||||||
Axis Labels: #9CA3AF, 12px
|
|
||||||
Data Points: Constellation tier colors or semantic colors
|
|
||||||
Tooltips: #2D2A5F background, white text
|
|
||||||
Legend: Horizontal, 12px, icons + labels
|
|
||||||
```
|
|
||||||
|
|
||||||
**Progress Bars**
|
|
||||||
```
|
|
||||||
Track: #1E1B4B
|
|
||||||
Fill: Linear gradient with tier/category color
|
|
||||||
Height: 8px (thin) / 12px (medium) / 16px (thick)
|
|
||||||
Border Radius: 9999px
|
|
||||||
Label: Above or inline, monospace numbers
|
|
||||||
```
|
|
||||||
|
|
||||||
**Badges & Pills**
|
|
||||||
```
|
|
||||||
Background: Semantic color with 15% opacity
|
|
||||||
Text: Semantic color (full saturation)
|
|
||||||
Border: 1px solid semantic color with 30% opacity
|
|
||||||
Padding: 4px 10px
|
|
||||||
Border Radius: 9999px
|
|
||||||
Font Size: 12px
|
|
||||||
Font Weight: 500
|
|
||||||
|
|
||||||
Status Examples:
|
|
||||||
Active: Green
|
|
||||||
Pending: Amber
|
|
||||||
Inactive: Gray
|
|
||||||
Error: Red
|
|
||||||
Premium: Gold
|
|
||||||
```
|
|
||||||
|
|
||||||
### Icons
|
|
||||||
|
|
||||||
**Icon System**
|
|
||||||
- Use consistent icon family (e.g., Lucide, Heroicons, Phosphor)
|
|
||||||
- Line-style icons, not filled (except for active states)
|
|
||||||
- Stroke width: 1.5px-2px
|
|
||||||
- Sizes: 16px (small), 20px (default), 24px (large), 32px (extra large)
|
|
||||||
|
|
||||||
**Icon Colors**
|
|
||||||
- Default: #9CA3AF (Cosmic Gray)
|
|
||||||
- Active/Selected: #8B5CF6 (Aurora Purple)
|
|
||||||
- Success: #10B981
|
|
||||||
- Warning: #F59E0B
|
|
||||||
- Error: #DC2626
|
|
||||||
|
|
||||||
**Celestial Icon Themes**
|
|
||||||
- Stars, constellations, orbits for branding
|
|
||||||
- Minimalist, geometric line art
|
|
||||||
- Avoid overly detailed or realistic astronomy images
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Animation & Motion
|
|
||||||
|
|
||||||
### Principles
|
|
||||||
- **Purposeful**: Animations guide attention and provide feedback
|
|
||||||
- **Subtle**: No distracting or excessive motion
|
|
||||||
- **Fast**: Snappy interactions (150-300ms)
|
|
||||||
- **Professional**: Ease curves that feel polished
|
|
||||||
|
|
||||||
### Timing Functions
|
|
||||||
```
|
|
||||||
ease-out Default for most interactions
|
|
||||||
ease-in-out Modal/panel transitions
|
|
||||||
ease-in Exit animations
|
|
||||||
spring Micro-interactions (subtle bounce)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Standard Durations
|
|
||||||
```
|
|
||||||
Instant 0ms State changes
|
|
||||||
Fast 150ms Button hover, color changes
|
|
||||||
Standard 200ms Card hover, dropdown open
|
|
||||||
Moderate 300ms Modal open, page transitions
|
|
||||||
Slow 500ms Large panel animations
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Animations
|
|
||||||
|
|
||||||
**Hover Effects**
|
|
||||||
```css
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: [enhanced shadow];
|
|
||||||
```
|
|
||||||
|
|
||||||
**Focus States**
|
|
||||||
```css
|
|
||||||
transition: border 150ms ease-out, box-shadow 150ms ease-out;
|
|
||||||
border-color: #8B5CF6;
|
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Loading States**
|
|
||||||
```
|
|
||||||
Skeleton: Shimmer effect from left to right
|
|
||||||
Spinner: Rotating celestial icon or ring
|
|
||||||
Progress: Smooth bar fill with easing
|
|
||||||
```
|
|
||||||
|
|
||||||
**Page Transitions**
|
|
||||||
```
|
|
||||||
Fade in: Opacity 0 → 1 over 200ms
|
|
||||||
Slide up: TranslateY(20px) → 0 over 300ms
|
|
||||||
Blur fade: Blur + opacity for backdrop
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Responsive Behavior
|
|
||||||
|
|
||||||
### Breakpoints
|
|
||||||
```
|
|
||||||
Mobile < 640px
|
|
||||||
Tablet 640px - 1024px
|
|
||||||
Desktop > 1024px
|
|
||||||
Wide Desktop > 1536px
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mobile Adaptations
|
|
||||||
- Sidebar collapses to hamburger menu
|
|
||||||
- Cards stack vertically
|
|
||||||
- Tables become horizontally scrollable or convert to card view
|
|
||||||
- Reduce padding and spacing by 25-50%
|
|
||||||
- Larger touch targets (minimum 44px)
|
|
||||||
- Bottom navigation for primary actions
|
|
||||||
|
|
||||||
### Tablet Optimizations
|
|
||||||
- Hybrid layouts (sidebar can be toggled)
|
|
||||||
- Adaptive grid (4 columns → 2 columns)
|
|
||||||
- Touch-friendly sizing maintained
|
|
||||||
- Utilize available space efficiently
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
### Color Contrast
|
|
||||||
- Maintain WCAG AA standards minimum (4.5:1 for normal text)
|
|
||||||
- Critical actions and text meet AAA standards (7:1)
|
|
||||||
- Never rely on color alone for information
|
|
||||||
|
|
||||||
### Focus Indicators
|
|
||||||
- Always visible focus states
|
|
||||||
- 2px Aurora Purple outline with 3px glow
|
|
||||||
- Logical tab order follows visual hierarchy
|
|
||||||
|
|
||||||
### Screen Readers
|
|
||||||
- Semantic HTML structure
|
|
||||||
- ARIA labels for icon-only buttons
|
|
||||||
- Status messages announced appropriately
|
|
||||||
- Table headers properly associated
|
|
||||||
|
|
||||||
### Keyboard Navigation
|
|
||||||
- All interactive elements accessible via keyboard
|
|
||||||
- Modal traps focus within itself
|
|
||||||
- Escape key closes overlays
|
|
||||||
- Arrow keys for navigation where appropriate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dark Mode Philosophy
|
|
||||||
|
|
||||||
**Aurora Admin is dark-first by design.** The interface assumes a dark environment and doesn't offer a light mode toggle. This decision is intentional:
|
|
||||||
|
|
||||||
- **Focus**: Dark reduces eye strain during extended admin sessions
|
|
||||||
- **Data Emphasis**: Light text on dark makes numbers/data more prominent
|
|
||||||
- **Celestial Theme**: Dark backgrounds reinforce the cosmic aesthetic
|
|
||||||
- **Professional**: Dark UIs feel more serious and technical
|
|
||||||
|
|
||||||
If light mode is ever required, avoid pure white—use off-white (#F9FAFB) backgrounds with careful contrast management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Theming & Customization
|
|
||||||
|
|
||||||
### Constellation Tier Theming
|
|
||||||
When displaying constellation-specific data:
|
|
||||||
- Use tier colors for accents, not backgrounds
|
|
||||||
- Apply colors to borders, icons, badges
|
|
||||||
- Maintain readability—don't overwhelm with color
|
|
||||||
|
|
||||||
### Admin Privilege Levels
|
|
||||||
Different admin roles can have subtle UI indicators:
|
|
||||||
- Super Admin: Gold accents
|
|
||||||
- Moderator: Purple accents
|
|
||||||
- Viewer: Blue accents
|
|
||||||
|
|
||||||
These are subtle hints, not dominant visual themes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Library Standards
|
|
||||||
|
|
||||||
### Consistency
|
|
||||||
- Reuse components extensively
|
|
||||||
- Maintain consistent spacing, sizing, behavior
|
|
||||||
- Document component variants clearly
|
|
||||||
- Avoid one-off custom elements
|
|
||||||
|
|
||||||
### Composability
|
|
||||||
- Build complex UIs from simple components
|
|
||||||
- Components should work together seamlessly
|
|
||||||
- Predictable prop APIs
|
|
||||||
- Flexible but not overly configurable
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- Lazy load heavy components
|
|
||||||
- Virtualize long lists
|
|
||||||
- Optimize re-renders
|
|
||||||
- Compress and cache assets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Style (UI Framework Agnostic)
|
|
||||||
|
|
||||||
### Class Naming
|
|
||||||
Use clear, semantic names:
|
|
||||||
```
|
|
||||||
.card-stat Not .cs or .c1
|
|
||||||
.button-primary Not .btn-p or .bp
|
|
||||||
.table-header Not .th or .t-h
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Organization
|
|
||||||
```
|
|
||||||
/components
|
|
||||||
/ui Base components (buttons, inputs)
|
|
||||||
/layout Layout components (sidebar, header)
|
|
||||||
/data Data components (tables, charts)
|
|
||||||
/feedback Toasts, modals, alerts
|
|
||||||
/forms Form-specific components
|
|
||||||
```
|
|
||||||
|
|
||||||
### Style Organization
|
|
||||||
- Variables/tokens for all design values
|
|
||||||
- No magic numbers in components
|
|
||||||
- DRY—reuse common styles
|
|
||||||
- Mobile-first responsive approach
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Do's ✓
|
|
||||||
- Use established patterns from these guidelines
|
|
||||||
- Maintain consistent spacing throughout
|
|
||||||
- Prioritize data clarity and scannability
|
|
||||||
- Test with real data, not lorem ipsum
|
|
||||||
- Consider loading and empty states
|
|
||||||
- Provide clear feedback for all actions
|
|
||||||
- Use progressive disclosure for complex features
|
|
||||||
|
|
||||||
### Don'ts ✗
|
|
||||||
- Don't use bright, saturated colors outside defined palette
|
|
||||||
- Don't create custom components when standard ones exist
|
|
||||||
- Don't sacrifice accessibility for aesthetics
|
|
||||||
- Don't use decorative animations that distract
|
|
||||||
- Don't hide critical actions in nested menus
|
|
||||||
- Don't use tiny fonts (below 12px) for functional text
|
|
||||||
- Don't ignore error states and edge cases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quality Checklist
|
|
||||||
|
|
||||||
Before considering any UI complete:
|
|
||||||
|
|
||||||
**Visual**
|
|
||||||
- [ ] Colors match defined palette exactly
|
|
||||||
- [ ] Spacing uses the 4px grid system
|
|
||||||
- [ ] Typography follows scale and hierarchy
|
|
||||||
- [ ] Borders and shadows are consistent
|
|
||||||
- [ ] Icons are properly sized and aligned
|
|
||||||
|
|
||||||
**Interaction**
|
|
||||||
- [ ] Hover states are defined for all interactive elements
|
|
||||||
- [ ] Focus states are visible and clear
|
|
||||||
- [ ] Loading states prevent user confusion
|
|
||||||
- [ ] Success/error feedback is immediate
|
|
||||||
- [ ] Animations are smooth and purposeful
|
|
||||||
|
|
||||||
**Responsive**
|
|
||||||
- [ ] Layout adapts to mobile, tablet, desktop
|
|
||||||
- [ ] Touch targets are minimum 44px on mobile
|
|
||||||
- [ ] Text remains readable at all sizes
|
|
||||||
- [ ] No horizontal scrolling (except intentional)
|
|
||||||
|
|
||||||
**Accessibility**
|
|
||||||
- [ ] Keyboard navigation works completely
|
|
||||||
- [ ] Focus indicators are always visible
|
|
||||||
- [ ] Color contrast meets WCAG AA minimum
|
|
||||||
- [ ] ARIA labels present where needed
|
|
||||||
- [ ] Screen reader tested for critical flows
|
|
||||||
|
|
||||||
**Data**
|
|
||||||
- [ ] Empty states are handled gracefully
|
|
||||||
- [ ] Error states provide actionable guidance
|
|
||||||
- [ ] Large datasets perform well
|
|
||||||
- [ ] Loading states prevent layout shift
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reference Assets
|
|
||||||
|
|
||||||
### Suggested Icon Library
|
|
||||||
- **Lucide Icons**: Clean, consistent, extensive
|
|
||||||
- **Heroicons**: Tailwind-friendly, well-designed
|
|
||||||
- **Phosphor Icons**: Flexible weights and styles
|
|
||||||
|
|
||||||
### Font Resources
|
|
||||||
- **Inter**: [Google Fonts](https://fonts.google.com/specimen/Inter)
|
|
||||||
- **Space Grotesk**: [Google Fonts](https://fonts.google.com/specimen/Space+Grotesk)
|
|
||||||
- **JetBrains Mono**: [JetBrains](https://www.jetbrains.com/lp/mono/)
|
|
||||||
|
|
||||||
### Design Tools
|
|
||||||
- Use component libraries: shadcn/ui, Headless UI, Radix
|
|
||||||
- Tailwind CSS for utility-first styling
|
|
||||||
- CSS variables for theming
|
|
||||||
- Design tokens for consistency
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The Aurora Admin Panel is a sophisticated tool that demands respect through its design. Every pixel serves a purpose—whether to inform, to guide, or to reinforce the prestige of the academy it administers.
|
|
||||||
|
|
||||||
**Design with authority. Build with precision. Maintain the standard.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*These design guidelines are living documentation. As Aurora evolves, so too should these standards. Propose updates through the standard development workflow.*
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Feature Flag System
|
|
||||||
|
|
||||||
The feature flag system enables controlled beta testing of new features in production without requiring a separate test environment.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Feature flags allow you to:
|
|
||||||
- Test new features with a limited audience before full rollout
|
|
||||||
- Enable/disable features without code changes or redeployment
|
|
||||||
- Control access per guild, user, or role
|
|
||||||
- Eliminate environment drift between test and production
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**`feature_flags` table:**
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | serial | Primary key |
|
|
||||||
| `name` | varchar(100) | Unique flag identifier |
|
|
||||||
| `enabled` | boolean | Whether the flag is active |
|
|
||||||
| `description` | text | Human-readable description |
|
|
||||||
| `created_at` | timestamp | Creation time |
|
|
||||||
| `updated_at` | timestamp | Last update time |
|
|
||||||
|
|
||||||
**`feature_flag_access` table:**
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | serial | Primary key |
|
|
||||||
| `flag_id` | integer | References feature_flags.id |
|
|
||||||
| `guild_id` | bigint | Guild whitelist (nullable) |
|
|
||||||
| `user_id` | bigint | User whitelist (nullable) |
|
|
||||||
| `role_id` | bigint | Role whitelist (nullable) |
|
|
||||||
| `created_at` | timestamp | Creation time |
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
The `featureFlagsService` (`shared/modules/feature-flags/feature-flags.service.ts`) provides:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Check if a flag is globally enabled
|
|
||||||
await featureFlagsService.isFlagEnabled("trading_system");
|
|
||||||
|
|
||||||
// Check if a user has access to a flagged feature
|
|
||||||
await featureFlagsService.hasAccess("trading_system", {
|
|
||||||
guildId: "123456789",
|
|
||||||
userId: "987654321",
|
|
||||||
memberRoles: ["role1", "role2"]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a new feature flag
|
|
||||||
await featureFlagsService.createFlag("new_feature", "Description");
|
|
||||||
|
|
||||||
// Enable/disable a flag
|
|
||||||
await featureFlagsService.setFlagEnabled("new_feature", true);
|
|
||||||
|
|
||||||
// Grant access to users/roles/guilds
|
|
||||||
await featureFlagsService.grantAccess("new_feature", { userId: "123" });
|
|
||||||
await featureFlagsService.grantAccess("new_feature", { roleId: "456" });
|
|
||||||
await featureFlagsService.grantAccess("new_feature", { guildId: "789" });
|
|
||||||
|
|
||||||
// List all flags or access records
|
|
||||||
await featureFlagsService.listFlags();
|
|
||||||
await featureFlagsService.listAccess("new_feature");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Marking a Command as Beta
|
|
||||||
|
|
||||||
Add `beta: true` to any command definition:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const newFeature = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("newfeature")
|
|
||||||
.setDescription("A new experimental feature"),
|
|
||||||
beta: true, // Marks this command as a beta feature
|
|
||||||
execute: async (interaction) => {
|
|
||||||
// Implementation
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
By default, the command name is used as the feature flag name. To use a custom flag name:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const trade = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("trade")
|
|
||||||
.setDescription("Trade items with another user"),
|
|
||||||
beta: true,
|
|
||||||
featureFlag: "trading_system", // Custom flag name
|
|
||||||
execute: async (interaction) => {
|
|
||||||
// Implementation
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Access Control Flow
|
|
||||||
|
|
||||||
When a user attempts to use a beta command:
|
|
||||||
|
|
||||||
1. **Check if flag exists and is enabled** - Returns false if flag doesn't exist or is disabled
|
|
||||||
2. **Check guild whitelist** - User's guild has access if `guild_id` matches
|
|
||||||
3. **Check user whitelist** - User has access if `user_id` matches
|
|
||||||
4. **Check role whitelist** - User has access if any of their roles match
|
|
||||||
|
|
||||||
If none of these conditions are met, the user sees:
|
|
||||||
> **Beta Feature**
|
|
||||||
> This feature is currently in beta testing and not available to all users. Stay tuned for the official release!
|
|
||||||
|
|
||||||
## Admin Commands
|
|
||||||
|
|
||||||
The `/featureflags` command (Administrator only) provides full management:
|
|
||||||
|
|
||||||
### Subcommands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `/featureflags list` | List all feature flags with status |
|
|
||||||
| `/featureflags create <name> [description]` | Create a new flag (disabled by default) |
|
|
||||||
| `/featureflags delete <name>` | Delete a flag and all access records |
|
|
||||||
| `/featureflags enable <name>` | Enable a flag globally |
|
|
||||||
| `/featureflags disable <name>` | Disable a flag globally |
|
|
||||||
| `/featureflags grant <name> [user\|role]` | Grant access to a user or role |
|
|
||||||
| `/featureflags revoke <id>` | Revoke access by record ID |
|
|
||||||
| `/featureflags access <name>` | List all access records for a flag |
|
|
||||||
|
|
||||||
### Example Workflow
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Create the flag:
|
|
||||||
/featureflags create trading_system "Item trading between users"
|
|
||||||
|
|
||||||
2. Grant access to beta testers:
|
|
||||||
/featureflags grant trading_system user:@beta_tester
|
|
||||||
/featureflags grant trading_system role:@Beta Testers
|
|
||||||
|
|
||||||
3. Enable the flag:
|
|
||||||
/featureflags enable trading_system
|
|
||||||
|
|
||||||
4. View access list:
|
|
||||||
/featureflags access trading_system
|
|
||||||
|
|
||||||
5. When ready for full release:
|
|
||||||
- Remove beta: true from the command
|
|
||||||
- Delete the flag: /featureflags delete trading_system
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Descriptive Names**: Use snake_case names that clearly describe the feature
|
|
||||||
2. **Document Flags**: Always add a description when creating flags
|
|
||||||
3. **Role-Based Access**: Prefer role-based access over user-based for easier management
|
|
||||||
4. **Clean Up**: Delete flags after features are fully released
|
|
||||||
5. **Testing**: Always test with a small group before wider rollout
|
|
||||||
|
|
||||||
## Implementation Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `shared/db/schema/feature-flags.ts` | Database schema |
|
|
||||||
| `shared/modules/feature-flags/feature-flags.service.ts` | Service layer |
|
|
||||||
| `shared/lib/types.ts` | Command interface with beta properties |
|
|
||||||
| `bot/lib/handlers/CommandHandler.ts` | Beta access check |
|
|
||||||
| `bot/commands/admin/featureflags.ts` | Admin command |
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# Guild Settings System
|
|
||||||
|
|
||||||
The guild settings system enables per-guild configuration stored in the database, eliminating environment-specific config files and enabling runtime updates without redeployment.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Guild settings allow you to:
|
|
||||||
- Store per-guild configuration in the database
|
|
||||||
- Update settings at runtime without code changes
|
|
||||||
- Support multiple guilds with different configurations
|
|
||||||
- Maintain backward compatibility with file-based config
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**`guild_settings` table:**
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `guild_id` | bigint | Primary key (Discord guild ID) |
|
|
||||||
| `student_role_id` | bigint | Student role ID |
|
|
||||||
| `visitor_role_id` | bigint | Visitor role ID |
|
|
||||||
| `color_role_ids` | jsonb | Array of color role IDs |
|
|
||||||
| `welcome_channel_id` | bigint | Welcome message channel |
|
|
||||||
| `welcome_message` | text | Custom welcome message |
|
|
||||||
| `feedback_channel_id` | bigint | Feedback channel |
|
|
||||||
| `terminal_channel_id` | bigint | Terminal channel |
|
|
||||||
| `terminal_message_id` | bigint | Terminal message ID |
|
|
||||||
| `moderation_log_channel_id` | bigint | Moderation log channel |
|
|
||||||
| `moderation_dm_on_warn` | jsonb | DM user on warn |
|
|
||||||
| `moderation_auto_timeout_threshold` | jsonb | Auto timeout after N warnings |
|
|
||||||
| `feature_overrides` | jsonb | Feature flag overrides |
|
|
||||||
| `created_at` | timestamp | Creation time |
|
|
||||||
| `updated_at` | timestamp | Last update time |
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
The `guildSettingsService` (`shared/modules/guild-settings/guild-settings.service.ts`) provides:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Get settings for a guild (returns null if not configured)
|
|
||||||
await guildSettingsService.getSettings(guildId);
|
|
||||||
|
|
||||||
// Create or update settings
|
|
||||||
await guildSettingsService.upsertSettings({
|
|
||||||
guildId: "123456789",
|
|
||||||
studentRoleId: "987654321",
|
|
||||||
visitorRoleId: "111222333",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update a single setting
|
|
||||||
await guildSettingsService.updateSetting(guildId, "welcomeChannel", "456789123");
|
|
||||||
|
|
||||||
// Delete all settings for a guild
|
|
||||||
await guildSettingsService.deleteSettings(guildId);
|
|
||||||
|
|
||||||
// Color role helpers
|
|
||||||
await guildSettingsService.addColorRole(guildId, roleId);
|
|
||||||
await guildSettingsService.removeColorRole(guildId, roleId);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Getting Guild Configuration
|
|
||||||
|
|
||||||
Use `getGuildConfig()` instead of direct `config` imports for guild-specific settings:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
|
||||||
|
|
||||||
// In a command or interaction
|
|
||||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
|
||||||
|
|
||||||
// Access settings
|
|
||||||
const studentRole = guildConfig.studentRole;
|
|
||||||
const welcomeChannel = guildConfig.welcomeChannelId;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fallback Behavior
|
|
||||||
|
|
||||||
`getGuildConfig()` returns settings in this order:
|
|
||||||
1. **Database settings** (if guild is configured in DB)
|
|
||||||
2. **File config fallback** (during migration period)
|
|
||||||
|
|
||||||
This ensures backward compatibility while migrating from file-based config.
|
|
||||||
|
|
||||||
### Cache Invalidation
|
|
||||||
|
|
||||||
Settings are cached for 60 seconds. After updating settings, invalidate the cache:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
|
||||||
|
|
||||||
await guildSettingsService.upsertSettings({ guildId, ...settings });
|
|
||||||
invalidateGuildConfigCache(guildId);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Admin Commands
|
|
||||||
|
|
||||||
The `/settings` command (Administrator only) provides full management:
|
|
||||||
|
|
||||||
### Subcommands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `/settings show` | Display current guild settings |
|
|
||||||
| `/settings set <key> [value]` | Update a setting |
|
|
||||||
| `/settings reset <key>` | Reset a setting to default |
|
|
||||||
| `/settings colors <action> [role]` | Manage color roles |
|
|
||||||
|
|
||||||
### Settable Keys
|
|
||||||
|
|
||||||
| Key | Type | Description |
|
|
||||||
|-----|------|-------------|
|
|
||||||
| `studentRole` | Role | Role for enrolled students |
|
|
||||||
| `visitorRole` | Role | Role for visitors |
|
|
||||||
| `welcomeChannel` | Channel | Channel for welcome messages |
|
|
||||||
| `welcomeMessage` | Text | Custom welcome message |
|
|
||||||
| `feedbackChannel` | Channel | Channel for feedback |
|
|
||||||
| `terminalChannel` | Channel | Terminal channel |
|
|
||||||
| `terminalMessage` | Text | Terminal message ID |
|
|
||||||
| `moderationLogChannel` | Channel | Moderation log channel |
|
|
||||||
| `moderationDmOnWarn` | Boolean | DM users on warn |
|
|
||||||
| `moderationAutoTimeoutThreshold` | Number | Auto timeout threshold |
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| GET | `/api/guilds/:guildId/settings` | Get guild settings |
|
|
||||||
| PUT | `/api/guilds/:guildId/settings` | Create/replace settings |
|
|
||||||
| PATCH | `/api/guilds/:guildId/settings` | Partial update |
|
|
||||||
| DELETE | `/api/guilds/:guildId/settings` | Delete settings |
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
To migrate existing config.json settings to the database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run db:migrate-config
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Read values from `config.json`
|
|
||||||
2. Create a database record for `DISCORD_GUILD_ID`
|
|
||||||
3. Store all guild-specific settings
|
|
||||||
|
|
||||||
## Migration Strategy for Code
|
|
||||||
|
|
||||||
Update code references incrementally:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
import { config } from "@shared/lib/config";
|
|
||||||
const role = config.studentRole;
|
|
||||||
|
|
||||||
// After
|
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
|
||||||
const guildConfig = await getGuildConfig(guildId);
|
|
||||||
const role = guildConfig.studentRole;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files to Update
|
|
||||||
|
|
||||||
Files using guild-specific config that should be updated:
|
|
||||||
- `bot/events/guildMemberAdd.ts`
|
|
||||||
- `bot/modules/user/enrollment.interaction.ts`
|
|
||||||
- `bot/modules/feedback/feedback.interaction.ts`
|
|
||||||
- `bot/commands/feedback/feedback.ts`
|
|
||||||
- `bot/commands/inventory/use.ts`
|
|
||||||
- `bot/commands/admin/create_color.ts`
|
|
||||||
- `shared/modules/moderation/moderation.service.ts`
|
|
||||||
- `shared/modules/terminal/terminal.service.ts`
|
|
||||||
|
|
||||||
## Files Updated to Use Database Config
|
|
||||||
|
|
||||||
All code has been migrated to use `getGuildConfig()`:
|
|
||||||
|
|
||||||
- `bot/events/guildMemberAdd.ts` - Role assignment on join
|
|
||||||
- `bot/modules/user/enrollment.interaction.ts` - Enrollment flow
|
|
||||||
- `bot/modules/feedback/feedback.interaction.ts` - Feedback submission
|
|
||||||
- `bot/commands/feedback/feedback.ts` - Feedback command
|
|
||||||
- `bot/commands/inventory/use.ts` - Color role handling
|
|
||||||
- `bot/commands/admin/create_color.ts` - Color role creation
|
|
||||||
- `bot/commands/admin/warn.ts` - Warning with DM and auto-timeout
|
|
||||||
- `shared/modules/moderation/moderation.service.ts` - Accepts config param
|
|
||||||
- `shared/modules/terminal/terminal.service.ts` - Terminal location persistence
|
|
||||||
- `shared/modules/economy/lootdrop.service.ts` - Terminal updates
|
|
||||||
|
|
||||||
## Implementation Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `shared/db/schema/guild-settings.ts` | Database schema |
|
|
||||||
| `shared/modules/guild-settings/guild-settings.service.ts` | Service layer |
|
|
||||||
| `shared/lib/config.ts` | Config loader with getGuildConfig() |
|
|
||||||
| `bot/commands/admin/settings.ts` | Admin command |
|
|
||||||
| `web/src/routes/guild-settings.routes.ts` | API routes |
|
|
||||||
| `shared/scripts/migrate-config-to-db.ts` | Migration script |
|
|
||||||
195
docs/main.md
195
docs/main.md
@@ -1,195 +0,0 @@
|
|||||||
# 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 REST API in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
|
|
||||||
|
|
||||||
## Monorepo Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
aurora-bot-discord/
|
|
||||||
├── bot/ # Discord bot implementation
|
|
||||||
│ ├── commands/ # Slash command implementations
|
|
||||||
│ ├── events/ # Discord event handlers
|
|
||||||
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
|
||||||
│ ├── modules/ # Feature modules (views, interactions per domain)
|
|
||||||
│ ├── graphics/ # Canvas-based image generation
|
|
||||||
│ └── index.ts # Bot entry point
|
|
||||||
├── api/ # REST API server
|
|
||||||
│ └── src/routes/ # API route handlers
|
|
||||||
├── shared/ # Shared code between bot and API
|
|
||||||
│ ├── db/ # Database schema and Drizzle ORM
|
|
||||||
│ ├── lib/ # Utilities, config, errors, logger, events
|
|
||||||
│ └── modules/ # Domain services (economy, admin, inventory, quest, etc.)
|
|
||||||
├── panel/ # React admin dashboard (Vite + Tailwind)
|
|
||||||
├── scripts/ # Helper scripts
|
|
||||||
├── 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)
|
|
||||||
- `feedback/`: Feedback commands
|
|
||||||
- `inventory/`: Item management commands
|
|
||||||
- `leveling/`: XP and level tracking
|
|
||||||
- `quest/`: Quest commands
|
|
||||||
- `user/`: User profile commands
|
|
||||||
- **Modules** (`bot/modules/`): Feature modules with views and interaction handlers per domain (admin, economy, inventory, moderation, trade, trivia, etc.)
|
|
||||||
- **Graphics** (`bot/graphics/`): Canvas-based image generation (lootdrops, student IDs)
|
|
||||||
- **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. REST API (`api/`)
|
|
||||||
|
|
||||||
A headless REST API built with Bun's native HTTP server for bot administration and data access.
|
|
||||||
|
|
||||||
**Key Endpoints:**
|
|
||||||
|
|
||||||
- **Stats** (`/api/stats`): Real-time bot metrics and statistics
|
|
||||||
- **Settings** (`/api/settings`): Configuration management endpoints
|
|
||||||
- **Guild Settings** (`/api/guild-settings`): Per-guild configuration
|
|
||||||
- **Users** (`/api/users`): User data and profiles
|
|
||||||
- **Items** (`/api/items`): Item catalog and management
|
|
||||||
- **Quests** (`/api/quests`): Quest data and progress
|
|
||||||
- **Economy** (`/api/transactions`): Economy and transaction data
|
|
||||||
- **Moderation** (`/api/moderation`): Moderation case data
|
|
||||||
- **Classes** (`/api/classes`): RPG class data
|
|
||||||
- **Lootdrops** (`/api/lootdrops`): Lootdrop data
|
|
||||||
- **Health** (`/api/health`): Health check endpoint
|
|
||||||
|
|
||||||
**API Features:**
|
|
||||||
|
|
||||||
- Built with Bun's native HTTP server
|
|
||||||
- WebSocket support for real-time updates (`/ws`)
|
|
||||||
- REST API endpoints for all bot data
|
|
||||||
- Real-time event streaming via WebSocket
|
|
||||||
- Zod validation for all requests
|
|
||||||
|
|
||||||
### 3. Admin Panel (`panel/`)
|
|
||||||
|
|
||||||
A React-based admin dashboard built with Vite and Tailwind CSS for managing the bot through a web interface.
|
|
||||||
|
|
||||||
### 4. Shared Core (`shared/`)
|
|
||||||
|
|
||||||
Shared code accessible by both the bot and API.
|
|
||||||
|
|
||||||
**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
|
|
||||||
- **admin/**: Administrative actions (maintenance mode, cache clearing)
|
|
||||||
- **inventory/**: Inventory management
|
|
||||||
- **items/**: Item catalog and management
|
|
||||||
- **trade/**: Trading system
|
|
||||||
- **trivia/**: Trivia game logic
|
|
||||||
- **quest/**: Quest creation and tracking
|
|
||||||
- **class/**: RPG class system
|
|
||||||
- **leveling/**: XP and leveling logic
|
|
||||||
- **moderation/**: Moderation case management
|
|
||||||
- **user/**: User profile management
|
|
||||||
- **dashboard/**: Dashboard statistics and real-time event bus
|
|
||||||
- **guild-settings/**: Per-guild configuration
|
|
||||||
- **game-settings/**: Game-wide settings
|
|
||||||
- **feature-flags/**: Feature flag management
|
|
||||||
- **system/**: System-level utilities
|
|
||||||
|
|
||||||
**Utilities (`shared/lib/`):**
|
|
||||||
|
|
||||||
- `config.ts`: Application configuration management
|
|
||||||
- `logger.ts`: Structured logging system
|
|
||||||
- `env.ts`: Environment variable handling
|
|
||||||
- `errors.ts`: Error classes (UserError, SystemError)
|
|
||||||
- `events.ts`: Event bus for inter-module communication
|
|
||||||
- `eventWiring.ts`: Event bus wiring
|
|
||||||
- `constants.ts`: Application-wide constants
|
|
||||||
- `types.ts`: Shared TypeScript types
|
|
||||||
- `utils.ts`: General utility functions
|
|
||||||
- `rarity.ts`: Item rarity definitions
|
|
||||||
- `assets.ts`: Asset path utilities
|
|
||||||
|
|
||||||
## 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 API
|
|
||||||
2. **Moderation Tools**:
|
|
||||||
- Warn, note, and track moderation cases
|
|
||||||
- Mass prune inactive members
|
|
||||||
- Role management
|
|
||||||
3. **Quest Management**: Create and manage server-specific quests
|
|
||||||
4. **Monitoring**:
|
|
||||||
- Real-time statistics via REST API
|
|
||||||
- Activity data 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 | Bun HTTP Server (REST API) |
|
|
||||||
| Database | PostgreSQL 17 |
|
|
||||||
| ORM | Drizzle ORM |
|
|
||||||
| Admin Panel | React + Vite + Tailwind CSS |
|
|
||||||
| UI | Discord embeds and components |
|
|
||||||
| Validation | Zod |
|
|
||||||
| Containerization | Docker |
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Database migrations
|
|
||||||
bun run migrate
|
|
||||||
|
|
||||||
# Production (Docker)
|
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
The bot and API server run on port 3000 and are accessible at `http://localhost:3000`.
|
|
||||||
84
docs/new-design/DESIGN.md
Normal file
84
docs/new-design/DESIGN.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Design System Specification: Stellar Editorial
|
||||||
|
|
||||||
|
## 1. Overview & Creative North Star: "The Celestial Curator"
|
||||||
|
This design system is built to evoke the atmosphere of an elite, secretive astronomical academy. We are moving away from the "utility-first" aesthetic of standard Discord bots toward a "Digital Editorial" experience.
|
||||||
|
|
||||||
|
**Creative North Star: The Celestial Curator**
|
||||||
|
The UI should feel like a high-end, leather-bound astronomical ledger reimagined for the 22nd century. We achieve this through "The Void & The Light"—using deep, expansive dark spaces contrasted against precise, shimmering accents. We reject the rigid, boxed-in layouts of traditional dashboards in favor of intentional asymmetry, overlapping layers, and a profound sense of depth. Every element should feel curated, prestigious, and slightly mysterious.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Colors: The Palette of the Night Sky
|
||||||
|
The color philosophy is rooted in "Atmospheric Depth." We use a hierarchy of midnight tones to create a sense of infinite space, punctuated by gold and starlight.
|
||||||
|
|
||||||
|
### The "No-Line" Rule
|
||||||
|
**Explicit Instruction:** Designers are prohibited from using 1px solid borders to define sections or containers. Layout boundaries must be defined exclusively through background color shifts. Use `surface-container-low` against a `surface` background to create a subtle "carved" effect, or `surface-container-high` to create "lift."
|
||||||
|
|
||||||
|
### Surface Hierarchy & Nesting
|
||||||
|
Instead of a flat grid, treat the UI as a series of nested celestial bodies.
|
||||||
|
* **Base Layer:** `surface` (#0d1323) – The infinite void.
|
||||||
|
* **Secondary Sections:** `surface-container-low` (#151b2c) – Softly recessed areas.
|
||||||
|
* **Interactive Cards:** `surface-container-high` (#24293b) – Elevated content.
|
||||||
|
* **Floating Modals:** `surface-container-highest` (#2f3446) – The closest layer to the viewer.
|
||||||
|
|
||||||
|
### The "Glass & Gradient" Rule
|
||||||
|
To capture the "Stellar Academy" prestige, use Glassmorphism for floating elements.
|
||||||
|
* **Formula:** Apply `surface-variant` at 40% opacity with a `24px` backdrop blur.
|
||||||
|
* **Signature Textures:** For primary CTAs and hero headers, utilize a linear gradient: `primary` (#e9c349) to `primary-fixed-dim` (#e9c349) at a 135-degree angle. This provides a "metallic brass" sheen that flat colors cannot replicate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typography: Academic Authority
|
||||||
|
The type system pairs the intellectual weight of a classic Serif with the technical precision of a modern Sans-Serif.
|
||||||
|
|
||||||
|
* **Display & Headlines (Noto Serif):** These are the "Ancient Manuscripts." Use `display-lg` to `headline-sm` for titles and major headers. This font conveys the "Elite Academy" prestige.
|
||||||
|
* **UI & Body (Manrope):** This is the "Modern Faculty." Use `title-lg` down to `body-sm` for all functional UI elements, descriptions, and system feedback.
|
||||||
|
* **Technical Labels (Space Grotesk):** Use `label-md` and `label-sm` for status tags, metadata, and numerical RPG stats. This adds a subtle "astronomical instrument" feel.
|
||||||
|
|
||||||
|
**Editorial Tip:** Use exaggerated tracking (0.1em) on `label-sm` in all-caps to enhance the high-end, premium feel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Elevation & Depth: Tonal Layering
|
||||||
|
We do not use structural lines. We use light and shadow to imply existence.
|
||||||
|
|
||||||
|
* **The Layering Principle:** Depth is achieved by stacking. Place a `surface-container-lowest` card on a `surface-container-low` section. This creates a "soft-edge" transition that feels organic rather than digital.
|
||||||
|
* **Ambient Shadows:** For floating elements, use a shadow with a 40px blur, 0px offset, and 6% opacity using the `on-surface` color. This creates a "glow" rather than a "drop shadow," mimicking how light behaves in a dark vacuum.
|
||||||
|
* **The "Ghost Border" Fallback:** If a boundary is strictly required for accessibility, use a "Ghost Border": `outline-variant` (#45464c) at 15% opacity. Never use 100% opaque borders.
|
||||||
|
* **The Aurora Glow:** Use `primary-container` as a very large, soft radial gradient (600px+) behind key UI elements to create a "nebula" effect, grounding the component in space.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Components: Precision & Prestige
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
* **Primary:** A gradient-filled container (`primary` to `primary-fixed-dim`). No border. `label-md` (Space Grotesk) text in `on-primary`.
|
||||||
|
* **Secondary:** A "Ghost Border" container with `secondary` text. Upon hover, the background fills with 5% `secondary` white.
|
||||||
|
* **Tertiary:** Text-only in `primary`, but with a small `2px` gold dot (star) preceding the label.
|
||||||
|
|
||||||
|
### Cards & Lists
|
||||||
|
* **Rule:** Forbid the use of divider lines.
|
||||||
|
* **Separation:** Use `spacing-6` (2rem) of vertical white space to separate list items. If items must be grouped, use a subtle background shift to `surface-container-low`.
|
||||||
|
* **Visual Interest:** In the top-right corner of a "Highest" elevation card, place a subtle, 10% opacity constellation pattern SVG.
|
||||||
|
|
||||||
|
### Input Fields
|
||||||
|
* **Styling:** Inputs should not be boxes. Use a `surface-container-lowest` background with a `2px` bottom-only border in `outline-variant`.
|
||||||
|
* **Focus State:** The bottom border transitions to `primary` (gold) with a subtle `primary-container` outer glow.
|
||||||
|
|
||||||
|
### New Component: "The Astral Badge"
|
||||||
|
A specialized chip for RPG ranks.
|
||||||
|
* **Style:** `label-sm` text inside a `surface-container-highest` pill. It features a 1px "Ghost Border" and a tiny radial glow in the center-left using the `primary` color to represent a "guiding star."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Do's and Don'ts
|
||||||
|
|
||||||
|
### Do:
|
||||||
|
* **Embrace Asymmetry:** Align text to the left but place decorative "constellation" elements floating on the right to break the grid.
|
||||||
|
* **Use Generous Spacing:** Premium design requires "breathing room." Use `spacing-10` and `spacing-12` for section margins.
|
||||||
|
* **Tint Your Greys:** Ensure all "neutrals" are pulled from the `secondary` and `tertiary` tokens, which are infused with midnight blue and silver.
|
||||||
|
|
||||||
|
### Don't:
|
||||||
|
* **Don't use pure black (#000):** It kills the "depth" of the midnight blue theme. Use `surface-container-lowest`.
|
||||||
|
* **Don't use standard Discord "Blurple":** This system must feel like a custom application that exists outside the standard Discord ecosystem.
|
||||||
|
* **Don't use hard corners:** Stick strictly to the `Roundedness Scale`. Most containers should use `xl` (0.75rem) or `lg` (0.5rem) to maintain a sophisticated, approachable feel.
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
# Lootbox UX Overhaul Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Overhaul lootbox pull results and shop loot table displays using Discord Components V2 with rarity-driven theming.
|
|
||||||
|
|
||||||
**Architecture:** Extract shared rarity config and asset helpers into `shared/lib/rarity.ts`. Modify `effect.handlers.ts` to return separate `iconUrl`/`imageUrl` fields. Rewrite `inventory.view.ts` pull result builder and `shop.view.ts` loot table section to use Components V2 containers with rarity-themed accent colors.
|
|
||||||
|
|
||||||
**Tech Stack:** TypeScript, Discord.js (Components V2: ContainerBuilder, SectionBuilder, TextDisplayBuilder, MediaGalleryBuilder, SeparatorBuilder), Bun test
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-03-18-lootbox-ux-overhaul-design.md`
|
|
||||||
|
|
||||||
**Note on color changes:** The new `RARITY_CONFIG` uses slightly different hex values than the previous Discord.js `Colors` enum values for Common (`0x95A5A6` vs `Colors.LightGrey` = `0xBCC0C0`) and Nothing (`0x636363` vs `Colors.DarkButNotBlack` = `0x2C2F33`). This is intentional per the design spec.
|
|
||||||
|
|
||||||
**Note on `useItem` return shape:** `inventoryService.useItem()` returns the full item from the Drizzle relation query (`with: { item: true }`), which already includes both `iconUrl` and `imageUrl` columns from the `items` schema. No changes to the service are needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Action | Responsibility |
|
|
||||||
|------|--------|----------------|
|
|
||||||
| `shared/lib/rarity.ts` | Create | `RARITY_CONFIG` map, `defaultName` helper, rarity lookup with fallback |
|
|
||||||
| `shared/lib/rarity.test.ts` | Create | Tests for rarity config lookup and defaultName |
|
|
||||||
| `shared/modules/inventory/effect.handlers.ts` | Modify | Return separate `iconUrl` and `imageUrl` in ITEM lootbox results |
|
|
||||||
| `bot/modules/inventory/inventory.view.ts` | Modify | Replace `getItemUseResultEmbed()` with Components V2 `getLootboxResultMessage()`, remove local `defaultName` |
|
|
||||||
| `bot/commands/inventory/use.ts` | Modify | Switch from embed reply to Components V2 message |
|
|
||||||
| `bot/modules/economy/shop.view.ts` | Modify | Rework loot table into separate Container 2, replace local constants with shared imports, remove local `defaultName` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Create shared rarity config
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `shared/lib/rarity.ts`
|
|
||||||
- Create: `shared/lib/rarity.test.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// shared/lib/rarity.test.ts
|
|
||||||
import { describe, it, expect } from "bun:test";
|
|
||||||
import { getRarityConfig, RARITY_CONFIG, defaultName } from "./rarity";
|
|
||||||
|
|
||||||
describe("getRarityConfig", () => {
|
|
||||||
it("returns correct config for known rarities", () => {
|
|
||||||
expect(getRarityConfig("SSR").color).toBe(0xF1C40F);
|
|
||||||
expect(getRarityConfig("SSR").emoji).toBe("🌟");
|
|
||||||
expect(getRarityConfig("SSR").label).toBe("SSR");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns correct config for loot types", () => {
|
|
||||||
expect(getRarityConfig("CURRENCY").color).toBe(0x2ECC71);
|
|
||||||
expect(getRarityConfig("XP").color).toBe(0x1ABC9C);
|
|
||||||
expect(getRarityConfig("NOTHING").color).toBe(0x636363);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to Common for unknown rarity", () => {
|
|
||||||
const result = getRarityConfig("LEGENDARY");
|
|
||||||
expect(result).toEqual(RARITY_CONFIG["C"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("defaultName", () => {
|
|
||||||
it("extracts filename from path", () => {
|
|
||||||
expect(defaultName("/assets/items/sword.png")).toBe("sword.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns image.png for empty path", () => {
|
|
||||||
expect(defaultName("")).toBe("image.png");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `bun test shared/lib/rarity.test.ts`
|
|
||||||
Expected: FAIL — module not found
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write the implementation**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// shared/lib/rarity.ts
|
|
||||||
|
|
||||||
export const RARITY_CONFIG: Record<string, { color: number; emoji: string; label: string }> = {
|
|
||||||
C: { color: 0x95A5A6, emoji: "📦", label: "Common" },
|
|
||||||
R: { color: 0x3498DB, emoji: "📦", label: "Rare" },
|
|
||||||
SR: { color: 0x9B59B6, emoji: "✨", label: "Super Rare" },
|
|
||||||
SSR: { color: 0xF1C40F, emoji: "🌟", label: "SSR" },
|
|
||||||
CURRENCY: { color: 0x2ECC71, emoji: "💰", label: "Currency" },
|
|
||||||
XP: { color: 0x1ABC9C, emoji: "🔮", label: "Experience" },
|
|
||||||
NOTHING: { color: 0x636363, emoji: "💨", label: "Empty" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getRarityConfig(rarity: string): { color: number; emoji: string; label: string } {
|
|
||||||
return RARITY_CONFIG[rarity] ?? RARITY_CONFIG["C"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultName(path: string): string {
|
|
||||||
return path.split("/").pop() || "image.png";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `bun test shared/lib/rarity.test.ts`
|
|
||||||
Expected: PASS — all 4 tests pass
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add shared/lib/rarity.ts shared/lib/rarity.test.ts
|
|
||||||
git commit -m "feat: add shared rarity config and helpers"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Update effect handler and pull result display (atomic change)
|
|
||||||
|
|
||||||
These changes are done together because the effect handler return shape change and the view layer consumer update must stay in sync — splitting them would leave a broken intermediate state.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `shared/modules/inventory/effect.handlers.ts:141-153` (handleLootbox ITEM result)
|
|
||||||
- Modify: `bot/modules/inventory/inventory.view.ts` (replace `getItemUseResultEmbed` with `getLootboxResultMessage`)
|
|
||||||
- Modify: `bot/commands/inventory/use.ts:6,60-62` (update import and reply call)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update the ITEM result in `effect.handlers.ts` to return separate `iconUrl` and `imageUrl`**
|
|
||||||
|
|
||||||
In `shared/modules/inventory/effect.handlers.ts`, find the ITEM result return block (lines 141-153). Change the `item` object:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// OLD (line 145-149)
|
|
||||||
item: {
|
|
||||||
name: item.name,
|
|
||||||
rarity: item.rarity,
|
|
||||||
description: item.description,
|
|
||||||
image: item.imageUrl || item.iconUrl
|
|
||||||
},
|
|
||||||
|
|
||||||
// NEW
|
|
||||||
item: {
|
|
||||||
name: item.name,
|
|
||||||
rarity: item.rarity,
|
|
||||||
description: item.description,
|
|
||||||
iconUrl: item.iconUrl,
|
|
||||||
imageUrl: item.imageUrl,
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Rewrite `inventory.view.ts` — replace `getItemUseResultEmbed` with `getLootboxResultMessage`**
|
|
||||||
|
|
||||||
Replace the entire `getItemUseResultEmbed` function (lines 37-136) and the local `defaultName` helper (lines 138-140). Update the imports at the top of the file. Keep `getInventoryEmbed` (lines 23-32) unchanged.
|
|
||||||
|
|
||||||
New imports (replace lines 1-6):
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
EmbedBuilder,
|
|
||||||
AttachmentBuilder,
|
|
||||||
ContainerBuilder,
|
|
||||||
SectionBuilder,
|
|
||||||
TextDisplayBuilder,
|
|
||||||
MediaGalleryBuilder,
|
|
||||||
MediaGalleryItemBuilder,
|
|
||||||
ThumbnailBuilder,
|
|
||||||
SeparatorBuilder,
|
|
||||||
SeparatorSpacingSize,
|
|
||||||
MessageFlags,
|
|
||||||
} from "discord.js";
|
|
||||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
|
||||||
import { getRarityConfig, defaultName } from "@shared/lib/rarity";
|
|
||||||
import { join } from "path";
|
|
||||||
import { existsSync } from "fs";
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `EmbedBuilder` is still needed because `getInventoryEmbed` uses it. Remove the `EffectType` import and the `ItemUsageData` type import since the new function doesn't use them.
|
|
||||||
|
|
||||||
New function (replaces `getItemUseResultEmbed` and `defaultName`):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* Creates a Components V2 message showing the result of opening a lootbox.
|
|
||||||
* Falls back to a simple embed for non-lootbox item usage.
|
|
||||||
*/
|
|
||||||
export function getLootboxResultMessage(
|
|
||||||
results: any[],
|
|
||||||
item?: { name: string; iconUrl: string | null; imageUrl: string | null; usageData: any }
|
|
||||||
) {
|
|
||||||
const files: AttachmentBuilder[] = [];
|
|
||||||
const otherMessages: string[] = [];
|
|
||||||
let lootResult: any = null;
|
|
||||||
|
|
||||||
for (const res of results) {
|
|
||||||
if (typeof res === "object" && res.type === "LOOTBOX_RESULT") {
|
|
||||||
lootResult = res;
|
|
||||||
} else {
|
|
||||||
otherMessages.push(typeof res === "string" ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no loot result, fall back to a simple embed (non-lootbox item usage)
|
|
||||||
if (!lootResult) {
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!")
|
|
||||||
.setDescription(otherMessages.join("\n") || "Effect applied.")
|
|
||||||
.setColor(0x2ecc71)
|
|
||||||
.setTimestamp();
|
|
||||||
return { embeds: [embed], files, components: undefined, flags: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine rarity key for theming
|
|
||||||
let rarityKey = "C";
|
|
||||||
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
|
||||||
rarityKey = lootResult.item.rarity || "C";
|
|
||||||
} else if (lootResult.rewardType === "CURRENCY") {
|
|
||||||
rarityKey = "CURRENCY";
|
|
||||||
} else if (lootResult.rewardType === "XP") {
|
|
||||||
rarityKey = "XP";
|
|
||||||
} else {
|
|
||||||
rarityKey = "NOTHING";
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = getRarityConfig(rarityKey);
|
|
||||||
const container = new ContainerBuilder().setAccentColor(config.color);
|
|
||||||
|
|
||||||
// Header: lootbox name
|
|
||||||
if (item?.name) {
|
|
||||||
container.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(`-# Opened: ${item.name}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build title and description based on reward type
|
|
||||||
let title = "";
|
|
||||||
let description = "";
|
|
||||||
|
|
||||||
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
|
||||||
const i = lootResult.item;
|
|
||||||
const amountStr = lootResult.amount > 1 ? ` ×${lootResult.amount}` : "";
|
|
||||||
title = `${config.emoji} ${config.label} — ${i.name}${amountStr}`;
|
|
||||||
description = i.description || "";
|
|
||||||
if (description) description += "\n";
|
|
||||||
description += `\n**${config.label}** · ×${lootResult.amount || 1} added to inventory`;
|
|
||||||
} else if (lootResult.rewardType === "CURRENCY") {
|
|
||||||
title = `${config.emoji} You found ${lootResult.amount.toLocaleString()} AU!`;
|
|
||||||
description = "Coins have been added to your balance.";
|
|
||||||
} else if (lootResult.rewardType === "XP") {
|
|
||||||
title = `${config.emoji} You gained ${lootResult.amount.toLocaleString()} XP!`;
|
|
||||||
description = "Experience has been added to your profile.";
|
|
||||||
} else {
|
|
||||||
title = `${config.emoji} Empty...`;
|
|
||||||
description = lootResult.message || "You found nothing inside.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main section with optional thumbnail
|
|
||||||
const section = new SectionBuilder().addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(`# ${title}`),
|
|
||||||
new TextDisplayBuilder().setContent(description)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Thumbnail from iconUrl (use reward item's icon for ITEM, lootbox icon otherwise)
|
|
||||||
let thumbnailUrl: string | null = null;
|
|
||||||
const iconSource = lootResult.rewardType === "ITEM" ? lootResult.item?.iconUrl : item?.iconUrl;
|
|
||||||
if (iconSource) {
|
|
||||||
if (isLocalAssetUrl(iconSource)) {
|
|
||||||
const iconPath = join(process.cwd(), "bot/assets/graphics", iconSource.replace(/^\/?assets\//, ""));
|
|
||||||
if (existsSync(iconPath)) {
|
|
||||||
const iconName = defaultName(iconSource);
|
|
||||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
|
||||||
thumbnailUrl = `attachment://${iconName}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
thumbnailUrl = resolveAssetUrl(iconSource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thumbnailUrl) {
|
|
||||||
section.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addSectionComponents(section);
|
|
||||||
|
|
||||||
// Media gallery for full item art (if imageUrl differs from iconUrl)
|
|
||||||
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
|
||||||
const imgSource = lootResult.item.imageUrl;
|
|
||||||
const iconSrc = lootResult.item.iconUrl;
|
|
||||||
if (imgSource && imgSource !== iconSrc) {
|
|
||||||
let displayImageUrl: string | null = null;
|
|
||||||
if (isLocalAssetUrl(imgSource)) {
|
|
||||||
const imagePath = join(process.cwd(), "bot/assets/graphics", imgSource.replace(/^\/?assets\//, ""));
|
|
||||||
if (existsSync(imagePath)) {
|
|
||||||
const imageName = defaultName(imgSource);
|
|
||||||
if (!files.find(f => f.name === imageName)) {
|
|
||||||
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
|
||||||
}
|
|
||||||
displayImageUrl = `attachment://${imageName}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
displayImageUrl = resolveAssetUrl(imgSource);
|
|
||||||
}
|
|
||||||
if (displayImageUrl) {
|
|
||||||
container.addMediaGalleryComponents(
|
|
||||||
new MediaGalleryBuilder().addItems(
|
|
||||||
new MediaGalleryItemBuilder().setURL(displayImageUrl)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other effects (non-lootbox results like temp roles, XP boosts)
|
|
||||||
if (otherMessages.length > 0) {
|
|
||||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
||||||
container.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(`**Other Effects**\n${otherMessages.join("\n")}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
components: [container] as any,
|
|
||||||
files,
|
|
||||||
flags: MessageFlags.IsComponentsV2,
|
|
||||||
embeds: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `use.ts` to use the new function**
|
|
||||||
|
|
||||||
In `bot/commands/inventory/use.ts`:
|
|
||||||
|
|
||||||
Change the import (line 6):
|
|
||||||
```typescript
|
|
||||||
// OLD
|
|
||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
|
||||||
// NEW
|
|
||||||
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
|
||||||
```
|
|
||||||
|
|
||||||
Change the reply (lines 60-62):
|
|
||||||
```typescript
|
|
||||||
// OLD
|
|
||||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
|
||||||
await interaction.editReply({ embeds: [embed], files });
|
|
||||||
|
|
||||||
// NEW
|
|
||||||
const message = getLootboxResultMessage(result.results, result.item);
|
|
||||||
await interaction.editReply(message as any);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the full test suite**
|
|
||||||
|
|
||||||
Run: `bun test`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add shared/modules/inventory/effect.handlers.ts bot/modules/inventory/inventory.view.ts bot/commands/inventory/use.ts
|
|
||||||
git commit -m "feat: rewrite lootbox pull results with Components V2 and separate icon/image URLs"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Rework shop loot table display
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `bot/modules/economy/shop.view.ts`
|
|
||||||
|
|
||||||
This task replaces the entire `getShopListingMessage` function body. The changes are:
|
|
||||||
1. Replace local `RarityColors`, `TitleMap`, and `defaultName` with shared imports
|
|
||||||
2. Split the loot table into a separate Container 2 with blurple accent
|
|
||||||
3. Group drops by rarity tier with aggregated percentages
|
|
||||||
4. Move purchase button conditionally (into loot table container for lootboxes, main container otherwise)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update imports**
|
|
||||||
|
|
||||||
At the top of `bot/modules/economy/shop.view.ts`:
|
|
||||||
|
|
||||||
Remove the local `RarityColors` constant (lines 24-32) and `TitleMap` constant (lines 34-42). Remove the local `defaultName` function at the bottom (lines 206-208).
|
|
||||||
|
|
||||||
Add import:
|
|
||||||
```typescript
|
|
||||||
import { getRarityConfig, defaultName } from "@shared/lib/rarity";
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep existing imports for Discord.js builders, `resolveAssetUrl`, `isLocalAssetUrl`, `LootType`, `EffectType`, and `LootTableItem`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Rewrite the loot table section and purchase button placement**
|
|
||||||
|
|
||||||
Replace the loot table block (lines 122-184) and purchase button block (lines 186-195) with the new two-container logic. The key change is:
|
|
||||||
|
|
||||||
1. **Create `buyButton` before the conditional** (move lines 187-191 up, before line 122):
|
|
||||||
```typescript
|
|
||||||
const buyButton = new ButtonBuilder()
|
|
||||||
.setCustomId(`shop_buy_${item.id}`)
|
|
||||||
.setLabel(`Purchase for ${item.price} 🪙`)
|
|
||||||
.setStyle(ButtonStyle.Success)
|
|
||||||
.setEmoji("🛒");
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Replace lines 122-195** (old loot table + old unconditional button) with:
|
|
||||||
```typescript
|
|
||||||
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
|
|
||||||
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
|
|
||||||
const pool = lootboxEffect.pool as LootTableItem[];
|
|
||||||
const totalWeight = pool.reduce((sum: number, i: LootTableItem) => sum + i.weight, 0);
|
|
||||||
|
|
||||||
const lootContainer = new ContainerBuilder().setAccentColor(0x5865F2);
|
|
||||||
lootContainer.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent("## 🎁 Loot Table")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group drops by rarity tier
|
|
||||||
const tiers: Record<string, { items: string[]; totalChance: number }> = {};
|
|
||||||
|
|
||||||
for (const drop of pool) {
|
|
||||||
const chance = (drop.weight / totalWeight) * 100;
|
|
||||||
let line = "";
|
|
||||||
let rarity = "C";
|
|
||||||
|
|
||||||
switch (drop.type as any) {
|
|
||||||
case LootType.CURRENCY: {
|
|
||||||
const amt = (drop.minAmount != null && drop.maxAmount != null)
|
|
||||||
? `${drop.minAmount} – ${drop.maxAmount}`
|
|
||||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} – ${drop.amount[1]}` : `${drop.amount || 0}`);
|
|
||||||
line = `${amt} 🪙`;
|
|
||||||
rarity = "CURRENCY";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case LootType.XP: {
|
|
||||||
const amt = (drop.minAmount != null && drop.maxAmount != null)
|
|
||||||
? `${drop.minAmount} – ${drop.maxAmount}`
|
|
||||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} – ${drop.amount[1]}` : `${drop.amount || 0}`);
|
|
||||||
line = `${amt} XP`;
|
|
||||||
rarity = "XP";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case LootType.ITEM: {
|
|
||||||
const referencedItems = context?.referencedItems;
|
|
||||||
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
|
||||||
const i = referencedItems.get(drop.itemId)!;
|
|
||||||
line = `${i.name} ×${drop.amount || 1}`;
|
|
||||||
rarity = i.rarity;
|
|
||||||
} else {
|
|
||||||
line = `Unknown Item`;
|
|
||||||
rarity = "C";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case LootType.NOTHING: {
|
|
||||||
line = "Nothing";
|
|
||||||
rarity = "NOTHING";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line) {
|
|
||||||
if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 };
|
|
||||||
tiers[rarity].items.push(line);
|
|
||||||
tiers[rarity].totalChance += chance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
|
||||||
let isFirst = true;
|
|
||||||
for (const rarity of order) {
|
|
||||||
const tier = tiers[rarity];
|
|
||||||
if (!tier || tier.items.length === 0) continue;
|
|
||||||
|
|
||||||
if (!isFirst) {
|
|
||||||
lootContainer.addSeparatorComponents(
|
|
||||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
isFirst = false;
|
|
||||||
|
|
||||||
const config = getRarityConfig(rarity);
|
|
||||||
const chanceStr = tier.totalChance.toFixed(1);
|
|
||||||
lootContainer.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(
|
|
||||||
`${config.emoji} **${config.label}** — ${chanceStr}%`
|
|
||||||
),
|
|
||||||
new TextDisplayBuilder().setContent(tier.items.join(", "))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purchase button inside loot table container
|
|
||||||
lootContainer.addActionRowComponents(
|
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
|
||||||
);
|
|
||||||
|
|
||||||
containers.push(lootContainer);
|
|
||||||
} else {
|
|
||||||
// Non-lootbox items: purchase button stays in main container
|
|
||||||
mainContainer.addActionRowComponents(
|
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Update the main container accent color** (line 92): replace `RarityColors[item.rarity || "C"]` with `getRarityConfig(item.rarity || "C").color`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the full test suite**
|
|
||||||
|
|
||||||
Run: `bun test`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add bot/modules/economy/shop.view.ts
|
|
||||||
git commit -m "feat: rework shop loot table into two-container Components V2 layout"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Manual verification checklist
|
|
||||||
|
|
||||||
- [ ] **Step 1: Start the bot**
|
|
||||||
|
|
||||||
Run: `bun --watch bot/index.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 2: Test pull results in Discord**
|
|
||||||
|
|
||||||
Test each reward type by using a lootbox item:
|
|
||||||
- ITEM reward (verify accent color matches rarity, thumbnail shows, title format correct)
|
|
||||||
- CURRENCY reward (verify green accent, amount displayed)
|
|
||||||
- XP reward (verify aqua accent, amount displayed)
|
|
||||||
- NOTHING reward (verify gray accent, custom message shown)
|
|
||||||
- Item with both iconUrl and imageUrl (verify thumbnail + media gallery)
|
|
||||||
- Item without icon (no thumbnail, no crash)
|
|
||||||
- Lootbox with additional effects (verify "Other Effects" section appears)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Test shop listing in Discord**
|
|
||||||
|
|
||||||
Use `/listing` command to post a lootbox item listing:
|
|
||||||
- Verify two containers appear (item info + loot table)
|
|
||||||
- Verify tiers are grouped with aggregated percentages
|
|
||||||
- Verify separators between tiers
|
|
||||||
- Verify purchase button is inside the loot table container
|
|
||||||
- Test with a non-lootbox item to verify purchase button stays in main container
|
|
||||||
|
|
||||||
- [ ] **Step 4: Test edge cases**
|
|
||||||
|
|
||||||
- Item without icon (no thumbnail, no crash)
|
|
||||||
- Item without image (no media gallery, no crash)
|
|
||||||
- Lootbox with only one tier
|
|
||||||
- Lootbox with all tiers populated
|
|
||||||
@@ -1,874 +0,0 @@
|
|||||||
# Inventory Display Redesign Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Redesign the `/inventory` command into a polished Components V2 experience with rarity emojis, paginated list, item detail view with artwork, and inline item actions (use/discard).
|
|
||||||
|
|
||||||
**Architecture:** Rewrite `inventory.view.ts` to produce CV2 containers for list and detail views. Rewrite `inventory.ts` command to manage pagination and view state with a collector. Add `inventory.interaction.ts` for interaction routing. Extend `RARITY_CONFIG` with square emojis.
|
|
||||||
|
|
||||||
**Tech Stack:** discord.js Components V2 (ContainerBuilder, TextDisplayBuilder, SectionBuilder, MediaGalleryBuilder, ActionRowBuilder, ButtonBuilder, StringSelectMenuBuilder), Drizzle ORM, Bun test runner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Action | Responsibility |
|
|
||||||
|------|--------|----------------|
|
|
||||||
| `shared/lib/rarity.ts` | Modify | Add `squareEmoji` field to `RARITY_CONFIG` |
|
|
||||||
| `bot/modules/inventory/inventory.view.ts` | Rewrite | CV2 list message builder, CV2 detail message builder (keep `getLootboxResultMessage` untouched) |
|
|
||||||
| `bot/modules/inventory/inventory.interaction.ts` | Create | Handle all inventory interactions (select, pagination, back, use, discard, confirm) |
|
|
||||||
| `bot/commands/inventory/inventory.ts` | Rewrite | Command definition with `view` subcommand, pagination collector, autocomplete |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Add squareEmoji to RARITY_CONFIG
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `shared/lib/rarity.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update the RARITY_CONFIG type and entries**
|
|
||||||
|
|
||||||
In `shared/lib/rarity.ts`, update the type signature and add `squareEmoji` to each entry:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* Shared Rarity Configuration
|
|
||||||
* Provides the canonical rarity display config (colors, emoji, labels)
|
|
||||||
* used by lootbox pull results and shop loot table views.
|
|
||||||
*/
|
|
||||||
export const RARITY_CONFIG: Record<string, { color: number; emoji: string; squareEmoji: string; label: string }> = {
|
|
||||||
C: { color: 0x95A5A6, emoji: "📦", squareEmoji: "🟤", label: "Common" },
|
|
||||||
R: { color: 0x3498DB, emoji: "📦", squareEmoji: "🔵", label: "Rare" },
|
|
||||||
SR: { color: 0x9B59B6, emoji: "✨", squareEmoji: "🟣", label: "Super Rare" },
|
|
||||||
SSR: { color: 0xF1C40F, emoji: "🌟", squareEmoji: "🟡", label: "SSR" },
|
|
||||||
CURRENCY: { color: 0x2ECC71, emoji: "💰", squareEmoji: "💰", label: "Currency" },
|
|
||||||
XP: { color: 0x1ABC9C, emoji: "🔮", squareEmoji: "🔮", label: "Experience" },
|
|
||||||
NOTHING: { color: 0x636363, emoji: "💨", squareEmoji: "💨", label: "Empty" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getRarityConfig(rarity: string): { color: number; emoji: string; squareEmoji: string; label: string } {
|
|
||||||
return RARITY_CONFIG[rarity] ?? (RARITY_CONFIG["C"]!);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify nothing is broken**
|
|
||||||
|
|
||||||
Run: `bun test`
|
|
||||||
Expected: All existing tests pass (lootbox and other rarity consumers still work since they access `emoji`, not `squareEmoji`).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add shared/lib/rarity.ts
|
|
||||||
git commit -m "feat(inventory): add squareEmoji to RARITY_CONFIG"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Build the inventory list view (CV2)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Rewrite: `bot/modules/inventory/inventory.view.ts`
|
|
||||||
|
|
||||||
This task rewrites `getInventoryEmbed` → `getInventoryListMessage` and adds `getItemDetailMessage`. The existing `getLootboxResultMessage` function stays untouched.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Define constants and types at the top of inventory.view.ts**
|
|
||||||
|
|
||||||
Replace the existing `InventoryEntry` interface and add constants. Keep all existing imports and add the new ones needed:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
EmbedBuilder,
|
|
||||||
AttachmentBuilder,
|
|
||||||
ContainerBuilder,
|
|
||||||
SectionBuilder,
|
|
||||||
TextDisplayBuilder,
|
|
||||||
MediaGalleryBuilder,
|
|
||||||
MediaGalleryItemBuilder,
|
|
||||||
ThumbnailBuilder,
|
|
||||||
SeparatorBuilder,
|
|
||||||
SeparatorSpacingSize,
|
|
||||||
ActionRowBuilder,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
StringSelectMenuBuilder,
|
|
||||||
StringSelectMenuOptionBuilder,
|
|
||||||
MessageFlags,
|
|
||||||
} from "discord.js";
|
|
||||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
|
||||||
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
|
||||||
import { ItemType } from "@shared/lib/constants";
|
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
|
||||||
import { join } from "path";
|
|
||||||
import { existsSync } from "fs";
|
|
||||||
|
|
||||||
export const ITEMS_PER_PAGE = 5;
|
|
||||||
|
|
||||||
const RARITY_SORT_ORDER: Record<string, number> = {
|
|
||||||
SSR: 0,
|
|
||||||
SR: 1,
|
|
||||||
R: 2,
|
|
||||||
C: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface InventoryItem {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
rarity: string | null;
|
|
||||||
type: string;
|
|
||||||
price: bigint | null;
|
|
||||||
iconUrl: string;
|
|
||||||
imageUrl: string;
|
|
||||||
usageData: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InventoryEntry {
|
|
||||||
quantity: bigint | null;
|
|
||||||
item: InventoryItem;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the sortInventoryItems helper**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function sortInventoryItems(entries: InventoryEntry[]): InventoryEntry[] {
|
|
||||||
return [...entries].sort((a, b) => {
|
|
||||||
const rarityA = RARITY_SORT_ORDER[a.item.rarity ?? "C"] ?? 3;
|
|
||||||
const rarityB = RARITY_SORT_ORDER[b.item.rarity ?? "C"] ?? 3;
|
|
||||||
if (rarityA !== rarityB) return rarityA - rarityB;
|
|
||||||
return a.item.name.localeCompare(b.item.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement getInventoryListMessage**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function getInventoryListMessage(
|
|
||||||
entries: InventoryEntry[],
|
|
||||||
username: string,
|
|
||||||
page: number,
|
|
||||||
viewerId: string,
|
|
||||||
ownerId: string,
|
|
||||||
) {
|
|
||||||
const sorted = sortInventoryItems(entries);
|
|
||||||
const totalPages = Math.max(1, Math.ceil(sorted.length / ITEMS_PER_PAGE));
|
|
||||||
const safePage = Math.min(page, totalPages - 1);
|
|
||||||
const pageItems = sorted.slice(safePage * ITEMS_PER_PAGE, (safePage + 1) * ITEMS_PER_PAGE);
|
|
||||||
|
|
||||||
// Accent color from highest-rarity item on page
|
|
||||||
const highestRarity = pageItems[0]?.item.rarity ?? "C";
|
|
||||||
const accentColor = getRarityConfig(highestRarity).color;
|
|
||||||
|
|
||||||
const container = new ContainerBuilder()
|
|
||||||
.setAccentColor(accentColor)
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(`# 📦 ${username}'s Inventory`),
|
|
||||||
new TextDisplayBuilder().setContent(`-# ${sorted.length} item${sorted.length !== 1 ? "s" : ""} total`)
|
|
||||||
);
|
|
||||||
|
|
||||||
container.addSeparatorComponents(
|
|
||||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Item rows
|
|
||||||
const lines = pageItems.map((entry) => {
|
|
||||||
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
|
||||||
return `${rc.squareEmoji} **${entry.item.name}** — ${rc.label} · ${entry.item.type} · ×${entry.quantity}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
container.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(lines.join("\n"))
|
|
||||||
);
|
|
||||||
|
|
||||||
container.addSeparatorComponents(
|
|
||||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Select menu with current page items
|
|
||||||
const selectMenu = new StringSelectMenuBuilder()
|
|
||||||
.setCustomId(`inv_select_${viewerId}`)
|
|
||||||
.setPlaceholder("Select an item for details");
|
|
||||||
|
|
||||||
for (const entry of pageItems) {
|
|
||||||
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
|
||||||
selectMenu.addOptions(
|
|
||||||
new StringSelectMenuOptionBuilder()
|
|
||||||
.setLabel(entry.item.name)
|
|
||||||
.setDescription(`${rc.label} · ${entry.item.type}`)
|
|
||||||
.setValue(entry.item.id.toString())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addActionRowComponents(
|
|
||||||
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pagination buttons
|
|
||||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`inv_prev_${viewerId}`)
|
|
||||||
.setLabel("◀ Previous")
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
.setDisabled(safePage <= 0),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`inv_page_${viewerId}`)
|
|
||||||
.setLabel(`Page ${safePage + 1}/${totalPages}`)
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
.setDisabled(true),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`inv_next_${viewerId}`)
|
|
||||||
.setLabel("Next ▶")
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
.setDisabled(safePage >= totalPages - 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
container.addActionRowComponents(navRow);
|
|
||||||
|
|
||||||
return {
|
|
||||||
components: [container] as any,
|
|
||||||
files: [] as AttachmentBuilder[],
|
|
||||||
flags: MessageFlags.IsComponentsV2,
|
|
||||||
embeds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Implement getEmptyInventoryMessage**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function getEmptyInventoryMessage(username: string) {
|
|
||||||
const container = new ContainerBuilder()
|
|
||||||
.setAccentColor(0x95A5A6)
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(`# 📦 ${username}'s Inventory`),
|
|
||||||
new TextDisplayBuilder().setContent("*No items yet. Visit the shop or complete quests to earn items!*")
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
components: [container] as any,
|
|
||||||
files: [],
|
|
||||||
flags: MessageFlags.IsComponentsV2,
|
|
||||||
embeds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Implement getItemDetailMessage**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function getItemDetailMessage(
|
|
||||||
entry: InventoryEntry,
|
|
||||||
viewerId: string,
|
|
||||||
ownerId: string,
|
|
||||||
) {
|
|
||||||
const { item } = entry;
|
|
||||||
const rc = getRarityConfig(item.rarity ?? "C");
|
|
||||||
const files: AttachmentBuilder[] = [];
|
|
||||||
|
|
||||||
const container = new ContainerBuilder().setAccentColor(rc.color);
|
|
||||||
|
|
||||||
// Header section with thumbnail
|
|
||||||
const section = new SectionBuilder().addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(`${rc.squareEmoji} **${item.name}**`),
|
|
||||||
new TextDisplayBuilder().setContent(`-# ${rc.label} · ${item.type}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resolve icon thumbnail
|
|
||||||
const iconUrl = resolveItemUrl(item.iconUrl, files);
|
|
||||||
if (iconUrl) {
|
|
||||||
section.setThumbnailAccessory(new ThumbnailBuilder().setURL(iconUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addSectionComponents(section);
|
|
||||||
|
|
||||||
// Artwork via MediaGallery
|
|
||||||
const imageUrl = resolveItemUrl(item.imageUrl, files);
|
|
||||||
if (imageUrl && item.imageUrl !== item.iconUrl) {
|
|
||||||
container.addMediaGalleryComponents(
|
|
||||||
new MediaGalleryBuilder().addItems(
|
|
||||||
new MediaGalleryItemBuilder().setURL(imageUrl)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description
|
|
||||||
if (item.description) {
|
|
||||||
container.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(item.description)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addSeparatorComponents(
|
|
||||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stats row
|
|
||||||
const priceText = item.price ? `${item.price} 🪙` : "Not tradeable";
|
|
||||||
container.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(
|
|
||||||
`Owned: **×${entry.quantity}** · Value: **${priceText}**`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
const isOwner = viewerId === ownerId;
|
|
||||||
const usageData = item.usageData as ItemUsageData | null;
|
|
||||||
const isUsable = isOwner && item.type === ItemType.CONSUMABLE &&
|
|
||||||
usageData?.effects && usageData.effects.length > 0;
|
|
||||||
|
|
||||||
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`inv_back_${viewerId}`)
|
|
||||||
.setLabel("◀ Back")
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isUsable) {
|
|
||||||
actionRow.addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`inv_use_${viewerId}`)
|
|
||||||
.setLabel("🧪 Use")
|
|
||||||
.setStyle(ButtonStyle.Success)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOwner) {
|
|
||||||
actionRow.addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`inv_discard_${viewerId}`)
|
|
||||||
.setLabel("🗑 Discard")
|
|
||||||
.setStyle(ButtonStyle.Danger)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addActionRowComponents(actionRow);
|
|
||||||
|
|
||||||
return {
|
|
||||||
components: [container] as any,
|
|
||||||
files,
|
|
||||||
flags: MessageFlags.IsComponentsV2,
|
|
||||||
embeds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Implement getDiscardConfirmMessage and the resolveItemUrl helper**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string) {
|
|
||||||
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
|
||||||
|
|
||||||
const container = new ContainerBuilder()
|
|
||||||
.setAccentColor(0xED4245)
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(
|
|
||||||
`Are you sure you want to discard 1× **${entry.item.name}**?`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addActionRowComponents(
|
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`inv_discard_confirm_${viewerId}`)
|
|
||||||
.setLabel("Confirm")
|
|
||||||
.setStyle(ButtonStyle.Danger),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`inv_discard_cancel_${viewerId}`)
|
|
||||||
.setLabel("Cancel")
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
components: [container] as any,
|
|
||||||
files: [],
|
|
||||||
flags: MessageFlags.IsComponentsV2,
|
|
||||||
embeds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves an item URL (icon or image) for use in CV2 components.
|
|
||||||
* Handles both local assets and remote URLs.
|
|
||||||
* Pushes AttachmentBuilders to `files` array for local assets.
|
|
||||||
*/
|
|
||||||
function resolveItemUrl(url: string | null | undefined, files: AttachmentBuilder[]): string | null {
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
if (isLocalAssetUrl(url)) {
|
|
||||||
const filePath = join(process.cwd(), "bot/assets/graphics", stripQuery(url).replace(/^\/?assets\//, ""));
|
|
||||||
if (existsSync(filePath)) {
|
|
||||||
const fileName = defaultName(url);
|
|
||||||
if (!files.find(f => f.name === fileName)) {
|
|
||||||
files.push(new AttachmentBuilder(filePath, { name: fileName }));
|
|
||||||
}
|
|
||||||
return `attachment://${fileName}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveAssetUrl(url);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Verify the file compiles**
|
|
||||||
|
|
||||||
Run: `bunx tsc --noEmit bot/modules/inventory/inventory.view.ts`
|
|
||||||
Expected: No type errors. (Note: `getLootboxResultMessage` remains unchanged below all the new code.)
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add bot/modules/inventory/inventory.view.ts
|
|
||||||
git commit -m "feat(inventory): rewrite inventory view with CV2 list and detail builders"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Create the inventory interaction handler
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `bot/modules/inventory/inventory.interaction.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the interaction handler file**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { StringSelectMenuInteraction, ButtonInteraction, MessageFlags } from "discord.js";
|
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|
||||||
import { getLootboxResultMessage } from "./inventory.view";
|
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
|
||||||
|
|
||||||
export interface InventoryState {
|
|
||||||
ownerId: string;
|
|
||||||
viewerId: string;
|
|
||||||
page: number;
|
|
||||||
selectedItemId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the viewer user ID from an inventory custom ID.
|
|
||||||
* Custom IDs follow the format: inv_{action}_{viewerId}
|
|
||||||
*/
|
|
||||||
export function parseInventoryCustomId(customId: string): { action: string; viewerId: string } | null {
|
|
||||||
const match = customId.match(/^inv_(\w+?)_(\d+)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
return { action: match[1], viewerId: match[2] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a custom ID belongs to the inventory system.
|
|
||||||
*/
|
|
||||||
export function isInventoryInteraction(customId: string): boolean {
|
|
||||||
return customId.startsWith("inv_");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the "Use" button — executes item effects.
|
|
||||||
* Returns the result messages array from inventoryService.useItem,
|
|
||||||
* plus handles role-based effects that require the guild member.
|
|
||||||
*/
|
|
||||||
export async function executeItemUse(
|
|
||||||
interaction: ButtonInteraction,
|
|
||||||
userId: string,
|
|
||||||
itemId: number,
|
|
||||||
): Promise<{ results: any[]; usageData: ItemUsageData | null; item: any }> {
|
|
||||||
const result = await inventoryService.useItem(userId, itemId);
|
|
||||||
|
|
||||||
// Handle role effects (same logic as /use command)
|
|
||||||
const usageData = result.usageData;
|
|
||||||
if (usageData) {
|
|
||||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
|
||||||
const colorRoles = guildConfig.colorRoles ?? [];
|
|
||||||
|
|
||||||
for (const effect of usageData.effects) {
|
|
||||||
if (effect.type === "TEMP_ROLE" || effect.type === "COLOR_ROLE") {
|
|
||||||
try {
|
|
||||||
const member = await interaction.guild?.members.fetch(userId);
|
|
||||||
if (member) {
|
|
||||||
if (effect.type === "TEMP_ROLE") {
|
|
||||||
await member.roles.add(effect.roleId);
|
|
||||||
} else if (effect.type === "COLOR_ROLE") {
|
|
||||||
const rolesToRemove = colorRoles.filter((r: string) => member.roles.cache.has(r));
|
|
||||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
|
||||||
await member.roles.add(effect.roleId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to assign role in inventory use:", e);
|
|
||||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify the file compiles**
|
|
||||||
|
|
||||||
Run: `bunx tsc --noEmit bot/modules/inventory/inventory.interaction.ts`
|
|
||||||
Expected: No type errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add bot/modules/inventory/inventory.interaction.ts
|
|
||||||
git commit -m "feat(inventory): add inventory interaction handler utilities"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Rewrite the inventory command
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Rewrite: `bot/commands/inventory/inventory.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Rewrite the command with subcommands, collector, and interaction routing**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createCommand } from "@shared/lib/utils";
|
|
||||||
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
|
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
|
||||||
import { createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
|
||||||
import {
|
|
||||||
getInventoryListMessage,
|
|
||||||
getEmptyInventoryMessage,
|
|
||||||
getItemDetailMessage,
|
|
||||||
getDiscardConfirmMessage,
|
|
||||||
sortInventoryItems,
|
|
||||||
ITEMS_PER_PAGE,
|
|
||||||
type InventoryEntry,
|
|
||||||
} from "@/modules/inventory/inventory.view";
|
|
||||||
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
|
||||||
import {
|
|
||||||
parseInventoryCustomId,
|
|
||||||
isInventoryInteraction,
|
|
||||||
executeItemUse,
|
|
||||||
} from "@/modules/inventory/inventory.interaction";
|
|
||||||
import { UserError } from "@shared/lib/errors";
|
|
||||||
|
|
||||||
export const inventory = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("inventory")
|
|
||||||
.setDescription("View your or another user's inventory")
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("list")
|
|
||||||
.setDescription("View your or another user's inventory")
|
|
||||||
.addUserOption(option =>
|
|
||||||
option.setName("user")
|
|
||||||
.setDescription("User to view")
|
|
||||||
.setRequired(false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("view")
|
|
||||||
.setDescription("View details of a specific item")
|
|
||||||
.addNumberOption(option =>
|
|
||||||
option.setName("item")
|
|
||||||
.setDescription("The item to view")
|
|
||||||
.setRequired(true)
|
|
||||||
.setAutocomplete(true)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
execute: async (interaction) => {
|
|
||||||
await interaction.deferReply();
|
|
||||||
|
|
||||||
const viewerId = interaction.user.id;
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
if (subcommand === "view") {
|
|
||||||
// Direct item detail view
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
|
||||||
const user = await userService.getOrCreateUser(viewerId, interaction.user.username);
|
|
||||||
if (!user) {
|
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = await inventoryService.getInventory(user.id.toString());
|
|
||||||
const entry = entries.find((e: any) => e.item.id === itemId);
|
|
||||||
if (!entry) {
|
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerId = user.id.toString();
|
|
||||||
let currentPage = 0;
|
|
||||||
let selectedItemId: number | null = itemId;
|
|
||||||
|
|
||||||
const response = await interaction.editReply(
|
|
||||||
getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
|
|
||||||
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "list" subcommand
|
|
||||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
|
||||||
|
|
||||||
if (targetUser.bot) {
|
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
|
||||||
if (!user) {
|
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerId = user.id.toString();
|
|
||||||
const entries = await inventoryService.getInventory(ownerId);
|
|
||||||
|
|
||||||
if (!entries || entries.length === 0) {
|
|
||||||
await interaction.editReply(getEmptyInventoryMessage(user.username));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentPage = 0;
|
|
||||||
let selectedItemId: number | null = null;
|
|
||||||
|
|
||||||
const response = await interaction.editReply(
|
|
||||||
getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
|
|
||||||
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
|
||||||
},
|
|
||||||
autocomplete: async (interaction) => {
|
|
||||||
const focusedValue = interaction.options.getFocused();
|
|
||||||
const userId = interaction.user.id;
|
|
||||||
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
|
||||||
await interaction.respond(results);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function setupCollector(
|
|
||||||
interaction: any,
|
|
||||||
response: any,
|
|
||||||
viewerId: string,
|
|
||||||
ownerId: string,
|
|
||||||
username: string,
|
|
||||||
initialPage: number,
|
|
||||||
initialItemId: number | null,
|
|
||||||
) {
|
|
||||||
let currentPage = initialPage;
|
|
||||||
let selectedItemId = initialItemId;
|
|
||||||
|
|
||||||
const collector = response.createMessageComponentCollector({
|
|
||||||
time: 120_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
collector.on("collect", async (i: any) => {
|
|
||||||
if (i.user.id !== viewerId) return;
|
|
||||||
|
|
||||||
const parsed = parseInventoryCustomId(i.customId);
|
|
||||||
if (!parsed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await i.deferUpdate();
|
|
||||||
|
|
||||||
// Re-fetch inventory for fresh data
|
|
||||||
const entries = await inventoryService.getInventory(ownerId);
|
|
||||||
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
|
||||||
|
|
||||||
switch (parsed.action) {
|
|
||||||
case "select": {
|
|
||||||
const itemId = parseInt(i.values[0]);
|
|
||||||
const entry = sorted.find(e => e.item.id === itemId);
|
|
||||||
if (!entry) break;
|
|
||||||
selectedItemId = itemId;
|
|
||||||
await interaction.editReply(
|
|
||||||
getItemDetailMessage(entry, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "prev": {
|
|
||||||
currentPage = Math.max(0, currentPage - 1);
|
|
||||||
selectedItemId = null;
|
|
||||||
await interaction.editReply(
|
|
||||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "next": {
|
|
||||||
currentPage = currentPage + 1;
|
|
||||||
selectedItemId = null;
|
|
||||||
await interaction.editReply(
|
|
||||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "back": {
|
|
||||||
selectedItemId = null;
|
|
||||||
if (sorted.length === 0) {
|
|
||||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
|
||||||
} else {
|
|
||||||
await interaction.editReply(
|
|
||||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "use": {
|
|
||||||
if (viewerId !== ownerId || !selectedItemId) break;
|
|
||||||
try {
|
|
||||||
const result = await executeItemUse(i, viewerId, selectedItemId);
|
|
||||||
const message = getLootboxResultMessage(result.results, result.item);
|
|
||||||
await interaction.editReply(message as any);
|
|
||||||
|
|
||||||
// After showing result, wait briefly then return to detail or list
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const freshEntries = await inventoryService.getInventory(ownerId);
|
|
||||||
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
|
||||||
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
|
||||||
|
|
||||||
if (freshEntry) {
|
|
||||||
await interaction.editReply(
|
|
||||||
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
selectedItemId = null;
|
|
||||||
if (freshSorted.length === 0) {
|
|
||||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
|
||||||
} else {
|
|
||||||
await interaction.editReply(
|
|
||||||
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}, 3000);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(error.message)],
|
|
||||||
components: [],
|
|
||||||
flags: undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "discard": {
|
|
||||||
if (viewerId !== ownerId || !selectedItemId) break;
|
|
||||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
|
||||||
if (!entry) break;
|
|
||||||
await interaction.editReply(
|
|
||||||
getDiscardConfirmMessage(entry, viewerId)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "discard_confirm": {
|
|
||||||
if (viewerId !== ownerId || !selectedItemId) break;
|
|
||||||
try {
|
|
||||||
await inventoryService.removeItem(ownerId, selectedItemId, 1n);
|
|
||||||
|
|
||||||
const freshEntries = await inventoryService.getInventory(ownerId);
|
|
||||||
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
|
||||||
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
|
||||||
|
|
||||||
if (freshEntry) {
|
|
||||||
await interaction.editReply(
|
|
||||||
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
selectedItemId = null;
|
|
||||||
if (freshSorted.length === 0) {
|
|
||||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
|
||||||
} else {
|
|
||||||
await interaction.editReply(
|
|
||||||
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(error.message)],
|
|
||||||
components: [],
|
|
||||||
flags: undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "discard_cancel": {
|
|
||||||
if (!selectedItemId) break;
|
|
||||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
|
||||||
if (!entry) break;
|
|
||||||
await interaction.editReply(
|
|
||||||
getItemDetailMessage(entry, viewerId, ownerId)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Inventory interaction error:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
collector.on("end", () => {
|
|
||||||
interaction.editReply({ components: [] }).catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify the file compiles**
|
|
||||||
|
|
||||||
Run: `bunx tsc --noEmit bot/commands/inventory/inventory.ts`
|
|
||||||
Expected: No type errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add bot/commands/inventory/inventory.ts
|
|
||||||
git commit -m "feat(inventory): rewrite command with CV2 pagination and detail view"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Integration testing and verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- All modified files
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run the full test suite**
|
|
||||||
|
|
||||||
Run: `bun test`
|
|
||||||
Expected: All tests pass. The inventory service tests should still pass since we didn't change the service.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify TypeScript compiles cleanly**
|
|
||||||
|
|
||||||
Run: `bunx tsc --noEmit`
|
|
||||||
Expected: No type errors across the entire project.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the bot starts**
|
|
||||||
|
|
||||||
Run: `bun --watch bot/index.ts` (start and verify no startup errors, then stop)
|
|
||||||
Expected: Bot initializes and registers commands without errors.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Final commit if any fixes were needed**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "fix(inventory): address integration issues from inventory redesign"
|
|
||||||
```
|
|
||||||
|
|
||||||
(Only if fixes were needed in the previous steps.)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
|||||||
# Lootbox UX Overhaul
|
|
||||||
|
|
||||||
**Date:** 2026-03-18
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The current lootbox system has three UX issues:
|
|
||||||
1. **Pull results are visually flat** — a basic embed with plain text like "You found X!" with no visual differentiation between rarities.
|
|
||||||
2. **Shop loot table formatting is poor** — rewards are dumped as flat text lines grouped by rarity, with no visual hierarchy or scannability.
|
|
||||||
3. **No personality** — opening a lootbox feels like a database query response, not an event.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
**Full Components V2** — both pull results and shop loot tables use Discord's Components V2 system (containers, sections, media galleries, accent colors). No canvas image generation. Keeps the rendering approach consistent, simpler to build and maintain.
|
|
||||||
|
|
||||||
**Instant reveal** — no two-phase animations or button-driven reveals. The result appears immediately; excitement comes from visual quality and rarity theming.
|
|
||||||
|
|
||||||
**Loot table stays in shop only** — not shown in inventory or alongside pull results.
|
|
||||||
|
|
||||||
## Design: Pull Result
|
|
||||||
|
|
||||||
When a user opens a lootbox, the result is displayed as a Components V2 message (`flags: MessageFlags.IsComponentsV2`) with:
|
|
||||||
|
|
||||||
### Container
|
|
||||||
- **Accent color** driven by reward rarity:
|
|
||||||
- `C` (Common): `#95A5A6` (gray)
|
|
||||||
- `R` (Rare): `#3498DB` (blue)
|
|
||||||
- `SR` (Super Rare): `#9B59B6` (purple)
|
|
||||||
- `SSR`: `#F1C40F` (gold)
|
|
||||||
- `CURRENCY`: `#2ECC71` (green)
|
|
||||||
- `XP`: `#1ABC9C` (aqua)
|
|
||||||
- `NOTHING`: `#636363` (dark gray)
|
|
||||||
|
|
||||||
### Header
|
|
||||||
- Subtle context line: source lootbox name (e.g., "Opened: Astral Crate")
|
|
||||||
|
|
||||||
### Section (main content)
|
|
||||||
- **Title format (item rewards):** `🌟 SSR — Celestial Blade` (emoji + rarity + item name)
|
|
||||||
- **Title format (currency):** `💰 You found 1,250 AU!`
|
|
||||||
- **Title format (XP):** `🔮 You gained 500 XP!`
|
|
||||||
- **Title format (nothing):** `💨 Empty...`
|
|
||||||
- **Description:** Item description for items, contextual message for currency/XP/nothing. For NOTHING results, use the custom `lootResult.message` from the handler (falls back to "You found nothing inside.")
|
|
||||||
- **Rarity badge:** Shown as text below description for item rewards (e.g., "SSR" + "×1 added to inventory")
|
|
||||||
- **Thumbnail accessory:** Item icon (via `iconUrl`) when available
|
|
||||||
|
|
||||||
### Media Gallery
|
|
||||||
- If the item has an `imageUrl` different from `iconUrl`, display it in a media gallery below the section for full art showcase.
|
|
||||||
|
|
||||||
### Other Effects
|
|
||||||
- If the lootbox item has non-lootbox effects that also produce results (e.g., a lootbox that also grants XP or a temp role), display these as an additional text display below the main result: "**Other Effects**\n• Gained 100 XP\n• Temporary Role granted for 30m"
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
- **Unknown rarity:** If a reward item's rarity is not in `RARITY_CONFIG`, fall back to Common (`C`) styling.
|
|
||||||
- **Missing icon:** If no `iconUrl` is available, omit the thumbnail accessory entirely (section without accessory).
|
|
||||||
- **Missing image:** If no `imageUrl` is available (or same as `iconUrl`), omit the media gallery.
|
|
||||||
|
|
||||||
## Design: Shop Loot Table
|
|
||||||
|
|
||||||
When viewing a lootbox item in the shop, the listing uses two containers:
|
|
||||||
|
|
||||||
### Container 1: Item Info
|
|
||||||
- **Accent color:** Based on lootbox item's own rarity
|
|
||||||
- **Section:** Item name (heading), description, price
|
|
||||||
- **Thumbnail accessory:** Item icon
|
|
||||||
- **Media gallery:** Item image if different from icon
|
|
||||||
|
|
||||||
### Container 2: Loot Table + Purchase
|
|
||||||
- **Accent color:** Discord blurple (`#5865F2`)
|
|
||||||
- **Header:** `🎁 Loot Table`
|
|
||||||
- **Tiers listed in descending rarity order:** SSR → SR → R → C → Currency → XP → Nothing
|
|
||||||
- **Each tier shows:**
|
|
||||||
- Tier header: emoji + rarity label + aggregated chance percentage (sum of all items in that tier)
|
|
||||||
- Items listed inline, comma-separated (e.g., "Shadow Dagger ×1, Arcane Focus ×1")
|
|
||||||
- **Separators** between tiers for visual scannability
|
|
||||||
- **Tiers with no items are omitted**
|
|
||||||
- **Purchase button:** Action row inside this container with "🛒 Purchase for {price} 🪙" button (success style)
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `bot/modules/inventory/inventory.view.ts` | Replace `getItemUseResultEmbed()` with new Components V2 pull result builder |
|
|
||||||
| `bot/modules/economy/shop.view.ts` | Rework `getShopListingMessage()` loot table section into two-container layout |
|
|
||||||
| `bot/commands/inventory/use.ts` | Update to send Components V2 message with `flags: MessageFlags.IsComponentsV2` instead of embed |
|
|
||||||
| `shared/modules/inventory/effect.handlers.ts` | Modify `handleLootbox` ITEM result to return both `iconUrl` and `imageUrl` separately (currently collapses into single `image` field) |
|
|
||||||
|
|
||||||
## Shared Constants
|
|
||||||
|
|
||||||
The rarity color map and title/emoji map are currently duplicated between `shop.view.ts` and `inventory.view.ts`. Consolidate into a shared location (either a new `shared/lib/rarity.ts` or add to existing `shared/lib/constants.ts`).
|
|
||||||
|
|
||||||
Also consolidate the `defaultName` helper (duplicated in both view files) into a shared utility.
|
|
||||||
|
|
||||||
Rarity display config:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const RARITY_CONFIG: Record<string, { color: number; emoji: string; label: string }> = {
|
|
||||||
C: { color: 0x95A5A6, emoji: "📦", label: "Common" },
|
|
||||||
R: { color: 0x3498DB, emoji: "📦", label: "Rare" },
|
|
||||||
SR: { color: 0x9B59B6, emoji: "✨", label: "Super Rare" },
|
|
||||||
SSR: { color: 0xF1C40F, emoji: "🌟", label: "SSR" },
|
|
||||||
CURRENCY: { color: 0x2ECC71, emoji: "💰", label: "Currency" },
|
|
||||||
XP: { color: 0x1ABC9C, emoji: "🔮", label: "Experience" },
|
|
||||||
NOTHING: { color: 0x636363, emoji: "💨", label: "Empty" },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Loot table visibility in inventory or pull results
|
|
||||||
- Canvas-based image generation for pulls
|
|
||||||
- Two-phase or button-driven reveal mechanics
|
|
||||||
- Lootdrop system changes (channel activity drops are separate)
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Existing lootbox tests should continue to pass (effect handler return shape changes are additive)
|
|
||||||
- Manual testing needed for visual output in Discord (Components V2 rendering)
|
|
||||||
- Verify all reward types render correctly: ITEM (all rarities), CURRENCY, XP, NOTHING
|
|
||||||
- Verify shop listing renders cleanly with various loot table sizes (1 tier, all tiers, many items per tier)
|
|
||||||
- Verify "other effects" display when lootbox item has multiple effect types
|
|
||||||
- Verify fallback behavior for items with unknown rarity, missing icons, missing images
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# Impersonate Panel — Design Spec
|
|
||||||
|
|
||||||
A Discohook-style webhook message editor inside the Aurora admin panel for sending messages as custom characters, with reusable presets.
|
|
||||||
|
|
||||||
## Summary of Decisions
|
|
||||||
|
|
||||||
| Decision | Choice |
|
|
||||||
|----------|--------|
|
|
||||||
| Channel targeting | Pick channel each time (dropdown) |
|
|
||||||
| Preset storage | PostgreSQL (new table) |
|
|
||||||
| Editor layout | Side-by-side (builder left, preview right) |
|
|
||||||
| Component adding | Drag & drop from palette |
|
|
||||||
| Preset management | Separate "Presets" tab with card grid |
|
|
||||||
| JSON editing | Bidirectional visual ↔ JSON toggle |
|
|
||||||
| Format support | Classic (content + embeds) AND Components V2 |
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### New table: `webhook_presets`
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | serial PK | Auto-increment ID |
|
|
||||||
| `name` | varchar(100) | Preset display name |
|
|
||||||
| `username` | varchar(80) | Webhook display name |
|
|
||||||
| `avatar_url` | text, nullable | Avatar image URL |
|
|
||||||
| `payload` | jsonb | Full webhook payload (content, embeds, components) |
|
|
||||||
| `created_by` | bigint | Discord user ID of creator |
|
|
||||||
| `created_at` | timestamp | Creation time |
|
|
||||||
| `updated_at` | timestamp | Last modified |
|
|
||||||
|
|
||||||
The `payload` column stores the complete webhook JSON body and is the source of truth. The visual editor reads/writes this JSONB directly.
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- `created_by` uses `bigint('created_by', { mode: 'bigint' })` with a foreign key reference to `users.id` (`onDelete: CASCADE`)
|
|
||||||
- `created_at` uses `.defaultNow()`
|
|
||||||
- `updated_at` is set by the application on every write (no database trigger)
|
|
||||||
- No `guild_id` scoping — Aurora is a single-guild bot
|
|
||||||
- The new schema file must be re-exported from `shared/db/schema/index.ts`
|
|
||||||
- Backend validation: reject payloads larger than 100KB
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
All endpoints are protected behind existing admin auth.
|
|
||||||
|
|
||||||
### Presets CRUD
|
|
||||||
|
|
||||||
- `GET /api/impersonate/presets` — list all presets
|
|
||||||
- `POST /api/impersonate/presets` — create preset
|
|
||||||
- `PUT /api/impersonate/presets/:id` — update preset
|
|
||||||
- `DELETE /api/impersonate/presets/:id` — delete preset
|
|
||||||
|
|
||||||
### Sending
|
|
||||||
|
|
||||||
- `POST /api/impersonate/send` — send webhook message to a channel
|
|
||||||
- Body: `{ channelId: string, username: string, avatarUrl?: string, payload: object }`
|
|
||||||
- **Bridge pattern:** The route handler imports `BotClient` to resolve the channel by ID (`client.channels.fetch(channelId)`) and obtain the client user. These discord.js objects are passed to the existing `sendWebhookMessage` utility from `bot/lib/webhookUtils.ts`. This is acceptable because `api/` already runs in the same Bun process as the bot.
|
|
||||||
|
|
||||||
### Channels
|
|
||||||
|
|
||||||
- `GET /api/impersonate/channels` — fetch guild text channels for the channel picker
|
|
||||||
- Returns `{ id, name, parentName }` grouped by category
|
|
||||||
|
|
||||||
## Frontend Architecture
|
|
||||||
|
|
||||||
### Page Structure
|
|
||||||
|
|
||||||
Two tabs at the top of the Impersonate page: **Editor** and **Presets**.
|
|
||||||
|
|
||||||
### Editor Tab (Side-by-Side)
|
|
||||||
|
|
||||||
**Left pane — Builder:**
|
|
||||||
|
|
||||||
- **Top bar:** Username input, avatar URL input, channel dropdown, format toggle (Classic / Components V2), JSON/Visual toggle
|
|
||||||
- **Component palette:** Draggable component types. Components V2: Text Display, Section, Media Gallery, Separator, Container, File, Action Row. Classic: Content, Embed
|
|
||||||
- **Message canvas:** Drop zone where components are arranged. Each dropped component expands into an inline collapsible form editor. Drag to reorder via `@dnd-kit/core`
|
|
||||||
- **Bottom bar:** "Send" button and "Save as Preset" button
|
|
||||||
|
|
||||||
**Right pane — Preview:**
|
|
||||||
|
|
||||||
- Discord-styled message preview (dark theme, `#313338` background)
|
|
||||||
- Avatar circle + username + timestamp header
|
|
||||||
- Live-renders the current payload on every change
|
|
||||||
- Visual approximation of Discord's rendering, not pixel-perfect
|
|
||||||
|
|
||||||
### JSON Mode
|
|
||||||
|
|
||||||
Toggling to JSON replaces the visual builder with a monospace code editor. Edits sync bidirectionally. Invalid JSON shows an inline error and blocks switching back to visual mode until fixed.
|
|
||||||
|
|
||||||
### Presets Tab
|
|
||||||
|
|
||||||
- Card grid of saved presets showing avatar, name, and truncated payload preview
|
|
||||||
- Click a card to load it into the editor tab
|
|
||||||
- Edit/delete actions on each card
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
panel/src/
|
|
||||||
├── pages/
|
|
||||||
│ ├── Impersonate.tsx # Main page, tab switching, top-level state
|
|
||||||
│ └── impersonate/
|
|
||||||
│ ├── Editor.tsx # Side-by-side builder + preview layout
|
|
||||||
│ ├── Preview.tsx # Discord-style message renderer
|
|
||||||
│ ├── Presets.tsx # Preset card grid
|
|
||||||
│ ├── ComponentPalette.tsx # Draggable component type list
|
|
||||||
│ └── components/
|
|
||||||
│ ├── TextDisplayEditor.tsx
|
|
||||||
│ ├── SectionEditor.tsx
|
|
||||||
│ ├── MediaGalleryEditor.tsx
|
|
||||||
│ ├── SeparatorEditor.tsx
|
|
||||||
│ ├── ContainerEditor.tsx
|
|
||||||
│ ├── FileEditor.tsx
|
|
||||||
│ ├── ActionRowEditor.tsx
|
|
||||||
│ ├── EmbedEditor.tsx # Classic mode
|
|
||||||
│ └── ContentEditor.tsx # Classic mode
|
|
||||||
├── lib/
|
|
||||||
│ └── useImpersonate.ts # API hook for presets CRUD + send + channels
|
|
||||||
```
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
```
|
|
||||||
shared/db/schema/
|
|
||||||
│ └── webhook-presets.ts # New schema file (re-export from index.ts)
|
|
||||||
|
|
||||||
shared/modules/impersonate/
|
|
||||||
│ └── impersonate.service.ts # Preset CRUD + send logic
|
|
||||||
|
|
||||||
api/src/routes/
|
|
||||||
│ └── impersonate.routes.ts # Route handler (register in index.ts protectedRoutes)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Panel Wiring
|
|
||||||
|
|
||||||
- Add `"impersonate"` to the `Page` union type in `Layout.tsx`
|
|
||||||
- Add nav item to `navItems` array in `Layout.tsx` with appropriate Lucide icon
|
|
||||||
- Add conditional render branch in `App.tsx`
|
|
||||||
|
|
||||||
**Note:** The `pages/impersonate/` sub-directory is a new pattern — existing pages are flat files. This is justified by the complexity of this feature (9+ component files). Flat pages remain appropriate for simpler pages.
|
|
||||||
|
|
||||||
## Component Editors
|
|
||||||
|
|
||||||
Each component type gets an inline collapsible editor card on the canvas.
|
|
||||||
|
|
||||||
### Components V2
|
|
||||||
|
|
||||||
| Component | Editable Fields |
|
|
||||||
|-----------|----------------|
|
|
||||||
| **Text Display** | Markdown content textarea |
|
|
||||||
| **Section** | Text content, accessory type (button or thumbnail), accessory config (URL, label, style) |
|
|
||||||
| **Media Gallery** | List of media items: URL, alt text, spoiler toggle. Add/remove items |
|
|
||||||
| **Separator** | Spacing size toggle (small/large) |
|
|
||||||
| **Container** | Accent color picker, nested drop zone (accepts Text Display, Section, Media Gallery, Separator, Action Row, File) |
|
|
||||||
| **File** | URL input, filename |
|
|
||||||
| **Action Row** | Buttons: label, style (Primary/Secondary/Success/Danger/Link), URL/custom ID, emoji, disabled toggle. Select menus: placeholder, options list, min/max values |
|
|
||||||
|
|
||||||
### Classic Mode
|
|
||||||
|
|
||||||
| Component | Editable Fields |
|
|
||||||
|-----------|----------------|
|
|
||||||
| **Content** | Markdown textarea |
|
|
||||||
| **Embed** | Title, description, URL, color picker, timestamp, author (name, icon URL), footer (text, icon URL), image URL, thumbnail URL, fields (array of name, value, inline toggle) |
|
|
||||||
|
|
||||||
### Webhook-Level Options
|
|
||||||
|
|
||||||
- `tts` toggle
|
|
||||||
- `thread_name` input (for forum channels)
|
|
||||||
- `flags` (suppress embeds/notifications)
|
|
||||||
- When Components V2 format is selected, the payload must include `flags: 32768` (`IS_COMPONENTS_V2` flag, `1 << 15`). This is set automatically by the editor when the format toggle is on Components V2.
|
|
||||||
|
|
||||||
## Preview Renderer
|
|
||||||
|
|
||||||
Renders a Discord-style message mock in the right pane:
|
|
||||||
|
|
||||||
- Dark background (`#313338`)
|
|
||||||
- Avatar circle + username + "Today at HH:MM" timestamp header
|
|
||||||
- Components V2: containers with accent-colored left border, text blocks with markdown rendering, media gallery as responsive image grid, buttons as pill-shaped elements with Discord color scheme, separators as horizontal rules
|
|
||||||
- Classic: content as rendered markdown, embeds with colored left border, field grids, inline images
|
|
||||||
- Live updates on every change
|
|
||||||
|
|
||||||
This is a visual approximation for authoring purposes, not a 1:1 Discord replica.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
| Scenario | Behavior |
|
|
||||||
|----------|----------|
|
|
||||||
| Invalid JSON on toggle | Show inline error, block switch to visual until fixed |
|
|
||||||
| Send failure | Display Discord API error message inline (e.g., "Missing permissions") |
|
|
||||||
| Empty payload | Disable Send button |
|
|
||||||
| Discord payload limits | Validate against limits (6000 char embeds, 10 components per action row, 5 action rows) and show warnings |
|
|
||||||
| Channel permission errors | Surface "Bot lacks MANAGE_WEBHOOKS permission" clearly |
|
|
||||||
| Invalid avatar URL | Lightweight `https://` check; Discord rejects bad URLs on send |
|
|
||||||
| Preset name collision | Allowed — presets identified by ID |
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- `@dnd-kit/core` + `@dnd-kit/sortable` — drag and drop
|
|
||||||
- No other new dependencies expected; existing stack (React, Tailwind, Lucide) covers the rest
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Inventory Display Redesign
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Redesign the `/inventory` command from a basic embed listing to a polished Components V2 experience with rarity indicators, paginated list view, item detail view with artwork, and inline item management actions.
|
|
||||||
|
|
||||||
## Rarity Emoji Mapping
|
|
||||||
|
|
||||||
Add a `squareEmoji` field to `RARITY_CONFIG` in `shared/lib/rarity.ts`:
|
|
||||||
|
|
||||||
| Rarity | squareEmoji | Existing emoji | Color hex |
|
|
||||||
|--------|-------------|----------------|-----------|
|
|
||||||
| C | 🟤 | 📦 | 0x95A5A6 |
|
|
||||||
| R | 🔵 | 📦 | 0x3498DB |
|
|
||||||
| SR | 🟣 | ✨ | 0x9B59B6 |
|
|
||||||
| SSR | 🟡 | 🌟 | 0xF1C40F |
|
|
||||||
|
|
||||||
Non-item rarities (CURRENCY, XP, NOTHING) do not get square emojis. The existing `emoji` field remains unchanged (used by lootbox results).
|
|
||||||
|
|
||||||
## List View
|
|
||||||
|
|
||||||
The `/inventory [user]` command renders a Components V2 message:
|
|
||||||
|
|
||||||
1. **Header** — `TextDisplayBuilder`: `# 📦 {username}'s Inventory` with subtitle showing total item count.
|
|
||||||
2. **Separator**
|
|
||||||
3. **Item rows** (5 per page) — Each item is a `TextDisplayBuilder` line: `{squareEmoji} **{Item Name}** — {Rarity Label} · {Type} · ×{quantity}`
|
|
||||||
4. **Separator**
|
|
||||||
5. **Select menu** — `StringSelectMenuBuilder` populated with the 5 items on the current page. Placeholder: "Select an item for details". Each option shows item name and rarity label.
|
|
||||||
6. **Navigation row** — `ActionRowBuilder`: `◀ Previous` (disabled on page 1), disabled `Page X/Y` indicator button, `Next ▶` (disabled on last page).
|
|
||||||
|
|
||||||
**Container:** `ContainerBuilder` with accent color from the highest-rarity item on the current page.
|
|
||||||
|
|
||||||
**Sorting:** Items sorted by rarity descending (SSR → SR → R → C), then alphabetically within the same rarity.
|
|
||||||
|
|
||||||
**Empty state:** If inventory is empty, show: "No items yet. Visit the shop or complete quests to earn items!"
|
|
||||||
|
|
||||||
**Collector:** `createMessageComponentCollector` with 2-minute idle timeout. On timeout, disable all interactive components.
|
|
||||||
|
|
||||||
## Detail View
|
|
||||||
|
|
||||||
Shown when a user selects an item from the dropdown or uses `/inventory view <item>`:
|
|
||||||
|
|
||||||
1. **Header section** — `SectionBuilder`:
|
|
||||||
- `TextDisplayBuilder`: `{squareEmoji} **{Item Name}**` with subtitle `-# {Rarity Label} · {Type}`
|
|
||||||
- `ThumbnailBuilder` with the item's `iconUrl`
|
|
||||||
2. **Artwork** — `MediaGalleryBuilder` displaying the item's `imageUrl`
|
|
||||||
3. **Description** — `TextDisplayBuilder` with the item's `description`
|
|
||||||
4. **Separator**
|
|
||||||
5. **Stats row** — `TextDisplayBuilder`: `Owned: **×{quantity}**` and `Value: **{price} 🪙**` (or "Not tradeable" if price is null)
|
|
||||||
6. **Action buttons** — `ActionRowBuilder`:
|
|
||||||
- `◀ Back` (primary) — always shown, returns to list view at the same page
|
|
||||||
- `🧪 Use` (success) — only shown if **viewer is the owner** AND item type is CONSUMABLE with effects defined
|
|
||||||
- `🗑 Discard` (danger) — only shown if **viewer is the owner**
|
|
||||||
|
|
||||||
**Container:** `ContainerBuilder` with accent color matching the item's rarity color.
|
|
||||||
|
|
||||||
### Ownership Protection
|
|
||||||
|
|
||||||
The command tracks two IDs: `viewerId` (who ran the command) and `ownerId` (whose inventory is displayed). When `viewerId !== ownerId`, the inventory is **read-only**:
|
|
||||||
- The detail view only shows the Back button (no Use or Discard).
|
|
||||||
- The interaction handler validates `viewerId === ownerId` before executing `useItem` or `removeItem`, as a server-side guard even if the buttons were somehow rendered.
|
|
||||||
|
|
||||||
### Use Button Flow
|
|
||||||
|
|
||||||
Calls `inventoryService.useItem()` and shows the result inline. Then returns to the detail view with updated quantity. If quantity reaches 0, returns to the list view.
|
|
||||||
|
|
||||||
### Discard Flow
|
|
||||||
|
|
||||||
1. Clicking `🗑 Discard` replaces the action row with a confirmation: "Discard 1× {Item Name}?" with `Confirm` (danger) and `Cancel` (secondary) buttons.
|
|
||||||
2. On confirm: calls `inventoryService.removeItem(userId, itemId, 1)`, returns to detail view with updated quantity. If quantity reaches 0, returns to list view.
|
|
||||||
3. On cancel: returns to the normal detail view action buttons.
|
|
||||||
|
|
||||||
## `/inventory view <item>` Subcommand
|
|
||||||
|
|
||||||
Adds a `view` subcommand with a required `item` string option that has autocomplete. Autocomplete queries the user's inventory items (reusing the pattern from `getAutocompleteItems`). Goes directly to the detail view. The Back button returns to the full paginated list at page 1.
|
|
||||||
|
|
||||||
## Item Selection Entry Points
|
|
||||||
|
|
||||||
Two ways to reach the detail view:
|
|
||||||
- **Select menu dropdown** on the inventory list — for browsing
|
|
||||||
- **`/inventory view <item>`** subcommand — for direct access when the user knows the item name
|
|
||||||
|
|
||||||
Both render the same detail view.
|
|
||||||
|
|
||||||
## Interaction Custom IDs
|
|
||||||
|
|
||||||
All custom IDs include the invoking user's ID to prevent other users from interacting:
|
|
||||||
|
|
||||||
| Custom ID | Purpose |
|
|
||||||
|-----------|---------|
|
|
||||||
| `inv_select_{userId}` | Item select menu |
|
|
||||||
| `inv_prev_{userId}` | Previous page button |
|
|
||||||
| `inv_next_{userId}` | Next page button |
|
|
||||||
| `inv_back_{userId}` | Back to list from detail |
|
|
||||||
| `inv_use_{userId}` | Use item button |
|
|
||||||
| `inv_discard_{userId}` | Discard item button |
|
|
||||||
| `inv_discard_confirm_{userId}` | Confirm discard |
|
|
||||||
| `inv_discard_cancel_{userId}` | Cancel discard |
|
|
||||||
|
|
||||||
## File Changes
|
|
||||||
|
|
||||||
### Modified
|
|
||||||
|
|
||||||
- **`shared/lib/rarity.ts`** — Add `squareEmoji` field to `RARITY_CONFIG` entries for C, R, SR, SSR.
|
|
||||||
- **`bot/commands/inventory/inventory.ts`** — Rewrite to CV2 with pagination collector. Add `view` subcommand with autocomplete. Command setup and collector logic live here.
|
|
||||||
- **`bot/modules/inventory/inventory.view.ts`** — Replace `getInventoryEmbed` with `getInventoryListMessage` (builds the paginated CV2 list) and add `getItemDetailMessage` (builds the detail CV2 view). `getLootboxResultMessage` is untouched.
|
|
||||||
|
|
||||||
### New
|
|
||||||
|
|
||||||
- **`bot/modules/inventory/inventory.interaction.ts`** — Handles all inventory interaction routing: select menu item selection, pagination buttons, back navigation, use item, discard + confirmation flow.
|
|
||||||
|
|
||||||
### Unchanged
|
|
||||||
|
|
||||||
- `shared/modules/inventory/inventory.service.ts` — Already provides `getInventory`, `useItem`, `removeItem`, `getAutocompleteItems`.
|
|
||||||
- Database schema — All required fields (`iconUrl`, `imageUrl`, `description`, `rarity`, `type`, `price`) already exist on the items table.
|
|
||||||
|
|
||||||
## Pagination Details
|
|
||||||
|
|
||||||
- **Items per page:** 5
|
|
||||||
- **Page calculation:** `totalPages = Math.ceil(items.length / 5)`
|
|
||||||
- **Page clamping:** `safePage = Math.min(page, totalPages - 1)` to handle items being consumed while browsing
|
|
||||||
- **Collector timeout:** 2 minutes idle, matching the quest system pattern
|
|
||||||
- **On timeout:** Edit message to disable all buttons and the select menu
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
# Web Games Platform Design
|
|
||||||
|
|
||||||
**Date:** 2026-04-02
|
|
||||||
**Status:** Draft
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Extend the Aurora web panel beyond admin-only use. Enrolled bot players get a player dashboard (stats, inventory, activity) and access to a multiplayer game system. Admins keep the existing admin panel unchanged. Games are built as plugins — adding a new game requires implementing a defined interface without touching server or framework code.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Discord OAuth open to all enrolled bot users (exist in `users` table), not just admins
|
|
||||||
- Two-tier experience: admins see admin panel under `/admin/*`, players see player dashboard + games
|
|
||||||
- Explicit room creation with browsing — no matchmaking
|
|
||||||
- Session-only (in-memory) game state — no database persistence for games
|
|
||||||
- Open spectating on all games
|
|
||||||
- Shareable room URLs: `/:gameSlug/:roomId`
|
|
||||||
- Plugin architecture so adding a game is self-contained
|
|
||||||
|
|
||||||
## Auth & Role System
|
|
||||||
|
|
||||||
### Changes to OAuth Flow
|
|
||||||
|
|
||||||
The current OAuth callback in `auth.routes.ts` checks `ADMIN_USER_IDS` and returns 403 for non-admins. This changes to:
|
|
||||||
|
|
||||||
1. Any Discord user can complete OAuth (remove the 403 gate)
|
|
||||||
2. After getting Discord user info, look up their Discord ID in the `users` table
|
|
||||||
3. **Not enrolled** (no row): do not create a session. Return `{ authenticated: false, enrolled: false }`
|
|
||||||
4. **Enrolled**: create a session with a `role` field — `"admin"` if their ID is in `ADMIN_USER_IDS`, otherwise `"player"`
|
|
||||||
|
|
||||||
### Session Shape
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
{ discordId, username, avatar, expiresAt }
|
|
||||||
|
|
||||||
// After
|
|
||||||
{ discordId, username, avatar, role: "admin" | "player", expiresAt }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `/auth/me` Response
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{ authenticated: boolean, enrolled: boolean, user?: { discordId, username, avatar, role } }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Panel Behavior
|
|
||||||
|
|
||||||
- Not authenticated → redirect to Discord OAuth
|
|
||||||
- Authenticated but not enrolled → "You need to use the bot first" page
|
|
||||||
- Authenticated + enrolled as player → player experience
|
|
||||||
- Authenticated + enrolled as admin → admin experience
|
|
||||||
|
|
||||||
## Routing
|
|
||||||
|
|
||||||
### Adopting React Router
|
|
||||||
|
|
||||||
The current state-based page switching (`setPage()`) cannot handle URL paths. Adopt React Router for:
|
|
||||||
|
|
||||||
- URL-based navigation with params (`:roomId`, `:gameSlug`)
|
|
||||||
- Shareable game room links
|
|
||||||
- Clean separation of admin vs player route trees
|
|
||||||
|
|
||||||
### URL Structure
|
|
||||||
|
|
||||||
| Path | Role | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `/` | any | Redirect: admins → `/admin`, players → `/dashboard` |
|
|
||||||
| `/dashboard` | player | Player dashboard (stats, inventory, activity) |
|
|
||||||
| `/games` | player | Game lobby — browse/create rooms |
|
|
||||||
| `/:gameSlug/:roomId` | any | Game room (play or spectate) |
|
|
||||||
| `/admin` | admin | Admin dashboard (existing) |
|
|
||||||
| `/admin/users` | admin | User management (existing) |
|
|
||||||
| `/admin/items` | admin | Item management (existing) |
|
|
||||||
| `/admin/settings` | admin | Settings (existing) |
|
|
||||||
| `/admin/classes` | admin | Class management (existing) |
|
|
||||||
| `/admin/quests` | admin | Quest management (existing) |
|
|
||||||
| `/admin/lootdrops` | admin | Lootdrop management (existing) |
|
|
||||||
| `/admin/moderation` | admin | Moderation cases (existing) |
|
|
||||||
| `/admin/transactions` | admin | Transaction log (existing) |
|
|
||||||
|
|
||||||
Game room URLs (`/:gameSlug/:roomId`) are accessible to any authenticated+enrolled user — both players and admins can play.
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
- Shared shell: header bar, sidebar container, user section
|
|
||||||
- **Player sidebar:** Dashboard, Games, Leaderboards
|
|
||||||
- **Admin sidebar:** existing nav items, plus a Games link so admins can play too
|
|
||||||
- Sidebar renders different nav items based on `role` from `useAuth`
|
|
||||||
|
|
||||||
## WebSocket Architecture
|
|
||||||
|
|
||||||
### Channel Design
|
|
||||||
|
|
||||||
Extend the existing `/ws` endpoint (Bun native WebSocket in `server.ts`):
|
|
||||||
|
|
||||||
| Channel | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `dashboard` | Existing admin stats broadcast (unchanged) |
|
|
||||||
| `lobby` | Room list updates — room created, closed, player count changes |
|
|
||||||
| `room:<roomId>` | Per-room game events |
|
|
||||||
|
|
||||||
### Server → Client Messages
|
|
||||||
|
|
||||||
| Type | Channel | Payload |
|
|
||||||
|---|---|---|
|
|
||||||
| `STATS_UPDATE` | `dashboard` | Existing dashboard stats |
|
|
||||||
| `ROOM_LIST_UPDATE` | `lobby` | `{ rooms: RoomSummary[] }` |
|
|
||||||
| `GAME_STATE` | `room:<id>` | Full view-filtered state snapshot |
|
|
||||||
| `GAME_UPDATE` | `room:<id>` | New view-filtered state after action |
|
|
||||||
| `PLAYER_JOINED` | `room:<id>` | `{ player: PlayerInfo, as: "player" \| "spectator" }` |
|
|
||||||
| `PLAYER_LEFT` | `room:<id>` | `{ playerId: string }` |
|
|
||||||
| `GAME_STARTED` | `room:<id>` | Initial game state |
|
|
||||||
| `GAME_ENDED` | `room:<id>` | `{ winner: string \| null, reason: string }` |
|
|
||||||
| `ERROR` | direct to sender | `{ message: string }` |
|
|
||||||
|
|
||||||
### Client → Server Messages
|
|
||||||
|
|
||||||
| Type | Payload |
|
|
||||||
|---|---|
|
|
||||||
| `JOIN_ROOM` | `{ roomId, as: "player" \| "spectator" }` |
|
|
||||||
| `LEAVE_ROOM` | `{ roomId }` |
|
|
||||||
| `GAME_ACTION` | `{ roomId, action: { type, ...data } }` |
|
|
||||||
| `CREATE_ROOM` | `{ gameType }` |
|
|
||||||
| `PING` | (existing heartbeat) |
|
|
||||||
|
|
||||||
### Connection Model
|
|
||||||
|
|
||||||
- One WebSocket connection per client (shared across dashboard, lobby, and game rooms)
|
|
||||||
- Auth: validate session cookie on WS upgrade, attach `discordId` and `role` to socket
|
|
||||||
- A player can only be *playing* in one room at a time
|
|
||||||
- Max connections limit raised from 10 to 200
|
|
||||||
|
|
||||||
## Game Plugin Interface
|
|
||||||
|
|
||||||
### Server-Side (`shared/games/`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface GamePlugin<TState, TAction> {
|
|
||||||
slug: string; // URL segment: "chess", "blackjack"
|
|
||||||
name: string; // Display name: "Chess", "Blackjack"
|
|
||||||
minPlayers: number;
|
|
||||||
maxPlayers: number;
|
|
||||||
|
|
||||||
createInitialState(players: string[]): TState;
|
|
||||||
handleAction(state: TState, action: TAction, playerId: string): GameResult<TState>;
|
|
||||||
getPlayerView(state: TState, playerId: string): Partial<TState>;
|
|
||||||
getSpectatorView(state: TState): Partial<TState>;
|
|
||||||
|
|
||||||
isGameOver?(state: TState): GameOverResult | null;
|
|
||||||
onPlayerDisconnect?(state: TState, playerId: string): TState;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameResult<TState> =
|
|
||||||
| { ok: true; state: TState }
|
|
||||||
| { ok: false; error: string };
|
|
||||||
|
|
||||||
type GameOverResult = {
|
|
||||||
winner: string | null; // playerId or null for draw
|
|
||||||
reason: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Key properties:
|
|
||||||
- `handleAction` is a **pure function** — state in, state out. No side effects, trivially testable.
|
|
||||||
- `getPlayerView` handles information hiding — chess shows everything, blackjack hides opponent hands.
|
|
||||||
- `getSpectatorView` provides a public-safe view.
|
|
||||||
- Generic `TState`/`TAction` types give each game full type safety.
|
|
||||||
|
|
||||||
### Client-Side (`panel/src/games/`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface GameUIPlugin {
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
component: React.ComponentType<GameUIProps>;
|
|
||||||
icon?: React.ComponentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameUIProps {
|
|
||||||
state: unknown;
|
|
||||||
myPlayerId: string;
|
|
||||||
isSpectator: boolean;
|
|
||||||
onAction: (action: unknown) => void;
|
|
||||||
players: PlayerInfo[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Game components receive state and an action callback. No networking code — that's handled by the `useGameRoom` hook.
|
|
||||||
|
|
||||||
### Registry
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// shared/games/registry.ts
|
|
||||||
const games = new Map<string, GamePlugin<any, any>>();
|
|
||||||
|
|
||||||
export const gameRegistry = {
|
|
||||||
register(plugin: GamePlugin<any, any>) { games.set(plugin.slug, plugin); },
|
|
||||||
get(slug: string) { return games.get(slug); },
|
|
||||||
list() { return Array.from(games.values()); },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Server uses `gameRegistry.get(slug)` to route actions. Lobby uses `gameRegistry.list()` to show available game types. Client has an equivalent `gameUIRegistry`.
|
|
||||||
|
|
||||||
## Room Management
|
|
||||||
|
|
||||||
### Room Data Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Room {
|
|
||||||
id: string; // UUID
|
|
||||||
gameSlug: string;
|
|
||||||
host: string; // discordId of creator
|
|
||||||
players: string[]; // discordIds of active players
|
|
||||||
spectators: Set<string>;
|
|
||||||
state: unknown; // opaque game state
|
|
||||||
status: "waiting" | "playing" | "finished";
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lifecycle
|
|
||||||
|
|
||||||
1. **Create** — player sends `CREATE_ROOM { gameType }`. RoomManager generates UUID, creates room in `"waiting"` status, adds creator as first player. Broadcasts `ROOM_LIST_UPDATE` to lobby. Returns room ID — client navigates to `/:gameSlug/:roomId`.
|
|
||||||
|
|
||||||
2. **Join** — player sends `JOIN_ROOM { roomId, as: "player" }`. Validates room exists, not full, status is `"waiting"`. Adds to `players`. Broadcasts `PLAYER_JOINED`. If `players.length === maxPlayers`, auto-start.
|
|
||||||
|
|
||||||
3. **Start** — calls `plugin.createInitialState(players)`, sets status to `"playing"`. Sends each player their `getPlayerView`, spectators their `getSpectatorView`.
|
|
||||||
|
|
||||||
4. **Action** — player sends `GAME_ACTION { roomId, action }`. RoomManager calls `plugin.handleAction(state, action, playerId)`. If `ok`: update state, check `isGameOver()`, broadcast views. If not `ok`: send `ERROR` to that player only.
|
|
||||||
|
|
||||||
5. **End** — `isGameOver()` returns a result. Set status to `"finished"`, broadcast `GAME_ENDED`. Clean up room after 60 seconds.
|
|
||||||
|
|
||||||
6. **Disconnect** — if player's WebSocket closes, call `plugin.onPlayerDisconnect()` if defined. Remove from room after a short grace period.
|
|
||||||
|
|
||||||
### Cleanup
|
|
||||||
|
|
||||||
- Rooms in `"waiting"` with no players for 60 seconds: deleted
|
|
||||||
- Rooms in `"finished"`: deleted after 60 seconds
|
|
||||||
- Server restart clears all rooms (session-only)
|
|
||||||
|
|
||||||
## Panel Pages
|
|
||||||
|
|
||||||
### Player Dashboard (`/dashboard`)
|
|
||||||
|
|
||||||
- 4 stat cards: Level, Gold, Items, Games Won (gradient cards with left-border accents matching existing dashboard style)
|
|
||||||
- Recent Activity list (card with timestamped entries)
|
|
||||||
- Inventory preview (card with item list, equipped badges)
|
|
||||||
- Data sourced from existing endpoints: `GET /api/users/:id`, `GET /api/users/:id/inventory`
|
|
||||||
|
|
||||||
### Game Lobby (`/games`)
|
|
||||||
|
|
||||||
- Filter tabs by game type (generated from `gameUIRegistry.list()`)
|
|
||||||
- Room list: each row shows game icon, room name, status badge (Waiting/Playing), player count, spectator count
|
|
||||||
- "Waiting" rooms show Join button, "Playing" rooms show Spectate button
|
|
||||||
- Create Room button opens a dialog: pick game type, room name
|
|
||||||
- Live updates via `lobby` WebSocket channel
|
|
||||||
|
|
||||||
### Game Room (`/:gameSlug/:roomId`)
|
|
||||||
|
|
||||||
- Room header: game icon, room name, status badge, spectator count, Leave/Forfeit buttons
|
|
||||||
- Center: the game plugin's React component (rendered via `gameUIRegistry.get(slug).component`)
|
|
||||||
- Side panel: Players list (with online indicators), Spectators list, game-specific info (e.g. move history for chess)
|
|
||||||
- All updates via `room:<roomId>` WebSocket channel
|
|
||||||
- Room not found: friendly error page with link back to lobby
|
|
||||||
|
|
||||||
### Shared Hook: `useGameRoom`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function useGameRoom(roomId: string): {
|
|
||||||
gameState: unknown;
|
|
||||||
players: PlayerInfo[];
|
|
||||||
spectators: PlayerInfo[];
|
|
||||||
roomStatus: "waiting" | "playing" | "finished";
|
|
||||||
isSpectator: boolean;
|
|
||||||
myPlayerId: string;
|
|
||||||
sendAction: (action: unknown) => void;
|
|
||||||
leaveRoom: () => void;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
On mount: sends `JOIN_ROOM`. Subscribes to room messages. On unmount: sends `LEAVE_ROOM`. The game component just reads `gameState` and calls `sendAction`.
|
|
||||||
|
|
||||||
### Shared Hook: `useWebSocket`
|
|
||||||
|
|
||||||
Manages the single WebSocket connection for the entire panel:
|
|
||||||
- Connects after auth
|
|
||||||
- Reconnects with exponential backoff
|
|
||||||
- Routes messages to subscribers by type/channel
|
|
||||||
- Used by dashboard (existing stats), lobby (room list), and game rooms
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
shared/games/
|
|
||||||
types.ts — GamePlugin, GameResult, GameOverResult
|
|
||||||
registry.ts — gameRegistry singleton
|
|
||||||
chess/
|
|
||||||
plugin.ts — Chess GamePlugin implementation
|
|
||||||
types.ts — ChessState, ChessAction
|
|
||||||
plugin.test.ts
|
|
||||||
blackjack/
|
|
||||||
plugin.ts — Blackjack GamePlugin implementation
|
|
||||||
types.ts — BlackjackState, BlackjackAction
|
|
||||||
plugin.test.ts
|
|
||||||
|
|
||||||
api/src/games/
|
|
||||||
RoomManager.ts — Room CRUD, action routing, cleanup
|
|
||||||
RoomManager.test.ts
|
|
||||||
types.ts — Room, RoomEvent types
|
|
||||||
ws-handler.ts — WS message parsing, auth, routing to RoomManager
|
|
||||||
|
|
||||||
panel/src/
|
|
||||||
lib/
|
|
||||||
useWebSocket.ts — shared WS connection + message routing
|
|
||||||
useGameRoom.ts — per-room hook
|
|
||||||
games/
|
|
||||||
registry.ts — client-side GameUIPlugin registry
|
|
||||||
GameRoom.tsx — generic room wrapper + renders plugin component
|
|
||||||
GameLobby.tsx — room list + create dialog
|
|
||||||
chess/
|
|
||||||
index.ts — registers ChessUI
|
|
||||||
ChessBoard.tsx
|
|
||||||
blackjack/
|
|
||||||
index.ts — registers BlackjackUI
|
|
||||||
BlackjackTable.tsx
|
|
||||||
pages/
|
|
||||||
PlayerDashboard.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a New Game
|
|
||||||
|
|
||||||
1. `shared/games/<name>/types.ts` — define state and action types
|
|
||||||
2. `shared/games/<name>/plugin.ts` — implement `GamePlugin` interface
|
|
||||||
3. `shared/games/<name>/plugin.test.ts` — test pure functions
|
|
||||||
4. `panel/src/games/<name>/Component.tsx` — React component
|
|
||||||
5. `panel/src/games/<name>/index.ts` — register UI plugin
|
|
||||||
|
|
||||||
No changes to: RoomManager, ws-handler, useGameRoom, GameRoom.tsx, GameLobby.tsx, routing, or WebSocket code.
|
|
||||||
@@ -62,7 +62,7 @@ function AppRoutes() {
|
|||||||
<p className="text-sm text-muted-foreground mb-8">Welcome to Aurora</p>
|
<p className="text-sm text-muted-foreground mb-8">Welcome to Aurora</p>
|
||||||
<a
|
<a
|
||||||
href={`/auth/discord?return_to=${encodeURIComponent(window.location.pathname)}`}
|
href={`/auth/discord?return_to=${encodeURIComponent(window.location.pathname)}`}
|
||||||
className="inline-flex items-center justify-center w-full rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
|
className="inline-flex items-center justify-center w-full rounded-md bg-primary text-on-primary px-4 py-2 text-sm font-label font-medium hover:opacity-90 transition-colors"
|
||||||
>
|
>
|
||||||
Sign in with Discord
|
Sign in with Discord
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default function Layout({
|
|||||||
key={path}
|
key={path}
|
||||||
onClick={() => handleNav(path)}
|
onClick={() => handleNav(path)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
|
"w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
isActive(path)
|
isActive(path)
|
||||||
? "bg-primary/15 text-primary border-l-4 border-primary"
|
? "bg-primary/15 text-primary border-l-4 border-primary"
|
||||||
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
|
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
|
||||||
@@ -103,7 +103,7 @@ export default function Layout({
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-border p-3 space-y-2">
|
<div className="pt-3 p-3 space-y-2">
|
||||||
{(!collapsed || mobileOpen) && (
|
{(!collapsed || mobileOpen) && (
|
||||||
<div className="flex items-center gap-3 px-2 py-1.5">
|
<div className="flex items-center gap-3 px-2 py-1.5">
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
@@ -143,7 +143,7 @@ export default function Layout({
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex">
|
<div className="min-h-screen flex">
|
||||||
{/* Mobile header bar */}
|
{/* Mobile header bar */}
|
||||||
<div className="fixed top-0 left-0 right-0 z-40 flex items-center h-14 px-4 bg-background border-b border-border md:hidden">
|
<div className="fixed top-0 left-0 right-0 z-40 flex items-center h-14 px-4 bg-background md:hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(true)}
|
onClick={() => setMobileOpen(true)}
|
||||||
className="p-2 -ml-2 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
|
className="p-2 -ml-2 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
|
||||||
@@ -164,7 +164,7 @@ export default function Layout({
|
|||||||
{/* Sidebar - mobile drawer + desktop fixed */}
|
{/* Sidebar - mobile drawer + desktop fixed */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
|
"fixed inset-y-0 left-0 z-50 flex flex-col bg-surface-container-low transition-all duration-200",
|
||||||
// Mobile: off-screen drawer, shown when mobileOpen
|
// Mobile: off-screen drawer, shown when mobileOpen
|
||||||
"w-60 -translate-x-full md:translate-x-0",
|
"w-60 -translate-x-full md:translate-x-0",
|
||||||
mobileOpen && "translate-x-0",
|
mobileOpen && "translate-x-0",
|
||||||
@@ -172,7 +172,7 @@ export default function Layout({
|
|||||||
!mobileOpen && collapsed && "md:w-16"
|
!mobileOpen && collapsed && "md:w-16"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between h-14 md:h-16 px-4 border-b border-border">
|
<div className="flex items-center justify-between h-14 md:h-16 px-4">
|
||||||
<div className="font-display text-xl font-bold tracking-tight">
|
<div className="font-display text-xl font-bold tracking-tight">
|
||||||
{collapsed && !mobileOpen ? "A" : "Aurora"}
|
{collapsed && !mobileOpen ? "A" : "Aurora"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function GameLobby() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors shrink-0"
|
className="rounded-xl bg-primary text-on-primary px-4 py-2 text-sm font-label font-medium hover:opacity-90 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
+ Create Room
|
+ Create Room
|
||||||
</button>
|
</button>
|
||||||
@@ -84,21 +84,21 @@ export function GameLobby() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg border border-border">
|
<div className="bg-card rounded-xl">
|
||||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
<div className="flex items-center gap-2 px-5 py-3">
|
||||||
<span className="text-sm font-semibold">Active Rooms</span>
|
<span className="text-sm font-display font-semibold">Active Rooms</span>
|
||||||
<span className="text-xs text-text-disabled">({activeRooms.length})</span>
|
<span className="text-xs text-text-disabled font-label">({activeRooms.length})</span>
|
||||||
</div>
|
</div>
|
||||||
{activeRooms.length === 0 ? (
|
{activeRooms.length === 0 ? (
|
||||||
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
|
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
|
||||||
No active rooms. Create one to get started!
|
No active rooms. Create one to get started!
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="px-2 pb-2 space-y-0.5">
|
||||||
{activeRooms.map(room => {
|
{activeRooms.map(room => {
|
||||||
const plugin = gameUIRegistry.get(room.gameSlug);
|
const plugin = gameUIRegistry.get(room.gameSlug);
|
||||||
return (
|
return (
|
||||||
<div key={room.id} className="flex items-center justify-between gap-3 px-4 py-3 md:px-5 hover:bg-raised/40 transition-colors">
|
<div key={room.id} className="flex items-center justify-between gap-3 px-3 py-3 md:px-4 hover:bg-raised/30 transition-colors rounded-lg">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<span className="text-lg shrink-0">{plugin?.icon ?? "🎮"}</span>
|
<span className="text-lg shrink-0">{plugin?.icon ?? "🎮"}</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -120,10 +120,10 @@ export function GameLobby() {
|
|||||||
onClick={() => navigate(`/${room.gameSlug}/${room.id}`, {
|
onClick={() => navigate(`/${room.gameSlug}/${room.id}`, {
|
||||||
state: { preferAs: room.status === "waiting" ? "player" : "spectator" }
|
state: { preferAs: room.status === "waiting" ? "player" : "spectator" }
|
||||||
})}
|
})}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors shrink-0 ${
|
className={`rounded-xl px-3 py-1.5 text-xs font-label font-semibold transition-colors shrink-0 ${
|
||||||
room.status === "waiting"
|
room.status === "waiting"
|
||||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
? "bg-primary text-on-primary hover:opacity-90"
|
||||||
: "bg-card border border-border text-text-tertiary hover:text-foreground"
|
: "bg-raised text-text-tertiary hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{room.status === "waiting" ? "Join" : "Spectate"}
|
{room.status === "waiting" ? "Join" : "Spectate"}
|
||||||
@@ -137,14 +137,14 @@ export function GameLobby() {
|
|||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}>
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}>
|
||||||
<div className="bg-card border border-border rounded-t-xl sm:rounded-lg p-6 w-full sm:max-w-sm" onClick={e => e.stopPropagation()}>
|
<div className="bg-surface-container-highest rounded-xl p-6 w-full sm:max-w-sm shadow-[0_20px_40px_rgba(0,0,0,0.5)]" onClick={e => e.stopPropagation()}>
|
||||||
<h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
|
<h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{gameTypes.map(g => (
|
{gameTypes.map(g => (
|
||||||
<button
|
<button
|
||||||
key={g.slug}
|
key={g.slug}
|
||||||
onClick={() => createRoom(g.slug)}
|
onClick={() => createRoom(g.slug)}
|
||||||
className="w-full flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm font-medium hover:bg-raised/40 transition-colors"
|
className="w-full flex items-center gap-3 rounded-xl bg-raised px-4 py-3 text-sm font-medium hover:bg-surface-container-high transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-lg">{g.icon}</span>
|
<span className="text-lg">{g.icon}</span>
|
||||||
<span>{g.name}</span>
|
<span>{g.name}</span>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function CopyInviteLink({ url }: { url: string }) {
|
|||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="text-xs text-text-disabled mb-1">Share this link to invite:</div>
|
<div className="text-xs text-text-disabled mb-1">Share this link to invite:</div>
|
||||||
<div className="flex items-center gap-2 w-full max-w-sm">
|
<div className="flex items-center gap-2 w-full max-w-sm">
|
||||||
<span className="flex-1 font-mono bg-surface border border-border px-2 py-1.5 rounded text-[11px] text-text-tertiary truncate">
|
<span className="flex-1 font-mono bg-card rounded-lg px-2 py-1.5 text-[11px] text-text-tertiary truncate">
|
||||||
{url}
|
{url}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -25,7 +25,7 @@ function CopyInviteLink({ url }: { url: string }) {
|
|||||||
className={`shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
copied
|
copied
|
||||||
? "bg-success/15 text-success"
|
? "bg-success/15 text-success"
|
||||||
: "bg-card border border-border text-text-tertiary hover:text-foreground"
|
: "bg-raised text-text-tertiary hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{copied ? "Copied!" : "Copy"}
|
{copied ? "Copied!" : "Copy"}
|
||||||
@@ -107,14 +107,14 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { leaveRoom(); navigate("/games"); }}
|
onClick={() => { leaveRoom(); navigate("/games"); }}
|
||||||
className="rounded-md px-3 py-1.5 text-sm font-medium bg-card border border-border text-text-tertiary hover:text-foreground transition-colors shrink-0"
|
className="rounded-md px-3 py-1.5 text-sm font-medium bg-raised text-text-tertiary hover:text-foreground transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
Leave
|
Leave
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionReplaced && (
|
{sessionReplaced && (
|
||||||
<div className="mb-4 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 flex items-center justify-between gap-3">
|
<div className="mb-4 rounded-xl bg-warning/10 px-4 py-3 flex items-center justify-between gap-3">
|
||||||
<p className="text-sm text-warning">
|
<p className="text-sm text-warning">
|
||||||
You opened this game in another tab. Actions from this tab are disabled.
|
You opened this game in another tab. Actions from this tab are disabled.
|
||||||
</p>
|
</p>
|
||||||
@@ -128,13 +128,13 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
<div className="mb-4 rounded-xl bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gameOver && (
|
{gameOver && (
|
||||||
<div className="mb-4 rounded-lg border border-primary/30 bg-primary/10 px-4 py-3">
|
<div className="mb-4 rounded-xl bg-primary/10 px-4 py-3">
|
||||||
<div className="text-sm font-semibold text-primary">
|
<div className="text-sm font-semibold text-primary">
|
||||||
{gameOver.winner
|
{gameOver.winner
|
||||||
? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
|
? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
|
||||||
@@ -148,7 +148,7 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => { leaveRoom(); navigate("/games"); }}
|
onClick={() => { leaveRoom(); navigate("/games"); }}
|
||||||
className="rounded-md bg-primary text-primary-foreground px-5 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
|
className="rounded-xl bg-primary text-on-primary px-5 py-2 text-sm font-label font-medium hover:opacity-90 transition-colors"
|
||||||
>
|
>
|
||||||
Back to Lobby
|
Back to Lobby
|
||||||
</button>
|
</button>
|
||||||
@@ -156,7 +156,7 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{roomStatus === "waiting" && (
|
{roomStatus === "waiting" && (
|
||||||
<div className="bg-card rounded-lg border border-border p-5 md:p-8">
|
<div className="bg-card rounded-xl p-5 md:p-8">
|
||||||
<div className="text-sm font-semibold mb-4 text-center">
|
<div className="text-sm font-semibold mb-4 text-center">
|
||||||
Waiting for players ({players.length}/{plugin.maxPlayers})
|
Waiting for players ({players.length}/{plugin.maxPlayers})
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +164,7 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
{Array.from({ length: plugin.maxPlayers }).map((_, i) => {
|
{Array.from({ length: plugin.maxPlayers }).map((_, i) => {
|
||||||
const player = players[i];
|
const player = players[i];
|
||||||
return (
|
return (
|
||||||
<div key={i} className={`flex flex-col items-center gap-2 px-4 py-3 rounded-lg border ${player ? "border-primary/40 bg-primary/5" : "border-border bg-surface"}`}>
|
<div key={i} className={`flex flex-col items-center gap-2 px-4 py-3 rounded-xl ${player ? "bg-primary/10" : "bg-surface"}`}>
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${player ? "bg-primary/20 text-primary" : "bg-surface text-text-disabled animate-pulse"}`}>
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${player ? "bg-primary/20 text-primary" : "bg-surface text-text-disabled animate-pulse"}`}>
|
||||||
{player ? player.username[0]?.toUpperCase() : "?"}
|
{player ? player.username[0]?.toUpperCase() : "?"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,400;0,600;0,700;1,400&family=Manrope:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
@@ -6,44 +6,72 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: #0A0A0F;
|
/* ── Surface hierarchy: "The Void & The Light" ── */
|
||||||
--color-foreground: #F9FAFB;
|
--color-background: #0d1323;
|
||||||
--color-muted: #151520;
|
--color-surface: #0d1323;
|
||||||
--color-muted-foreground: #9CA3AF;
|
--color-surface-container-low: #151b2c;
|
||||||
--color-border: rgba(139, 92, 246, 0.15);
|
--color-surface-container-high: #24293b;
|
||||||
--color-input: #1E1B4B;
|
--color-surface-container-highest: #2f3446;
|
||||||
--color-ring: #8B5CF6;
|
|
||||||
--color-primary: #8B5CF6;
|
|
||||||
--color-primary-foreground: #FFFFFF;
|
|
||||||
--color-secondary: #1E1B4B;
|
|
||||||
--color-secondary-foreground: #F9FAFB;
|
|
||||||
--color-accent: #2D2A5F;
|
|
||||||
--color-accent-foreground: #F9FAFB;
|
|
||||||
--color-destructive: #DC2626;
|
|
||||||
--color-destructive-foreground: #FFFFFF;
|
|
||||||
--color-card: #151520;
|
|
||||||
--color-card-foreground: #F9FAFB;
|
|
||||||
--color-success: #10B981;
|
|
||||||
--color-warning: #F59E0B;
|
|
||||||
--color-info: #3B82F6;
|
|
||||||
--color-gold: #FCD34D;
|
|
||||||
--color-surface: #1E1B4B;
|
|
||||||
--color-raised: #2D2A5F;
|
|
||||||
--color-text-secondary: #E5E7EB;
|
|
||||||
--color-text-tertiary: #9CA3AF;
|
|
||||||
--color-text-disabled: #6B7280;
|
|
||||||
--radius-sm: 0.25rem;
|
|
||||||
--radius-md: 0.5rem;
|
|
||||||
--radius-lg: 0.75rem;
|
|
||||||
|
|
||||||
--font-display: 'Space Grotesk', 'Inter', sans-serif;
|
/* Semantic aliases used by existing components */
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
--color-card: #151b2c;
|
||||||
|
--color-raised: #24293b;
|
||||||
|
--color-input: #0d1323;
|
||||||
|
--color-muted: #151b2c;
|
||||||
|
--color-muted-foreground: #7a7f96;
|
||||||
|
|
||||||
|
/* ── Primary — Celestial Gold ── */
|
||||||
|
--color-primary: #e9c349;
|
||||||
|
--color-primary-fixed-dim: #d4af37;
|
||||||
|
--color-primary-foreground: #1a1400;
|
||||||
|
--color-on-primary: #1a1400;
|
||||||
|
--color-primary-container: #3d2e00;
|
||||||
|
--color-ring: #e9c349;
|
||||||
|
|
||||||
|
/* ── Secondary — Midnight Blue / Silver ── */
|
||||||
|
--color-secondary: #1a2040;
|
||||||
|
--color-secondary-foreground: #c8cad6;
|
||||||
|
|
||||||
|
/* ── Accent ── */
|
||||||
|
--color-accent: #24293b;
|
||||||
|
--color-accent-foreground: #e2e4f0;
|
||||||
|
|
||||||
|
/* ── Neutral Text ── */
|
||||||
|
--color-foreground: #e2e4f0;
|
||||||
|
--color-card-foreground: #e2e4f0;
|
||||||
|
--color-text-secondary: #c8cad6;
|
||||||
|
--color-text-tertiary: #7a7f96;
|
||||||
|
--color-text-disabled: #4a4f66;
|
||||||
|
|
||||||
|
/* ── Borders: "Ghost Border" principle ── */
|
||||||
|
--color-border: rgba(69, 70, 76, 0.18);
|
||||||
|
--color-outline-variant: rgba(69, 70, 76, 0.45);
|
||||||
|
|
||||||
|
/* ── Semantic state colors ── */
|
||||||
|
--color-destructive: #dc2626;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-info: #3b82f6;
|
||||||
|
--color-gold: #e9c349;
|
||||||
|
|
||||||
|
/* ── Roundedness Scale ── */
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-xl: 0.75rem;
|
||||||
|
|
||||||
|
/* ── Type system: "Academic Authority" ── */
|
||||||
|
--font-display: 'Noto Serif', Georgia, 'Times New Roman', serif;
|
||||||
|
--font-body: 'Manrope', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-label: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
|||||||
<button
|
<button
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs text-text-tertiary",
|
||||||
"hover:text-destructive hover:border-destructive transition-colors",
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -321,8 +321,8 @@ function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
status === "loading"
|
status === "loading"
|
||||||
? "bg-raised border border-border text-text-tertiary cursor-not-allowed"
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
: "bg-primary text-white hover:bg-primary/90",
|
: "bg-primary text-on-primary hover:opacity-90",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" ? (
|
{status === "loading" ? (
|
||||||
@@ -334,7 +334,7 @@ function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-colors"
|
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold bg-primary text-on-primary hover:opacity-90 transition-colors"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
</button>
|
</button>
|
||||||
@@ -351,7 +351,7 @@ function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Original</p>
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Original</p>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<img src={imageSrc} className="w-full block" alt="Original" />
|
<img src={imageSrc} className="w-full block" alt="Original" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,7 +378,7 @@ function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
{resultUrl ? (
|
{resultUrl ? (
|
||||||
<div style={BG_PRESETS[bgPreset].style}>
|
<div style={BG_PRESETS[bgPreset].style}>
|
||||||
<img src={resultUrl} className="w-full block" alt="Result" />
|
<img src={resultUrl} className="w-full block" alt="Result" />
|
||||||
@@ -643,7 +643,7 @@ export function BackgroundRemoval() {
|
|||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs text-text-tertiary",
|
||||||
"hover:text-destructive hover:border-destructive transition-colors",
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -655,8 +655,8 @@ export function BackgroundRemoval() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
hasResult
|
hasResult
|
||||||
? "bg-primary text-white hover:bg-primary/90"
|
? "bg-primary text-on-primary hover:opacity-90"
|
||||||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
: "bg-raised text-text-tertiary cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
@@ -664,14 +664,14 @@ export function BackgroundRemoval() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
<div className="bg-card rounded-xl p-4 space-y-4">
|
||||||
{/* Row 1 — Key color + mode */}
|
{/* Row 1 — Key color + mode */}
|
||||||
<div className="flex flex-wrap gap-6 items-center">
|
<div className="flex flex-wrap gap-6 items-center">
|
||||||
<div className="space-y-1.5 shrink-0">
|
<div className="space-y-1.5 shrink-0">
|
||||||
<p className="text-xs font-medium text-text-secondary">Key Color</p>
|
<p className="text-xs font-medium text-text-secondary">Key Color</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-7 h-7 rounded-md border border-border shadow-inner shrink-0"
|
className="w-7 h-7 rounded-lg shadow-inner shrink-0"
|
||||||
style={
|
style={
|
||||||
keyColor
|
keyColor
|
||||||
? { backgroundColor: rgbToHex(...keyColor) }
|
? { backgroundColor: rgbToHex(...keyColor) }
|
||||||
@@ -704,12 +704,12 @@ export function BackgroundRemoval() {
|
|||||||
|
|
||||||
<div className="space-y-1.5 shrink-0">
|
<div className="space-y-1.5 shrink-0">
|
||||||
<p className="text-xs font-medium text-text-secondary">Keying Mode</p>
|
<p className="text-xs font-medium text-text-secondary">Keying Mode</p>
|
||||||
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium">
|
<div className="flex rounded-xl overflow-hidden text-xs font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={() => setHueMode(false)}
|
onClick={() => setHueMode(false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 transition-colors",
|
"px-3 py-1.5 transition-colors",
|
||||||
!hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
!hueMode ? "bg-primary text-on-primary" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
RGB
|
RGB
|
||||||
@@ -718,7 +718,7 @@ export function BackgroundRemoval() {
|
|||||||
onClick={() => setHueMode(true)}
|
onClick={() => setHueMode(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 transition-colors border-l border-border",
|
"px-3 py-1.5 transition-colors border-l border-border",
|
||||||
hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
hueMode ? "bg-primary text-on-primary" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
HSV
|
HSV
|
||||||
@@ -804,7 +804,7 @@ export function BackgroundRemoval() {
|
|||||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
Original — click to pick key color
|
Original — click to pick key color
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<canvas ref={sourceCanvasRef} className="w-full cursor-crosshair block" onClick={handleCanvasClick} title="Click a pixel to set it as the chroma key color" />
|
<canvas ref={sourceCanvasRef} className="w-full cursor-crosshair block" onClick={handleCanvasClick} title="Click a pixel to set it as the chroma key color" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -831,7 +831,7 @@ export function BackgroundRemoval() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !hasResult && "hidden")}>
|
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !hasResult && "hidden")}>
|
||||||
<canvas ref={glCanvasRef} className="w-full block" />
|
<canvas ref={glCanvasRef} className="w-full block" />
|
||||||
</div>
|
</div>
|
||||||
@@ -858,12 +858,12 @@ export function BackgroundRemoval() {
|
|||||||
|
|
||||||
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium shrink-0">
|
<div className="flex rounded-xl overflow-hidden text-xs font-medium shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => onChange("chroma")}
|
onClick={() => onChange("chroma")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 transition-colors",
|
"px-3 py-1.5 transition-colors",
|
||||||
mode === "chroma" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
mode === "chroma" ? "bg-primary text-on-primary" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Chroma Key
|
Chroma Key
|
||||||
@@ -872,7 +872,7 @@ function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => voi
|
|||||||
onClick={() => onChange("ai")}
|
onClick={() => onChange("ai")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 transition-colors border-l border-border",
|
"px-3 py-1.5 transition-colors border-l border-border",
|
||||||
mode === "ai" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
mode === "ai" ? "bg-primary text-on-primary" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
AI Remove
|
AI Remove
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export function CanvasTool() {
|
|||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs text-text-tertiary",
|
||||||
"hover:text-destructive hover:border-destructive transition-colors",
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -282,8 +282,8 @@ export function CanvasTool() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
imageReady
|
imageReady
|
||||||
? "bg-primary text-white hover:bg-primary/90"
|
? "bg-primary text-on-primary hover:opacity-90"
|
||||||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
: "bg-raised text-text-tertiary cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
@@ -291,7 +291,7 @@ export function CanvasTool() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
<div className="bg-card rounded-xl p-4 space-y-4">
|
||||||
{/* Row 1: output size */}
|
{/* Row 1: output size */}
|
||||||
<div className="flex flex-wrap gap-4 items-end">
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -301,7 +301,7 @@ export function CanvasTool() {
|
|||||||
type="number" min="1" max="4096" value={outW}
|
type="number" min="1" max="4096" value={outW}
|
||||||
onChange={(e) => handleWChange(e.target.value)}
|
onChange={(e) => handleWChange(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
"w-20 bg-input border-b-2 border-outline-variant rounded-none px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
)}
|
)}
|
||||||
placeholder="W"
|
placeholder="W"
|
||||||
@@ -311,7 +311,7 @@ export function CanvasTool() {
|
|||||||
type="number" min="1" max="4096" value={outH}
|
type="number" min="1" max="4096" value={outH}
|
||||||
onChange={(e) => handleHChange(e.target.value)}
|
onChange={(e) => handleHChange(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
"w-20 bg-input border-b-2 border-outline-variant rounded-none px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
)}
|
)}
|
||||||
placeholder="H"
|
placeholder="H"
|
||||||
@@ -437,7 +437,7 @@ export function CanvasTool() {
|
|||||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
Original — {naturalW}×{naturalH}
|
Original — {naturalW}×{naturalH}
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div style={CHECKERBOARD}>
|
<div style={CHECKERBOARD}>
|
||||||
<img src={imageSrc} alt="Source" className="w-full block" />
|
<img src={imageSrc} alt="Source" className="w-full block" />
|
||||||
</div>
|
</div>
|
||||||
@@ -467,7 +467,7 @@ export function CanvasTool() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}>
|
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}>
|
||||||
<canvas ref={previewCanvasRef} className="w-full block" />
|
<canvas ref={previewCanvasRef} className="w-full block" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ export function CropTool() {
|
|||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs text-text-tertiary",
|
||||||
"hover:text-destructive hover:border-destructive transition-colors",
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -492,8 +492,8 @@ export function CropTool() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
imageReady
|
imageReady
|
||||||
? "bg-primary text-white hover:bg-primary/90"
|
? "bg-primary text-on-primary hover:opacity-90"
|
||||||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
: "bg-raised text-text-tertiary cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
@@ -501,7 +501,7 @@ export function CropTool() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
|
<div className="bg-card rounded-xl p-4 space-y-3">
|
||||||
<div className="flex flex-wrap gap-4 items-end">
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
<label className="space-y-1.5">
|
<label className="space-y-1.5">
|
||||||
<span className="text-xs font-medium text-text-secondary block">Output W (px)</span>
|
<span className="text-xs font-medium text-text-secondary block">Output W (px)</span>
|
||||||
@@ -511,7 +511,7 @@ export function CropTool() {
|
|||||||
value={cropW}
|
value={cropW}
|
||||||
onChange={(e) => setCropW(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
onChange={(e) => setCropW(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
"w-24 bg-input border-b-2 border-outline-variant rounded-none px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -524,7 +524,7 @@ export function CropTool() {
|
|||||||
value={cropH}
|
value={cropH}
|
||||||
onChange={(e) => setCropH(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
onChange={(e) => setCropH(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
"w-24 bg-input border-b-2 border-outline-variant rounded-none px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -536,7 +536,7 @@ export function CropTool() {
|
|||||||
value={imgX}
|
value={imgX}
|
||||||
onChange={(e) => setImgX(parseIntSafe(e.target.value, 0))}
|
onChange={(e) => setImgX(parseIntSafe(e.target.value, 0))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
"w-20 bg-input border-b-2 border-outline-variant rounded-none px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -548,7 +548,7 @@ export function CropTool() {
|
|||||||
value={imgY}
|
value={imgY}
|
||||||
onChange={(e) => setImgY(parseIntSafe(e.target.value, 0))}
|
onChange={(e) => setImgY(parseIntSafe(e.target.value, 0))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
"w-20 bg-input border-b-2 border-outline-variant rounded-none px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -567,7 +567,7 @@ export function CropTool() {
|
|||||||
value={padding}
|
value={padding}
|
||||||
onChange={(e) => setPadding(Math.max(0, parseIntSafe(e.target.value, 0)))}
|
onChange={(e) => setPadding(Math.max(0, parseIntSafe(e.target.value, 0)))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
"w-20 bg-input border-b-2 border-outline-variant rounded-none px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -576,7 +576,7 @@ export function CropTool() {
|
|||||||
<button
|
<button
|
||||||
onClick={autoCenter}
|
onClick={autoCenter}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
|
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium transition-colors",
|
||||||
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
||||||
)}
|
)}
|
||||||
title="Pan the image so non-transparent content is centered within the current output canvas"
|
title="Pan the image so non-transparent content is centered within the current output canvas"
|
||||||
@@ -586,7 +586,7 @@ export function CropTool() {
|
|||||||
<button
|
<button
|
||||||
onClick={fitToContent}
|
onClick={fitToContent}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
|
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium transition-colors",
|
||||||
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
||||||
)}
|
)}
|
||||||
title="Resize output to the non-transparent content bounding box + padding, then center"
|
title="Resize output to the non-transparent content bounding box + padding, then center"
|
||||||
@@ -607,7 +607,7 @@ export function CropTool() {
|
|||||||
Editor
|
Editor
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className="bg-card border border-border rounded-lg overflow-auto"
|
className="bg-card rounded-xl overflow-auto"
|
||||||
style={{ maxHeight: "62vh" }}
|
style={{ maxHeight: "62vh" }}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
@@ -626,7 +626,7 @@ export function CropTool() {
|
|||||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
Preview
|
Preview
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
{imageReady ? (
|
{imageReady ? (
|
||||||
<div style={CHECKERBOARD}>
|
<div style={CHECKERBOARD}>
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function StatCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-gradient-to-br from-card to-surface rounded-lg border border-border p-6 border-l-4",
|
"bg-gradient-to-br from-card to-surface rounded-xl p-6 border-l-4",
|
||||||
accent
|
accent
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -105,19 +105,19 @@ function LeaderboardColumn({
|
|||||||
valuePrefix?: string;
|
valuePrefix?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-lg border border-border">
|
<div className="bg-card rounded-xl">
|
||||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
<div className="flex items-center gap-2 px-5 py-4">
|
||||||
<Icon className="w-4 h-4 text-primary" />
|
<Icon className="w-4 h-4 text-primary" />
|
||||||
<h3 className="text-sm font-semibold">{title}</h3>
|
<h3 className="text-sm font-display font-semibold">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border">
|
<div className="space-y-0">
|
||||||
{entries.length === 0 && (
|
{entries.length === 0 && (
|
||||||
<div className="px-5 py-4 text-sm text-text-tertiary">No data</div>
|
<div className="px-5 py-4 text-sm text-text-tertiary">No data</div>
|
||||||
)}
|
)}
|
||||||
{entries.slice(0, 10).map((entry, i) => (
|
{entries.slice(0, 10).map((entry, i) => (
|
||||||
<div
|
<div
|
||||||
key={entry.username}
|
key={entry.username}
|
||||||
className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors"
|
className="flex items-center justify-between px-5 py-3 hover:bg-raised/30 transition-colors rounded-lg mx-2 mb-1"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
@@ -179,7 +179,7 @@ function DashboardContent({ data }: { data: DashboardStats }) {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Maintenance banner */}
|
{/* Maintenance banner */}
|
||||||
{data.maintenanceMode && (
|
{data.maintenanceMode && (
|
||||||
<div className="flex items-center gap-3 bg-warning/10 border border-warning/30 rounded-lg px-5 py-3">
|
<div className="flex items-center gap-3 bg-warning/10 rounded-xl px-5 py-3">
|
||||||
<Wrench className="w-4 h-4 text-warning shrink-0" />
|
<Wrench className="w-4 h-4 text-warning shrink-0" />
|
||||||
<span className="text-sm text-warning font-medium">
|
<span className="text-sm text-warning font-medium">
|
||||||
Maintenance mode is active
|
Maintenance mode is active
|
||||||
@@ -249,13 +249,13 @@ function DashboardContent({ data }: { data: DashboardStats }) {
|
|||||||
{data.recentEvents.length > 0 && (
|
{data.recentEvents.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="font-display text-lg font-semibold mb-4">Recent Events</h2>
|
<h2 className="font-display text-lg font-semibold mb-4">Recent Events</h2>
|
||||||
<div className="bg-card rounded-lg border border-border divide-y divide-border">
|
<div className="bg-card rounded-xl">
|
||||||
{data.recentEvents.slice(0, 20).map((event, i) => {
|
{data.recentEvents.slice(0, 20).map((event, i) => {
|
||||||
const Icon = eventIcons[event.type];
|
const Icon = eventIcons[event.type];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-start gap-3 px-5 py-3 hover:bg-raised/40 transition-colors"
|
className="flex items-start gap-3 px-5 py-3 hover:bg-raised/30 transition-colors rounded-lg mx-2 first:mt-2 last:mb-2"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={cn("w-4 h-4 mt-0.5 shrink-0", eventColors[event.type])}
|
className={cn("w-4 h-4 mt-0.5 shrink-0", eventColors[event.type])}
|
||||||
@@ -275,7 +275,7 @@ function DashboardContent({ data }: { data: DashboardStats }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bot status footer */}
|
{/* Bot status footer */}
|
||||||
<footer className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-text-tertiary border-t border-border pt-6">
|
<footer className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-text-tertiary pt-8">
|
||||||
<span className="font-medium text-text-secondary">{data.bot.name}</span>
|
<span className="font-medium text-text-secondary">{data.bot.name}</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Wifi className="w-3 h-3" />
|
<Wifi className="w-3 h-3" />
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ export function HueShifter() {
|
|||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={isDefault}
|
disabled={isDefault}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary transition-colors",
|
"px-3 py-1.5 rounded-xl text-xs text-text-tertiary transition-colors",
|
||||||
isDefault
|
isDefault
|
||||||
? "opacity-40 cursor-not-allowed"
|
? "opacity-40 cursor-not-allowed"
|
||||||
: "hover:text-foreground hover:border-primary/40",
|
: "hover:text-foreground hover:border-primary/40",
|
||||||
@@ -381,7 +381,7 @@ export function HueShifter() {
|
|||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs text-text-tertiary",
|
||||||
"hover:text-destructive hover:border-destructive transition-colors",
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -393,8 +393,8 @@ export function HueShifter() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
imageReady && !isDefault
|
imageReady && !isDefault
|
||||||
? "bg-primary text-white hover:bg-primary/90"
|
? "bg-primary text-on-primary hover:opacity-90"
|
||||||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
: "bg-raised text-text-tertiary cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
@@ -402,7 +402,7 @@ export function HueShifter() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="bg-card border border-border rounded-xl p-5 space-y-5">
|
<div className="bg-card rounded-xl p-5 space-y-5">
|
||||||
<Slider
|
<Slider
|
||||||
label="Hue Shift"
|
label="Hue Shift"
|
||||||
value={hueShift}
|
value={hueShift}
|
||||||
@@ -438,7 +438,7 @@ export function HueShifter() {
|
|||||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
Original
|
Original
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div style={CHECKERBOARD}>
|
<div style={CHECKERBOARD}>
|
||||||
<img src={imageSrc} alt="Original" className="w-full block" />
|
<img src={imageSrc} alt="Original" className="w-full block" />
|
||||||
</div>
|
</div>
|
||||||
@@ -449,7 +449,7 @@ export function HueShifter() {
|
|||||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
Result
|
Result
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
{/* WebGL canvas always in DOM; hidden until image is ready */}
|
{/* WebGL canvas always in DOM; hidden until image is ready */}
|
||||||
<div style={CHECKERBOARD}>
|
<div style={CHECKERBOARD}>
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export function ItemStudio({
|
|||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-3 p-4 bg-destructive/10 border border-destructive/30 rounded-lg">
|
<div className="flex items-start gap-3 p-4 bg-destructive/10 rounded-xl">
|
||||||
<AlertTriangle className="w-5 h-5 text-destructive shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-destructive shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,8 +340,8 @@ export function ItemStudio({
|
|||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 rounded-lg border border-border text-sm text-text-tertiary",
|
"px-4 py-2 rounded-xl bg-raised text-sm text-text-tertiary",
|
||||||
"hover:text-foreground hover:border-primary/40 transition-colors",
|
"hover:text-foreground hover:bg-surface-container-highest transition-colors",
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -351,10 +351,10 @@ export function ItemStudio({
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting || success}
|
disabled={submitting || success}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-6 py-2 rounded-lg text-sm font-semibold transition-all flex items-center gap-2",
|
"px-6 py-2 rounded-xl text-sm font-label font-semibold transition-all flex items-center gap-2",
|
||||||
success
|
success
|
||||||
? "bg-success text-white"
|
? "bg-success text-white"
|
||||||
: "bg-primary hover:bg-primary/90 text-white",
|
: "bg-primary hover:opacity-90 text-on-primary",
|
||||||
"disabled:opacity-70 disabled:cursor-not-allowed"
|
"disabled:opacity-70 disabled:cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ function SearchFilterBar({
|
|||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder="Search items..."
|
placeholder="Search items..."
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none pl-10 pr-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors placeholder:text-text-disabled"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,8 +79,8 @@ function SearchFilterBar({
|
|||||||
value={type ?? ""}
|
value={type ?? ""}
|
||||||
onChange={(e) => onTypeChange(e.target.value || null)}
|
onChange={(e) => onTypeChange(e.target.value || null)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -95,8 +95,8 @@ function SearchFilterBar({
|
|||||||
value={rarity ?? ""}
|
value={rarity ?? ""}
|
||||||
onChange={(e) => onRarityChange(e.target.value || null)}
|
onChange={(e) => onRarityChange(e.target.value || null)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -110,8 +110,8 @@ function SearchFilterBar({
|
|||||||
<button
|
<button
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
|
"bg-raised rounded-xl px-3 py-2 text-sm font-label text-text-secondary",
|
||||||
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
|
"hover:bg-destructive hover:text-white transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@@ -137,13 +137,13 @@ function ItemTable({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-raised border-b border-border">
|
<thead className="bg-raised">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th key={col} className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
{col}
|
{col}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -151,7 +151,7 @@ function ItemTable({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<tr key={i} className="border-b border-border">
|
<tr key={i}>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td key={col} className="px-4 py-3">
|
<td key={col} className="px-4 py-3">
|
||||||
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
|
<div className="h-4 bg-raised rounded animate-pulse w-20"></div>
|
||||||
@@ -168,7 +168,7 @@ function ItemTable({
|
|||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg p-12 text-center">
|
<div className="bg-card rounded-xl p-12 text-center">
|
||||||
<Package className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
<Package className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
||||||
<p className="text-lg font-semibold text-text-secondary mb-2">
|
<p className="text-lg font-semibold text-text-secondary mb-2">
|
||||||
No items found
|
No items found
|
||||||
@@ -181,13 +181,13 @@ function ItemTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-raised border-b border-border">
|
<thead className="bg-raised">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th key={col} className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
{col}
|
{col}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -197,7 +197,7 @@ function ItemTable({
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<tr
|
<tr
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="border-b border-border hover:bg-raised transition-colors cursor-pointer"
|
className="hover:bg-raised/40 transition-colors cursor-pointer"
|
||||||
onClick={() => onItemClick(item)}
|
onClick={() => onItemClick(item)}
|
||||||
title="Click to edit"
|
title="Click to edit"
|
||||||
>
|
>
|
||||||
@@ -317,10 +317,10 @@ function Pagination({
|
|||||||
onClick={() => onPageChange(currentPage - 1)}
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
"px-3 py-2 rounded-xl text-sm font-medium transition-colors",
|
||||||
currentPage === 1
|
currentPage === 1
|
||||||
? "bg-raised text-text-tertiary cursor-not-allowed"
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
: "bg-raised text-foreground hover:bg-surface-container-highest"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
@@ -332,10 +332,10 @@ function Pagination({
|
|||||||
key={i}
|
key={i}
|
||||||
onClick={() => onPageChange(page)}
|
onClick={() => onPageChange(page)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
|
"px-3 py-2 rounded-xl text-sm font-label font-medium transition-colors min-w-[40px]",
|
||||||
page === currentPage
|
page === currentPage
|
||||||
? "bg-primary text-white"
|
? "bg-primary text-on-primary"
|
||||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
: "bg-raised text-foreground hover:bg-surface-container-highest"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
@@ -351,10 +351,10 @@ function Pagination({
|
|||||||
onClick={() => onPageChange(currentPage + 1)}
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
"px-3 py-2 rounded-xl text-sm font-medium transition-colors",
|
||||||
currentPage === totalPages
|
currentPage === totalPages
|
||||||
? "bg-raised text-text-tertiary cursor-not-allowed"
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
: "bg-raised text-foreground hover:bg-surface-container-highest"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@@ -364,8 +364,8 @@ function Pagination({
|
|||||||
value={limit}
|
value={limit}
|
||||||
onChange={(e) => onLimitChange(Number(e.target.value))}
|
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-2 bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"ml-2 bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -422,11 +422,11 @@ export default function Items() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-border p-6 space-y-4">
|
<header className="p-6 space-y-4">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
<h1 className="text-2xl font-display font-bold text-foreground">Items</h1>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 border-b border-border -mb-4 pb-px">
|
<div className="flex gap-1 border-b border-border/30 -mb-4 pb-px">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("all")}
|
onClick={() => setActiveTab("all")}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -504,7 +504,7 @@ export default function Items() {
|
|||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mx-6 mt-4 bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
|
<div className="mx-6 mt-4 bg-destructive/10 rounded-xl p-4 flex items-start gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-semibold text-destructive">Error</p>
|
<p className="text-sm font-semibold text-destructive">Error</p>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default function NotEnrolled() {
|
|||||||
You need to use the Aurora bot in Discord before you can access this panel.
|
You need to use the Aurora bot in Discord before you can access this panel.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-disabled">
|
<p className="text-xs text-text-disabled">
|
||||||
Use <span className="font-mono bg-surface px-1.5 py-0.5 rounded">/enroll</span> in any server with Aurora to get started.
|
Use <span className="font-mono bg-card px-1.5 py-0.5 rounded-md">/enroll</span> in any server with Aurora to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,17 +69,17 @@ export default function PlayerDashboard({ userId }: { userId: string }) {
|
|||||||
<StatCard label="Items" value={String(inventory.length)} accent="success" />
|
<StatCard label="Items" value={String(inventory.length)} accent="success" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg border border-border">
|
<div className="bg-card rounded-xl">
|
||||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
<div className="flex items-center gap-2 px-5 py-3">
|
||||||
<span className="text-sm font-semibold">Inventory</span>
|
<span className="text-sm font-display font-semibold">Inventory</span>
|
||||||
<span className="text-xs text-text-disabled">({inventory.length})</span>
|
<span className="text-xs text-text-disabled font-label">({inventory.length})</span>
|
||||||
</div>
|
</div>
|
||||||
{inventory.length === 0 ? (
|
{inventory.length === 0 ? (
|
||||||
<div className="px-5 py-6 text-center text-sm text-text-tertiary">No items yet</div>
|
<div className="px-5 py-6 text-center text-sm text-text-tertiary">No items yet</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="px-2 pb-2 space-y-0.5">
|
||||||
{inventory.slice(0, 10).map((item, i) => (
|
{inventory.slice(0, 10).map((item, i) => (
|
||||||
<div key={i} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors">
|
<div key={i} className="flex items-center justify-between px-3 py-3 hover:bg-raised/30 transition-colors rounded-lg">
|
||||||
<div className="text-sm font-medium">{item.name}</div>
|
<div className="text-sm font-medium">{item.name}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${rarityColor(item.rarity)}`}>
|
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${rarityColor(item.rarity)}`}>
|
||||||
@@ -111,7 +111,7 @@ function StatCard({ label, value, accent, subtitle }: { label: string; value: st
|
|||||||
success: "border-l-success",
|
success: "border-l-success",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={`bg-gradient-to-br from-card to-surface border border-border rounded-lg p-5 border-l-4 ${borderColor[accent] ?? ""}`}>
|
<div className={`bg-gradient-to-br from-card to-surface rounded-xl p-5 border-l-4 ${borderColor[accent] ?? ""}`}>
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">{label}</div>
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">{label}</div>
|
||||||
<div className="text-2xl font-bold font-display tracking-tight mt-1">{value}</div>
|
<div className="text-2xl font-bold font-display tracking-tight mt-1">{value}</div>
|
||||||
{subtitle && <div className="text-sm text-text-tertiary mt-0.5">{subtitle}</div>}
|
{subtitle && <div className="text-sm text-text-tertiary mt-0.5">{subtitle}</div>}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export default function Settings() {
|
|||||||
{dirty && (
|
{dirty && (
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-text-secondary hover:bg-primary/10 transition-colors"
|
className="inline-flex items-center gap-2 rounded-xl bg-raised px-4 py-2 text-sm font-label font-medium text-text-secondary hover:bg-surface-container-highest transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
Discard
|
Discard
|
||||||
@@ -217,10 +217,10 @@ export default function Settings() {
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!dirty || saving}
|
disabled={!dirty || saving}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 rounded-md px-5 py-2 text-sm font-medium transition-colors",
|
"inline-flex items-center gap-2 rounded-xl px-5 py-2 text-sm font-label font-medium transition-colors",
|
||||||
dirty
|
dirty
|
||||||
? "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm shadow-primary/30"
|
? "bg-primary text-on-primary hover:opacity-90 shadow-sm shadow-primary/30"
|
||||||
: "bg-primary/30 text-primary-foreground/50 cursor-not-allowed"
|
: "bg-primary/30 text-on-primary/50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
@@ -237,14 +237,14 @@ export default function Settings() {
|
|||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-3 bg-destructive/10 border border-destructive/30 rounded-lg px-5 py-3">
|
<div className="flex items-center gap-3 bg-destructive/10 rounded-xl px-5 py-3">
|
||||||
<AlertTriangle className="w-4 h-4 text-destructive shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-destructive shrink-0" />
|
||||||
<span className="text-sm text-destructive">{error}</span>
|
<span className="text-sm text-destructive">{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Section tabs */}
|
{/* Section tabs */}
|
||||||
<div className="flex gap-1 overflow-x-auto border-b border-border pb-px">
|
<div className="flex gap-1 overflow-x-auto border-b border-border/30 pb-px">
|
||||||
{sections.map(({ key, label, icon: Icon }) => (
|
{sections.map(({ key, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -324,7 +324,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
{/* Dirty indicator footer */}
|
{/* Dirty indicator footer */}
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<div className="sticky bottom-0 -mx-6 px-6 py-3 bg-background/80 backdrop-blur border-t border-border flex items-center justify-between">
|
<div className="sticky bottom-0 -mx-6 px-6 py-3 bg-surface-container-high/90 backdrop-blur flex items-center justify-between">
|
||||||
<span className="text-sm text-warning font-medium">
|
<span className="text-sm text-warning font-medium">
|
||||||
You have unsaved changes
|
You have unsaved changes
|
||||||
</span>
|
</span>
|
||||||
@@ -338,7 +338,7 @@ export default function Settings() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-primary text-primary-foreground px-4 py-1.5 text-sm font-medium hover:bg-primary/90 transition-colors"
|
className="inline-flex items-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-1.5 text-sm font-label font-medium hover:opacity-90 transition-colors"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function Users() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-border p-6 space-y-4">
|
<header className="p-6 space-y-4">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
||||||
<SearchFilterBar
|
<SearchFilterBar
|
||||||
search={searchInput}
|
search={searchInput}
|
||||||
@@ -113,7 +113,7 @@ export default function Users() {
|
|||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mx-6 mt-4 bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
|
<div className="mx-6 mt-4 bg-destructive/10 rounded-xl p-4 flex items-start gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-semibold text-destructive">Error</p>
|
<p className="text-sm font-semibold text-destructive">Error</p>
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ export function EffectEditor({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border rounded-lg p-4 space-y-3",
|
"rounded-xl p-4 space-y-3",
|
||||||
isLootbox
|
isLootbox
|
||||||
? "bg-amber-500/5 border-amber-500/25"
|
? "bg-amber-500/5"
|
||||||
: "bg-raised/20 border-border"
|
: "bg-surface-container-high"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -57,8 +57,8 @@ export function EffectEditor({
|
|||||||
update({ kind: e.target.value as EffectKind, ...resetFields })
|
update({ kind: e.target.value as EffectKind, ...resetFields })
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 bg-input border border-border rounded-md px-3 py-1.5 text-sm text-foreground",
|
"flex-1 bg-input border-b-2 border-outline-variant rounded-none px-3 py-1.5 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30 transition-colors"
|
"focus:outline-none focus:border-primary transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(Object.keys(EFFECT_META) as EffectKind[]).map((kind) => (
|
{(Object.keys(EFFECT_META) as EffectKind[]).map((kind) => (
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function ItemPreviewCard({
|
|||||||
|
|
||||||
{/* Lootbox pool mini-preview */}
|
{/* Lootbox pool mini-preview */}
|
||||||
{lootboxEffect && lootboxEffect.pool.length > 0 && (
|
{lootboxEffect && lootboxEffect.pool.length > 0 && (
|
||||||
<div className="pt-2 border-t border-border space-y-1.5">
|
<div className="pt-2 mt-2 space-y-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Gift className="w-3.5 h-3.5 text-amber-400" />
|
<Gift className="w-3.5 h-3.5 text-amber-400" />
|
||||||
<span className="text-xs text-amber-400 font-medium">
|
<span className="text-xs text-amber-400 font-medium">
|
||||||
@@ -131,7 +131,7 @@ export function ItemPreviewCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(draft.effects.length > 0 || draft.consume) && !lootboxEffect && (
|
{(draft.effects.length > 0 || draft.consume) && !lootboxEffect && (
|
||||||
<div className="pt-2 border-t border-border flex flex-wrap gap-1.5">
|
<div className="pt-2 mt-2 flex flex-wrap gap-1.5">
|
||||||
{draft.consume && (
|
{draft.consume && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 text-xs">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 text-xs">
|
||||||
Consumed on use
|
Consumed on use
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function ItemSearchPicker({
|
|||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 p-2 bg-raised/50 rounded-md border border-border">
|
<div className="flex items-center gap-2 p-2 bg-raised/50 rounded-xl">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs px-1.5 py-0.5 rounded font-bold shrink-0",
|
"text-xs px-1.5 py-0.5 rounded font-bold shrink-0",
|
||||||
@@ -105,7 +105,7 @@ export function ItemSearchPicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{open && results.length > 0 && (
|
{open && results.length > 0 && (
|
||||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-card border border-border rounded-lg shadow-xl overflow-hidden">
|
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-surface-container-high rounded-xl shadow-[0_20px_40px_rgba(0,0,0,0.4)] overflow-hidden">
|
||||||
{results.map((item) => (
|
{results.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ export function ClassificationSection({
|
|||||||
key={t}
|
key={t}
|
||||||
onClick={() => update({ type: t })}
|
onClick={() => update({ type: t })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center gap-1.5 py-3 px-2 rounded-lg border text-xs font-medium transition-all",
|
"flex flex-col items-center gap-1.5 py-3 px-2 rounded-xl text-xs font-label font-medium transition-all",
|
||||||
active
|
active
|
||||||
? "bg-primary/15 border-primary text-primary"
|
? "bg-primary/15 text-primary"
|
||||||
: "bg-input border-border text-text-tertiary hover:border-primary/40 hover:text-foreground"
|
: "bg-raised text-text-tertiary hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
@@ -96,10 +96,10 @@ export function ClassificationSection({
|
|||||||
key={r}
|
key={r}
|
||||||
onClick={() => update({ rarity: r })}
|
onClick={() => update({ rarity: r })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"py-3 px-2 rounded-lg border text-center transition-all",
|
"py-3 px-2 rounded-xl text-center transition-all border",
|
||||||
active
|
active
|
||||||
? cn(meta.bg, meta.text, meta.activeBorder)
|
? cn(meta.bg, meta.text, meta.activeBorder)
|
||||||
: "bg-input border-border text-text-tertiary hover:border-primary/40"
|
: "bg-raised border-transparent text-text-tertiary hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-bold">{r}</div>
|
<div className="text-sm font-bold">{r}</div>
|
||||||
@@ -188,10 +188,10 @@ export function ArtworkSection({
|
|||||||
key={mode}
|
key={mode}
|
||||||
onClick={() => setImageMode(mode)}
|
onClick={() => setImageMode(mode)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 rounded-md text-xs font-medium transition-colors border",
|
"px-3 py-1.5 rounded-xl text-xs font-label font-medium transition-colors",
|
||||||
imageMode === mode
|
imageMode === mode
|
||||||
? "bg-primary/15 border-primary text-primary"
|
? "bg-primary/15 text-primary"
|
||||||
: "bg-input border-border text-text-tertiary hover:text-foreground"
|
: "bg-raised text-text-tertiary hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mode === "upload" ? "Upload File" : "Enter URL"}
|
{mode === "upload" ? "Upload File" : "Enter URL"}
|
||||||
@@ -202,7 +202,7 @@ export function ArtworkSection({
|
|||||||
{imageMode === "upload" ? (
|
{imageMode === "upload" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{imageFile ? (
|
{imageFile ? (
|
||||||
<div className="relative rounded-lg overflow-hidden border border-border">
|
<div className="relative rounded-xl overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={imagePreview!}
|
src={imagePreview!}
|
||||||
alt="Selected"
|
alt="Selected"
|
||||||
@@ -214,7 +214,7 @@ export function ArtworkSection({
|
|||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="px-3 py-2 bg-card border-t border-border">
|
<div className="px-3 py-2 bg-surface-container-high">
|
||||||
<p className="text-xs text-text-tertiary truncate">
|
<p className="text-xs text-text-tertiary truncate">
|
||||||
{imageFile.name} ({(imageFile.size / 1024).toFixed(1)}{" "}
|
{imageFile.name} ({(imageFile.size / 1024).toFixed(1)}{" "}
|
||||||
KB)
|
KB)
|
||||||
@@ -231,10 +231,10 @@ export function ArtworkSection({
|
|||||||
onDragLeave={() => setDragOver(false)}
|
onDragLeave={() => setDragOver(false)}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-2 border-dashed rounded-lg p-10 flex flex-col items-center gap-3 cursor-pointer transition-all select-none",
|
"border-2 border-dashed rounded-xl p-10 flex flex-col items-center gap-3 cursor-pointer transition-all select-none",
|
||||||
dragOver
|
dragOver
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/5"
|
||||||
: "border-border hover:border-primary/50 hover:bg-primary/3"
|
: "border-outline-variant hover:border-primary/50 hover:bg-primary/3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
@@ -296,7 +296,7 @@ export function ArtworkSection({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{iconUrlInput && (
|
{iconUrlInput && (
|
||||||
<div className="flex items-center gap-3 p-3 bg-raised/40 rounded-lg border border-border">
|
<div className="flex items-center gap-3 p-3 bg-raised/40 rounded-xl">
|
||||||
<img
|
<img
|
||||||
src={iconUrlInput}
|
src={iconUrlInput}
|
||||||
alt="Icon preview"
|
alt="Icon preview"
|
||||||
@@ -337,10 +337,10 @@ export function EffectsSection({
|
|||||||
aria-checked={draft.consume}
|
aria-checked={draft.consume}
|
||||||
onClick={() => update({ consume: !draft.consume })}
|
onClick={() => update({ consume: !draft.consume })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-10 h-5 rounded-full border transition-all shrink-0",
|
"relative w-10 h-5 rounded-full transition-all shrink-0",
|
||||||
draft.consume
|
draft.consume
|
||||||
? "bg-primary border-primary"
|
? "bg-primary"
|
||||||
: "bg-input border-border"
|
: "bg-raised"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -367,8 +367,8 @@ export function EffectsSection({
|
|||||||
<button
|
<button
|
||||||
onClick={addEffect}
|
onClick={addEffect}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed text-sm",
|
"w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed text-sm",
|
||||||
"border-border text-text-tertiary hover:border-primary/50 hover:text-primary transition-colors"
|
"border-outline-variant text-text-tertiary hover:border-primary/50 hover:text-primary transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export function SectionCard({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
<div className="bg-card rounded-xl p-5 space-y-4">
|
||||||
<h3 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
<h3 className="text-xs font-label font-semibold text-text-tertiary uppercase tracking-widest">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export function draftFromItem(item: FullItem): Draft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const inputCls = cn(
|
export const inputCls = cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"placeholder:text-text-tertiary transition-colors"
|
"placeholder:text-text-tertiary transition-colors"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function LootPoolEntryEditor({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-raised/30 border border-border rounded-lg p-3 space-y-3">
|
<div className="bg-surface-container-high rounded-xl p-3 space-y-3">
|
||||||
{/* Header row: type tabs + weight input + percentage + remove */}
|
{/* Header row: type tabs + weight input + percentage + remove */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="flex gap-1 flex-1 flex-wrap">
|
<div className="flex gap-1 flex-1 flex-wrap">
|
||||||
@@ -49,10 +49,10 @@ export function LootPoolEntryEditor({
|
|||||||
key={t}
|
key={t}
|
||||||
onClick={() => upd({ type: t, ...resetAmounts })}
|
onClick={() => upd({ type: t, ...resetAmounts })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1 rounded text-xs font-medium transition-colors border",
|
"px-2 py-1 rounded-lg text-xs font-label font-medium transition-colors",
|
||||||
entry.type === t
|
entry.type === t
|
||||||
? "bg-primary/15 border-primary text-primary"
|
? "bg-primary/15 text-primary"
|
||||||
: "bg-input border-border text-text-tertiary hover:text-foreground"
|
: "bg-raised text-text-tertiary hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{LOOT_TYPE_META[t].label}
|
{LOOT_TYPE_META[t].label}
|
||||||
@@ -67,7 +67,7 @@ export function LootPoolEntryEditor({
|
|||||||
value={entry.weight}
|
value={entry.weight}
|
||||||
onChange={(e) => upd({ weight: e.target.value })}
|
onChange={(e) => upd({ weight: e.target.value })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-14 bg-input border border-border rounded-md px-2 py-1 text-xs text-foreground text-center",
|
"w-14 bg-input border-b-2 border-outline-variant rounded-none px-2 py-1 text-xs text-foreground text-center",
|
||||||
"focus:outline-none focus:border-primary transition-colors"
|
"focus:outline-none focus:border-primary transition-colors"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -106,10 +106,10 @@ export function LootPoolEntryEditor({
|
|||||||
key={mode}
|
key={mode}
|
||||||
onClick={() => upd({ amountMode: mode })}
|
onClick={() => upd({ amountMode: mode })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1 rounded text-xs font-medium border transition-colors",
|
"px-2.5 py-1 rounded-lg text-xs font-label font-medium transition-colors",
|
||||||
entry.amountMode === mode
|
entry.amountMode === mode
|
||||||
? "bg-primary/15 border-primary text-primary"
|
? "bg-primary/15 text-primary"
|
||||||
: "bg-input border-border text-text-tertiary hover:text-foreground"
|
: "bg-raised text-text-tertiary hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mode === "fixed" ? "Fixed" : "Random Range"}
|
{mode === "fixed" ? "Fixed" : "Random Range"}
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ export function LootboxEditor({
|
|||||||
<button
|
<button
|
||||||
onClick={addEntry}
|
onClick={addEntry}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed text-xs",
|
"w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-dashed text-xs",
|
||||||
"border-border text-text-tertiary hover:border-amber-500/50 hover:text-amber-400 transition-colors"
|
"border-outline-variant text-text-tertiary hover:border-amber-500/50 hover:text-amber-400 transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export function SearchFilterBar({
|
|||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder="Search by username..."
|
placeholder="Search by username..."
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md pl-10 pr-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none pl-10 pr-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors placeholder:text-text-disabled"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,8 +55,8 @@ export function SearchFilterBar({
|
|||||||
value={classId ?? ""}
|
value={classId ?? ""}
|
||||||
onChange={(e) => onClassChange(e.target.value || null)}
|
onChange={(e) => onClassChange(e.target.value || null)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -75,8 +75,8 @@ export function SearchFilterBar({
|
|||||||
onActiveChange(e.target.value === "" ? null : e.target.value === "true")
|
onActiveChange(e.target.value === "" ? null : e.target.value === "true")
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -90,8 +90,8 @@ export function SearchFilterBar({
|
|||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => onSortByChange(e.target.value)}
|
onChange={(e) => onSortByChange(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -105,8 +105,8 @@ export function SearchFilterBar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onSortOrderChange(sortOrder === "asc" ? "desc" : "asc")}
|
onClick={() => onSortOrderChange(sortOrder === "asc" ? "desc" : "asc")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"bg-raised rounded-xl px-3 py-2 text-sm font-label text-foreground",
|
||||||
"hover:bg-raised transition-colors"
|
"hover:bg-surface-container-highest transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{sortOrder === "asc" ? "\u2191 Asc" : "\u2193 Desc"}
|
{sortOrder === "asc" ? "\u2191 Asc" : "\u2193 Desc"}
|
||||||
@@ -116,8 +116,8 @@ export function SearchFilterBar({
|
|||||||
<button
|
<button
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input border border-border rounded-md px-3 py-2 text-sm text-text-secondary",
|
"bg-raised rounded-xl px-3 py-2 text-sm font-label text-text-secondary",
|
||||||
"hover:bg-destructive hover:text-white hover:border-destructive transition-colors"
|
"hover:bg-destructive hover:text-white transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function Field({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-text-secondary">
|
<label className="block text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{children}
|
{children}
|
||||||
@@ -51,8 +51,8 @@ export function NumberInput({
|
|||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm font-mono text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -78,9 +78,9 @@ export function StringInput({
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm font-mono text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors",
|
"transition-colors placeholder:text-text-disabled",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -105,9 +105,9 @@ export function TextArea({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground resize-y",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground resize-y",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors placeholder:text-text-disabled"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -155,8 +155,8 @@ export function SelectInput({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -186,8 +186,8 @@ export function RolePicker({
|
|||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value || undefined)}
|
onChange={(e) => onChange(e.target.value || undefined)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -220,8 +220,8 @@ export function ChannelPicker({
|
|||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value || undefined)}
|
onChange={(e) => onChange(e.target.value || undefined)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -257,7 +257,7 @@ export function MultiRolePicker({
|
|||||||
{selectedRoles.map((r) => (
|
{selectedRoles.map((r) => (
|
||||||
<span
|
<span
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className="inline-flex items-center gap-1.5 bg-primary/15 border border-primary/30 rounded-full px-3 py-1 text-xs font-medium"
|
className="inline-flex items-center gap-1.5 bg-primary/15 rounded-full px-3 py-1 text-xs font-label font-medium"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full"
|
className="w-2.5 h-2.5 rounded-full"
|
||||||
@@ -282,8 +282,8 @@ export function MultiRolePicker({
|
|||||||
if (e.target.value) onChange([...selected, e.target.value]);
|
if (e.target.value) onChange([...selected, e.target.value]);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -346,7 +346,7 @@ export function CategoryPicker({
|
|||||||
{selectedCats.map((c) => (
|
{selectedCats.map((c) => (
|
||||||
<span
|
<span
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className="inline-flex items-center gap-1.5 bg-info/15 border border-info/30 rounded-full px-3 py-1 text-xs font-medium"
|
className="inline-flex items-center gap-1.5 bg-info/15 rounded-full px-3 py-1 text-xs font-label font-medium"
|
||||||
>
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
<button
|
<button
|
||||||
@@ -368,8 +368,8 @@ export function CategoryPicker({
|
|||||||
if (id) onChange([...selected, id]);
|
if (id) onChange([...selected, id]);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -452,9 +452,9 @@ export function FeatureOverridesEditor({
|
|||||||
onClick={addFlag}
|
onClick={addFlag}
|
||||||
disabled={!newKey.trim()}
|
disabled={!newKey.trim()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
"rounded-xl px-4 py-2 text-sm font-label font-medium transition-colors",
|
||||||
newKey.trim()
|
newKey.trim()
|
||||||
? "bg-primary/15 text-primary hover:bg-primary/25 border border-primary/30"
|
? "bg-primary/15 text-primary hover:bg-primary/25"
|
||||||
: "bg-raised text-text-disabled cursor-not-allowed"
|
: "bg-raised text-text-disabled cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -475,12 +475,12 @@ export function SectionCard({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-lg border border-border">
|
<div className="bg-card rounded-xl">
|
||||||
<div className="flex items-center gap-2.5 px-6 py-4 border-b border-border">
|
<div className="flex items-center gap-2.5 px-6 py-4">
|
||||||
<Icon className="w-4 h-4 text-primary" />
|
<Icon className="w-4 h-4 text-primary" />
|
||||||
<h3 className="text-sm font-semibold">{title}</h3>
|
<h3 className="text-sm font-display font-semibold">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5 space-y-5">{children}</div>
|
<div className="px-6 py-5 pt-0 space-y-5">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ function InventoryAddForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs font-semibold text-text-secondary">Add Item</p>
|
<p className="text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">Add Item</p>
|
||||||
<select
|
<select
|
||||||
value={selectedItemId}
|
value={selectedItemId}
|
||||||
onChange={(e) => setSelectedItemId(e.target.value)}
|
onChange={(e) => setSelectedItemId(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -69,8 +69,8 @@ function InventoryAddForm({
|
|||||||
min="1"
|
min="1"
|
||||||
placeholder="Qty"
|
placeholder="Qty"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground",
|
"w-20 bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm font-mono text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -78,8 +78,8 @@ function InventoryAddForm({
|
|||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={!selectedItemId}
|
disabled={!selectedItemId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
"flex-1 px-3 py-2 rounded-xl text-sm font-label font-medium transition-colors",
|
||||||
"bg-primary text-white hover:bg-primary/90",
|
"bg-primary text-on-primary hover:opacity-90",
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
"flex items-center justify-center gap-1.5"
|
"flex items-center justify-center gap-1.5"
|
||||||
)}
|
)}
|
||||||
@@ -135,12 +135,12 @@ export function UserDetailPanel({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 md:relative md:w-96 border-l border-border bg-card overflow-auto z-50 md:z-auto">
|
<div className="fixed inset-0 md:relative md:w-96 bg-surface-container-low overflow-auto z-50 md:z-auto">
|
||||||
<div className="p-6 space-y-6 pb-24">
|
<div className="p-6 space-y-6 pb-24">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-1">
|
<h2 className="text-lg font-display font-semibold text-foreground mb-1">
|
||||||
{user.username}
|
{user.username}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs font-mono text-text-tertiary">{user.id}</p>
|
<p className="text-xs font-mono text-text-tertiary">{user.id}</p>
|
||||||
@@ -226,7 +226,7 @@ export function UserDetailPanel({
|
|||||||
{inventoryDraft.map((entry) => (
|
{inventoryDraft.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.itemId}
|
key={entry.itemId}
|
||||||
className="flex items-center justify-between gap-3 p-2 bg-raised rounded-md"
|
className="flex items-center justify-between gap-3 p-2 bg-raised rounded-xl"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground truncate">
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
@@ -238,7 +238,7 @@ export function UserDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveItem(entry.itemId)}
|
onClick={() => onRemoveItem(entry.itemId)}
|
||||||
className="p-1.5 text-text-tertiary hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
className="p-1.5 text-text-tertiary hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||||
title="Remove item"
|
title="Remove item"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -249,7 +249,7 @@ export function UserDetailPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Item Form */}
|
{/* Add Item Form */}
|
||||||
<div className="mt-4 pt-4 border-t border-border">
|
<div className="mt-4 pt-4">
|
||||||
<InventoryAddForm items={items} onAdd={onAddItem} />
|
<InventoryAddForm items={items} onAdd={onAddItem} />
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -257,7 +257,7 @@ export function UserDetailPanel({
|
|||||||
|
|
||||||
{/* Sticky footer for save/discard (only shown when dirty) */}
|
{/* Sticky footer for save/discard (only shown when dirty) */}
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<div className="sticky bottom-0 left-0 right-0 border-t border-border bg-card p-4 space-y-3">
|
<div className="sticky bottom-0 left-0 right-0 bg-surface-container-high p-4 space-y-3">
|
||||||
<div className="flex items-center gap-2 text-amber-400">
|
<div className="flex items-center gap-2 text-amber-400">
|
||||||
<AlertTriangle className="w-4 h-4" />
|
<AlertTriangle className="w-4 h-4" />
|
||||||
<span className="text-sm font-medium">You have unsaved changes</span>
|
<span className="text-sm font-medium">You have unsaved changes</span>
|
||||||
@@ -267,8 +267,8 @@ export function UserDetailPanel({
|
|||||||
onClick={onDiscard}
|
onClick={onDiscard}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
"flex-1 px-4 py-2 rounded-xl text-sm font-label font-medium transition-colors",
|
||||||
"bg-input border border-border text-foreground hover:bg-raised",
|
"bg-raised text-foreground hover:bg-surface-container-highest",
|
||||||
saving && "opacity-50 cursor-not-allowed"
|
saving && "opacity-50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -278,8 +278,8 @@ export function UserDetailPanel({
|
|||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
"flex-1 px-4 py-2 rounded-xl text-sm font-label font-medium transition-colors",
|
||||||
"bg-primary text-white hover:bg-primary/90",
|
"bg-primary text-on-primary hover:opacity-90",
|
||||||
"flex items-center justify-center gap-2",
|
"flex items-center justify-center gap-2",
|
||||||
saving && "opacity-50 cursor-not-allowed"
|
saving && "opacity-50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function Field({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-text-secondary">
|
<label className="block text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{children}
|
{children}
|
||||||
@@ -48,8 +48,8 @@ export function NumberInput({
|
|||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm font-mono text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -75,9 +75,9 @@ export function StringInput({
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm font-mono text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors",
|
"transition-colors placeholder:text-text-disabled",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -126,8 +126,8 @@ export function SelectInput({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"w-full bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -150,10 +150,10 @@ export function SectionCard({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg p-4">
|
<div className="bg-card rounded-xl p-4">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Icon className="w-4 h-4 text-primary" />
|
<Icon className="w-4 h-4 text-primary" />
|
||||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
<h3 className="text-sm font-display font-semibold text-foreground">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function UserPagination({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
|
<div className="mt-4 flex flex-wrap gap-4 items-center justify-between">
|
||||||
{/* Items info */}
|
{/* Items info */}
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm font-label text-text-secondary">
|
||||||
Showing {startItem}\u2013{endItem} of {formatNumber(total)} users
|
Showing {startItem}\u2013{endItem} of {formatNumber(total)} users
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -68,10 +68,10 @@ export function UserPagination({
|
|||||||
onClick={() => onPageChange(currentPage - 1)}
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
"px-3 py-2 rounded-xl text-sm font-medium transition-colors",
|
||||||
currentPage === 1
|
currentPage === 1
|
||||||
? "bg-raised text-text-tertiary cursor-not-allowed"
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
: "bg-raised text-foreground hover:bg-surface-container-highest"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
@@ -84,10 +84,10 @@ export function UserPagination({
|
|||||||
key={i}
|
key={i}
|
||||||
onClick={() => onPageChange(page)}
|
onClick={() => onPageChange(page)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors min-w-[40px]",
|
"px-3 py-2 rounded-xl text-sm font-label font-medium transition-colors min-w-[40px]",
|
||||||
page === currentPage
|
page === currentPage
|
||||||
? "bg-primary text-white"
|
? "bg-primary text-on-primary"
|
||||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
: "bg-raised text-foreground hover:bg-surface-container-highest"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
@@ -104,10 +104,10 @@ export function UserPagination({
|
|||||||
onClick={() => onPageChange(currentPage + 1)}
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
"px-3 py-2 rounded-xl text-sm font-medium transition-colors",
|
||||||
currentPage === totalPages
|
currentPage === totalPages
|
||||||
? "bg-raised text-text-tertiary cursor-not-allowed"
|
? "bg-raised text-text-tertiary cursor-not-allowed"
|
||||||
: "bg-input border border-border text-foreground hover:bg-raised"
|
: "bg-raised text-foreground hover:bg-surface-container-highest"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@@ -118,8 +118,8 @@ export function UserPagination({
|
|||||||
value={limit}
|
value={limit}
|
||||||
onChange={(e) => onLimitChange(Number(e.target.value))}
|
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-2 bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
|
"ml-2 bg-input border-b-2 border-outline-variant rounded-none px-3 py-2 text-sm text-foreground",
|
||||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
"focus:outline-none focus:border-primary",
|
||||||
"transition-colors"
|
"transition-colors"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,31 +18,31 @@ export function UserTable({
|
|||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-raised border-b border-border">
|
<thead className="bg-raised">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Username
|
Username
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Level
|
Level
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Balance
|
Balance
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Class
|
Class
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<tr key={i} className="border-b border-border">
|
<tr key={i}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="h-4 bg-raised rounded animate-pulse w-32"></div>
|
<div className="h-4 bg-raised rounded animate-pulse w-32"></div>
|
||||||
</td>
|
</td>
|
||||||
@@ -69,9 +69,9 @@ export function UserTable({
|
|||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg p-12 text-center">
|
<div className="bg-card rounded-xl p-12 text-center">
|
||||||
<UserCircle2 className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
<UserCircle2 className="w-16 h-16 mx-auto mb-4 text-text-tertiary" />
|
||||||
<p className="text-lg font-semibold text-text-secondary mb-2">
|
<p className="text-lg font-display font-semibold text-text-secondary mb-2">
|
||||||
No users found
|
No users found
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-tertiary">
|
<p className="text-sm text-text-tertiary">
|
||||||
@@ -82,27 +82,27 @@ export function UserTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-raised border-b border-border">
|
<thead className="bg-raised">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Username
|
Username
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Level
|
Level
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Balance
|
Balance
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
XP
|
XP
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Class
|
Class
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary">
|
<th className="px-4 py-3 text-left text-xs font-label font-semibold text-text-secondary uppercase tracking-wide">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -112,7 +112,7 @@ export function UserTable({
|
|||||||
<tr
|
<tr
|
||||||
key={user.id}
|
key={user.id}
|
||||||
onClick={() => onSelectUser(user)}
|
onClick={() => onSelectUser(user)}
|
||||||
className="border-b border-border hover:bg-raised cursor-pointer transition-colors"
|
className="hover:bg-raised/40 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -152,7 +152,7 @@ export function UserTable({
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
|
"inline-flex items-center px-2 py-1 rounded-full text-xs font-label font-medium",
|
||||||
user.isActive
|
user.isActive
|
||||||
? "bg-green-500/20 text-green-400"
|
? "bg-green-500/20 text-green-400"
|
||||||
: "bg-gray-500/20 text-gray-400"
|
: "bg-gray-500/20 text-gray-400"
|
||||||
|
|||||||
Reference in New Issue
Block a user