logic commit
This commit is contained in:
parent
dd6495874f
commit
81a9d3c7b3
98 changed files with 20848 additions and 0 deletions
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(node:*)",
|
||||||
|
"Bash(nvm version:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(fnm list:*)",
|
||||||
|
"Bash(fnm exec:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(npm rebuild:*)",
|
||||||
|
"Bash(grep:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
193
AGENTS.md
Normal file
193
AGENTS.md
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
# DnD Campaign Hub - Agent Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Monorepo with a Node.js/Express API + Discord bot and React/Vite frontend.
|
||||||
|
|
||||||
|
**Stack:**
|
||||||
|
- Server: Node.js + TypeScript + Express + SQLite + Discord.js
|
||||||
|
- Web: React 19 + TypeScript + Vite + React Router
|
||||||
|
- Testing: Vitest
|
||||||
|
- Build: npm workspaces
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Root (Monorepo)
|
||||||
|
```bash
|
||||||
|
npm install # Install all dependencies
|
||||||
|
npm run dev # Run both server and web in dev mode
|
||||||
|
npm run build # Build server and web
|
||||||
|
npm run test # Run all tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server (`apps/server`)
|
||||||
|
```bash
|
||||||
|
npm run dev -w @dnd-hub/server # Dev mode with hot reload (tsx)
|
||||||
|
npm run build -w @dnd-hub/server # Compile TypeScript
|
||||||
|
npm run start -w @dnd-hub/server # Run compiled server
|
||||||
|
npm run test -w @dnd-hub/server # Run server tests
|
||||||
|
npm run migrate -w @dnd-hub/server # Run DB migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web (`apps/web`)
|
||||||
|
```bash
|
||||||
|
npm run dev -w @dnd-hub/web # Vite dev server (port 5173)
|
||||||
|
npm run build -w @dnd-hub/web # Type check + Vite build
|
||||||
|
npm run preview -w @dnd-hub/web # Preview production build
|
||||||
|
npm run test -w @dnd-hub/web # Run web tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running a Single Test
|
||||||
|
```bash
|
||||||
|
# Run specific test file
|
||||||
|
npm run test -w @dnd-hub/server -- test/sessionService.test.ts
|
||||||
|
npm run test -w @dnd-hub/web -- src/smoke.test.ts
|
||||||
|
|
||||||
|
# Run with filter pattern
|
||||||
|
npm run test -w @dnd-hub/server -- --run --reporter=verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- **Strict mode enabled** in both apps (`strict: true`)
|
||||||
|
- Target ES2022, use ESNext modules
|
||||||
|
- Server: `module: NodeNext`, `moduleResolution: NodeNext`
|
||||||
|
- Web: `module: ESNext`, `moduleResolution: Bundler`
|
||||||
|
- Always use `.ts`/`.tsx` extensions (no `.js` in src)
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- Use ES modules (`import`/`export`)
|
||||||
|
- Include `.js` extension for relative imports in server code
|
||||||
|
- Group imports: React/hooks first, then libraries, then local imports
|
||||||
|
- Example:
|
||||||
|
```typescript
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, Route, Routes } from "react-router-dom";
|
||||||
|
import { api } from "./api/client";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Files**: camelCase for components/services (e.g., `CharacterSheetDrawer.tsx`, `sessionService.ts`)
|
||||||
|
- **Components**: PascalCase (e.g., `CharacterPage`, `ToastProvider`)
|
||||||
|
- **Functions/variables**: camelCase
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE for config values
|
||||||
|
- **Types/Interfaces**: PascalCase (e.g., `Me`, `Campaign`)
|
||||||
|
- **Test files**: `*.test.ts` or `*.test.tsx` alongside or near tested code
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
- 2-space indentation
|
||||||
|
- Semicolons required
|
||||||
|
- Double quotes for strings (single in JSX when needed)
|
||||||
|
- Trailing commas in multi-line objects/arrays
|
||||||
|
- Max line length: ~100 chars (flexible)
|
||||||
|
|
||||||
|
### React Conventions
|
||||||
|
- Functional components with hooks only (no class components)
|
||||||
|
- Custom hooks prefixed with `use` (e.g., `useToast`, `useDebugMode`)
|
||||||
|
- Context providers for global state (Toast, Debug, CharacterSheet)
|
||||||
|
- Arrow functions for event handlers
|
||||||
|
- TypeScript interfaces for props
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Server: Use try/catch with proper HTTP status codes
|
||||||
|
- Validate inputs with Zod schemas
|
||||||
|
- Web: Use Toast context for user-facing errors
|
||||||
|
- Log errors server-side, show friendly messages client-side
|
||||||
|
- Never expose stack traces to clients
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
- RESTful endpoints under `/routes/`
|
||||||
|
- Use Express middleware for auth/validation
|
||||||
|
- CORS configured per environment
|
||||||
|
- Health check at `GET /health`
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- SQLite via better-sqlite3 (synchronous)
|
||||||
|
- Migrations in `src/db/migrate.ts`
|
||||||
|
- Path: `./data/dnd_hub.db`
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- Load via dotenv from monorepo root `.env`
|
||||||
|
- Use `config.ts` wrapper with defaults for dev
|
||||||
|
- Required vars throw on startup if missing
|
||||||
|
- Never commit `.env` (use `.env.example`)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Framework: Vitest
|
||||||
|
- Pattern: `describe`/`it`/`expect`
|
||||||
|
- Import from `vitest`
|
||||||
|
- Tests should be isolated and deterministic
|
||||||
|
- Smoke tests for basic functionality
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dnd-hub/
|
||||||
|
├── apps/
|
||||||
|
│ ├── server/
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── routes/ # Express route handlers
|
||||||
|
│ │ │ ├── services/ # Business logic
|
||||||
|
│ │ │ ├── db/ # Database schema/migrations
|
||||||
|
│ │ │ ├── discord/ # Discord bot commands
|
||||||
|
│ │ │ ├── jobs/ # Scheduled cron jobs
|
||||||
|
│ │ │ └── main.ts # Entry point
|
||||||
|
│ │ └── test/ # Test files
|
||||||
|
│ └── web/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # React components
|
||||||
|
│ │ ├── contexts/ # React context providers
|
||||||
|
│ │ ├── pages/ # Route page components
|
||||||
|
│ │ ├── api/ # API client
|
||||||
|
│ │ └── App.tsx # Root component
|
||||||
|
│ └── vite.config.ts
|
||||||
|
├── infra/ # Docker, nginx config
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
|
├── .env.example # Environment template
|
||||||
|
└── package.json # Workspace root
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Transcript Sessions
|
||||||
|
- Discord commands: `/session start`, `/session stop`, `/session status`
|
||||||
|
- API endpoints: `POST /sessions/*`, `GET /sessions/:id/transcript`
|
||||||
|
- Cloud sync via `CLOUD_API_BASE_URL` with HMAC signatures
|
||||||
|
|
||||||
|
### Discord Integration
|
||||||
|
- OAuth2 authentication flow
|
||||||
|
- Role-based permissions (admin/dm/player)
|
||||||
|
- Game night notifications via node-cron
|
||||||
|
|
||||||
|
### Recap System
|
||||||
|
- Configurable providers: ollama (local) or claude (cloud)
|
||||||
|
- Set via `RECAP_MODE` or `RECAP_PROVIDERS` env vars
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Route
|
||||||
|
1. Create handler in `apps/server/src/routes/`
|
||||||
|
2. Register in `app.ts`
|
||||||
|
3. Add TypeScript types if needed
|
||||||
|
|
||||||
|
### Adding a React Component
|
||||||
|
1. Create in `apps/web/src/components/`
|
||||||
|
2. Use TypeScript with typed props
|
||||||
|
3. Follow existing component patterns
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
1. Update schema in `src/db/migrate.ts`
|
||||||
|
2. Run `npm run migrate -w @dnd-hub/server`
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
- Web app has debug toggle to simulate player role
|
||||||
|
- Controlled via DebugContext
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `.env` is at monorepo root, loaded relative to workspace
|
||||||
|
- Server uses `.js` extensions in imports (NodeNext module resolution)
|
||||||
|
- Web uses JSX transform (`jsx: react-jsx`)
|
||||||
|
- Both apps share Vitest but run independently
|
||||||
|
- Clean up both `.js` and `.ts` files in web/src (legacy JS exists)
|
||||||
413
VOICE_TRANSCRIPT_PLAN.md
Normal file
413
VOICE_TRANSCRIPT_PLAN.md
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
# Discord Voice Transcript Implementation Plan
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Discord Voice Channel │
|
||||||
|
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ Player │ │ Player │ │ Player │ │
|
||||||
|
│ └───┬────┘ └───┬────┘ └───┬────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
└───────┼───────────┼───────────┼─────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Discord Bot (discord.js + @discordjs/voice) │
|
||||||
|
│ - Joins voice channel on /session start │
|
||||||
|
│ - Subscribes to each user's audio stream │
|
||||||
|
│ - Saves per-user .webm files to temp storage │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ (on /session stop)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Whisper Service (Docker container on Ubuntu server) │
|
||||||
|
│ - whisper.cpp or openai/whisper │
|
||||||
|
│ - Receives audio files via HTTP POST │
|
||||||
|
│ - Returns JSON with timestamps + transcript │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DnD Hub Server │
|
||||||
|
│ - Maps Discord user ID → character (existing logic) │
|
||||||
|
│ - Stores voice segments in transcript_segments table │
|
||||||
|
│ - Marks segments with source='voice' │
|
||||||
|
│ - AI recap includes both text + voice transcripts │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Database Schema (2-3 hours)
|
||||||
|
|
||||||
|
### Migration: `apps/server/src/db/migrations/010_voice_transcripts.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Add source column to distinguish text vs voice
|
||||||
|
ALTER TABLE transcript_segments ADD COLUMN source TEXT DEFAULT 'text';
|
||||||
|
|
||||||
|
-- Table for voice recording metadata
|
||||||
|
CREATE TABLE voice_recordings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
discord_user_id TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
recorded_at TEXT NOT NULL,
|
||||||
|
processed_at TEXT,
|
||||||
|
processing_status TEXT DEFAULT 'pending',
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for efficient lookups
|
||||||
|
CREATE INDEX idx_voice_recordings_session ON voice_recordings(session_id);
|
||||||
|
CREATE INDEX idx_transcript_source ON transcript_segments(source);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Time:** 30 min schema design + 30 min migration + 1 hr testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Whisper Docker Service (3-4 hours)
|
||||||
|
|
||||||
|
### Option A: whisper.cpp (recommended for performance)
|
||||||
|
```yaml
|
||||||
|
# Add to docker-compose.yml
|
||||||
|
services:
|
||||||
|
whisper:
|
||||||
|
image: ghcr.io/ggerganov/whisper.cpp:server
|
||||||
|
ports:
|
||||||
|
- "10888:10888"
|
||||||
|
volumes:
|
||||||
|
- ./whisper-models:/models
|
||||||
|
environment:
|
||||||
|
- WHISPER_MODEL=/models/ggml-large-v3.bin
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: OpenAI Whisper (simpler)
|
||||||
|
```yaml
|
||||||
|
whisper:
|
||||||
|
image: ghcr.io/openai/whisper:latest
|
||||||
|
ports:
|
||||||
|
- "10888:10888"
|
||||||
|
volumes:
|
||||||
|
- ./whisper-audio:/audio
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup steps:**
|
||||||
|
1. Add whisper service to `docker-compose.yml`
|
||||||
|
2. Download model (`ggml-large-v3.bin` ~3GB, best accuracy)
|
||||||
|
3. Test API endpoint: `POST /inference` with audio file
|
||||||
|
4. Create `apps/server/src/services/whisperClient.ts`
|
||||||
|
|
||||||
|
**whisperClient.ts:**
|
||||||
|
```typescript
|
||||||
|
import FormData from 'form-data';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
const WHISPER_URL = process.env.WHISPER_BASE_URL ?? 'http://whisper:10888';
|
||||||
|
|
||||||
|
export async function transcribeAudio(audioBuffer: Buffer, speakerId: string) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', audioBuffer, { filename: `${speakerId}.webm` });
|
||||||
|
form.append('model', 'large-v3');
|
||||||
|
form.append('response_format', 'verbose_json');
|
||||||
|
form.append('word_timestamps', 'true');
|
||||||
|
|
||||||
|
const res = await fetch(`${WHISPER_URL}/inference`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(); // { text, segments: [{start, end, text}] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Time:** 2 hrs Docker setup + 1-2 hrs client + testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Voice Recording in Discord Bot (5-7 hours)
|
||||||
|
|
||||||
|
### Update `discordService.ts` with voice intents:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { joinVoiceChannel, createAudioSubscriber, VoiceConnectionStatus } from '@discordjs/voice';
|
||||||
|
import { AudioReceiveStream } from 'discord.js';
|
||||||
|
|
||||||
|
// Add to intents
|
||||||
|
const client = new Client({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
|
GatewayIntentBits.GuildMessages
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### New service: `apps/server/src/services/voiceRecorder.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { joinVoiceChannel, createAudioSubscriber } from '@discordjs/voice';
|
||||||
|
import { VoiceConnection } from 'discord.js';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const RECORDINGS_DIR = path.resolve('./data/voice-recordings');
|
||||||
|
|
||||||
|
class VoiceRecorder {
|
||||||
|
private connection: VoiceConnection | null = null;
|
||||||
|
private recordingStreams = new Map<string, WriteStream>();
|
||||||
|
private sessionId: number | null = null;
|
||||||
|
|
||||||
|
async joinChannel(guildId: number, channelId: string, sessionId: number) {
|
||||||
|
this.connection = joinVoiceChannel({
|
||||||
|
channelId,
|
||||||
|
guildId,
|
||||||
|
adapterCreator: getAdapter() // discord.js adapter
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connection.on(VoiceConnectionStatus.Ready, () => {
|
||||||
|
console.log(`Voice connected for session ${sessionId}`);
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to all users
|
||||||
|
this.connection.receiver.subscriptions.on('entry', (userId, stream) => {
|
||||||
|
this.startRecording(userId, stream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRecording(userId: string, stream: AudioReceiveStream) {
|
||||||
|
const filePath = path.join(RECORDINGS_DIR, `${this.sessionId}_${userId}.webm`);
|
||||||
|
mkdirSync(RECORDINGS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const file = createWriteStream(filePath);
|
||||||
|
stream.pipe(file);
|
||||||
|
this.recordingStreams.set(userId, file);
|
||||||
|
|
||||||
|
// Track in DB
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO voice_recordings (session_id, discord_user_id, file_path, recorded_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
`).run(this.sessionId, userId, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopRecording() {
|
||||||
|
// Close all file streams
|
||||||
|
for (const stream of this.recordingStreams.values()) {
|
||||||
|
stream.end();
|
||||||
|
}
|
||||||
|
this.recordingStreams.clear();
|
||||||
|
|
||||||
|
// Leave voice channel
|
||||||
|
this.connection?.destroy();
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async processRecordings(sessionId: number) {
|
||||||
|
const recordings = db.prepare(`
|
||||||
|
SELECT * FROM voice_recordings
|
||||||
|
WHERE session_id = ? AND processing_status = 'pending'
|
||||||
|
`).all(sessionId);
|
||||||
|
|
||||||
|
for (const rec of recordings) {
|
||||||
|
await this.processSingleRecording(rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processSingleRecording(recording: any) {
|
||||||
|
const audioBuffer = readFileSync(recording.file_path);
|
||||||
|
const result = await transcribeAudio(audioBuffer, recording.discord_user_id);
|
||||||
|
|
||||||
|
// Store transcript segments
|
||||||
|
for (const segment of result.segments) {
|
||||||
|
sessionService.appendSegment({
|
||||||
|
sessionId: this.sessionId!,
|
||||||
|
guildId: /* get from session */,
|
||||||
|
discordUserId: recording.discord_user_id,
|
||||||
|
text: segment.text,
|
||||||
|
startedAt: segment.start,
|
||||||
|
endedAt: segment.end,
|
||||||
|
confidence: segment.confidence,
|
||||||
|
source: 'voice'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE voice_recordings
|
||||||
|
SET processing_status = 'completed', processed_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(recording.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceRecorder = new VoiceRecorder();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Time:** 4-5 hrs discord.js voice API + 2 hrs file handling + 1 hr testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Discord Command Integration (2-3 hours)
|
||||||
|
|
||||||
|
### Update `/session start` command:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In discordService.ts handleSessionCommand
|
||||||
|
if (sub === 'start') {
|
||||||
|
const result = sessionService.startSession(guildId, user.id);
|
||||||
|
|
||||||
|
// Get the voice channel the user is in
|
||||||
|
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||||
|
const voiceChannel = member.voice.channel;
|
||||||
|
|
||||||
|
if (voiceChannel) {
|
||||||
|
await voiceRecorder.joinChannel(
|
||||||
|
interaction.guildId!,
|
||||||
|
voiceChannel.id,
|
||||||
|
result.sessionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Started session #${result.sessionId}. ${voiceChannel ? '🎤 Recording voice' : '📝 Text only'}`,
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update `/session stop` command:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (sub === 'stop') {
|
||||||
|
await voiceRecorder.stopRecording();
|
||||||
|
await voiceRecorder.processRecordings(sessionId);
|
||||||
|
sessionService.stopSession(guildId, user.id);
|
||||||
|
await interaction.reply({ content: `Stopped session #${active.id}. Processing voice transcripts...`, ephemeral: false });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Time:** 2 hrs integration + 1 hr testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: UI for Voice Transcripts (4-6 hours)
|
||||||
|
|
||||||
|
### Update `CampaignDetailPage.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Add filter toggle for transcript sources
|
||||||
|
const [showVoice, setShowVoice] = useState(true);
|
||||||
|
const [showText, setShowText] = useState(true);
|
||||||
|
|
||||||
|
// Filter segments
|
||||||
|
const filteredSegments = segments.filter(s =>
|
||||||
|
(s.source === 'voice' && showVoice) || (s.source === 'text' && showText)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add audio player for voice segments
|
||||||
|
{segment.source === 'voice' && (
|
||||||
|
<audio controls src={`/api/voice/${segment.recording_id}`} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New API endpoint: `apps/server/src/routes/voiceRoutes.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../middleware.js';
|
||||||
|
|
||||||
|
export const voiceRoutes = Router();
|
||||||
|
|
||||||
|
voiceRoutes.get('/voice/:recordingId', requireAuth, (req, res) => {
|
||||||
|
const recording = db.prepare('SELECT * FROM voice_recordings WHERE id = ?').get(req.params.recordingId);
|
||||||
|
if (!recording) return res.status(404).send('Not found');
|
||||||
|
|
||||||
|
res.sendFile(path.resolve(recording.file_path));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Time:** 3 hrs UI components + 2 hrs API + 1 hr testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: AI Recap Integration (2-3 hours)
|
||||||
|
|
||||||
|
### Update `recapService.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In buildContext, include voice segments
|
||||||
|
const segments = db
|
||||||
|
.prepare(`
|
||||||
|
SELECT sequence, speaker_label_snapshot AS speakerLabel,
|
||||||
|
text, started_at AS startedAt, source
|
||||||
|
FROM transcript_segments
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY sequence ASC
|
||||||
|
`)
|
||||||
|
.all(sessionId);
|
||||||
|
|
||||||
|
// Update prompt to mention voice transcripts
|
||||||
|
const prompt = `
|
||||||
|
Summarize this DnD session transcript.
|
||||||
|
Note: Some segments are from voice transcription (may have minor errors).
|
||||||
|
|
||||||
|
${context.segments.map(s =>
|
||||||
|
`[${s.startedAt}] ${s.speakerLabel}: ${s.text}${s.source === 'voice' ? ' [voice]' : ''}`
|
||||||
|
).join('\n')}
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Time:** 1-2 hrs updates + 1 hr testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Timeline
|
||||||
|
|
||||||
|
| Phase | Description | Hours | Cumulative |
|
||||||
|
|-------|-------------|-------|------------|
|
||||||
|
| 1 | DB schema + migration | 2-3 | 2-3 |
|
||||||
|
| 2 | Whisper Docker service | 3-4 | 5-7 |
|
||||||
|
| 3 | Voice recording service | 5-7 | 10-14 |
|
||||||
|
| 4 | Discord command integration | 2-3 | 12-17 |
|
||||||
|
| 5 | UI for voice transcripts | 4-6 | 16-23 |
|
||||||
|
| 6 | AI recap integration | 2-3 | 18-26 |
|
||||||
|
|
||||||
|
**Total: 18-26 hours** (3-4 full days)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies to Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Server workspace
|
||||||
|
npm install @discordjs/voice form-data node-fetch -w @dnd-hub/server
|
||||||
|
npm install -D @types/node-fetch -w @dnd-hub/server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps (Where to Pick Up)
|
||||||
|
|
||||||
|
1. **Phase 1**: Create `apps/server/src/db/migrations/010_voice_transcripts.sql`
|
||||||
|
2. **Phase 2**: Update `docker-compose.yml` with whisper service
|
||||||
|
3. **Phase 2**: Create `apps/server/src/services/whisperClient.ts`
|
||||||
|
|
||||||
|
Then proceed through phases 3-6 sequentially.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Decisions Made
|
||||||
|
|
||||||
|
- **Transcription**: whisper.cpp via Docker (local, free, runs on existing Ubuntu server)
|
||||||
|
- **Audio format**: Per-user .webm tracks (better speaker separation)
|
||||||
|
- **Processing**: Batch on /session stop (not real-time)
|
||||||
|
- **Storage**: Voice files in `apps/server/data/voice-recordings/`
|
||||||
|
- **DB**: Single `transcript_segments` table with `source` column (voice vs text)
|
||||||
35
apps/server/package.json
Normal file
35
apps/server/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "@dnd-hub/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"discord.js": "^14.19.3",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"node-cron": "^4.0.5",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"tsx": "^4.20.2",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vitest": "^3.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
31
apps/server/src/app.ts
Normal file
31
apps/server/src/app.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import cors from "cors";
|
||||||
|
import express from "express";
|
||||||
|
import { authRoutes } from "./routes/authRoutes.js";
|
||||||
|
import { campaignRoutes } from "./routes/campaignRoutes.js";
|
||||||
|
import { characterRoutes } from "./routes/characterRoutes.js";
|
||||||
|
import { dndBeyondRoutes } from "./routes/dndBeyondRoutes.js";
|
||||||
|
import { dmRoutes } from "./routes/dmRoutes.js";
|
||||||
|
import { wikiRoutes } from "./routes/wikiRoutes.js";
|
||||||
|
import { gameNightRoutes } from "./routes/gameNightRoutes.js";
|
||||||
|
import { sessionRoutes } from "./routes/sessionRoutes.js";
|
||||||
|
import { userRoutes } from "./routes/userRoutes.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: config.webBaseUrl }));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
app.use(authRoutes);
|
||||||
|
app.use(userRoutes);
|
||||||
|
app.use(dmRoutes);
|
||||||
|
app.use(campaignRoutes);
|
||||||
|
app.use(gameNightRoutes);
|
||||||
|
app.use(sessionRoutes);
|
||||||
|
app.use(characterRoutes);
|
||||||
|
app.use(dndBeyondRoutes);
|
||||||
|
app.use(wikiRoutes);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
57
apps/server/src/config.ts
Normal file
57
apps/server/src/config.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// .env lives at the monorepo root, two levels above apps/server/
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), "../../.env") });
|
||||||
|
|
||||||
|
const required = (key: string, fallback?: string): string => {
|
||||||
|
const value = process.env[key] ?? fallback;
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required env var: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||||
|
port: Number(process.env.PORT ?? 4000),
|
||||||
|
appBaseUrl: required("APP_BASE_URL", "http://localhost:4000"),
|
||||||
|
webBaseUrl: required("WEB_BASE_URL", "http://localhost:5173"),
|
||||||
|
jwtSecret: required("JWT_SECRET", "dev-secret"),
|
||||||
|
sqlitePath: required("SQLITE_PATH", "./data/dnd_hub.db"),
|
||||||
|
discordClientId: required("DISCORD_CLIENT_ID", "discord-client-id"),
|
||||||
|
discordClientSecret: required("DISCORD_CLIENT_SECRET", "discord-client-secret"),
|
||||||
|
discordBotToken: required("DISCORD_BOT_TOKEN", "discord-bot-token"),
|
||||||
|
discordGuildId: required("DISCORD_GUILD_ID", "discord-guild-id"),
|
||||||
|
discordOauthRedirectUri: required("DISCORD_OAUTH_REDIRECT_URI", "http://localhost:4000/auth/discord/callback"),
|
||||||
|
discordAdminRoleIds: (process.env.DISCORD_ADMIN_ROLE_IDS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
discordGameNightChannelId: required("DISCORD_GAME_NIGHT_CHANNEL_ID", "discord-game-night-channel"),
|
||||||
|
notifyHour: Number(process.env.NOTIFY_HOUR ?? 10),
|
||||||
|
notifyMinute: Number(process.env.NOTIFY_MINUTE ?? 0),
|
||||||
|
timezone: process.env.TIMEZONE ?? "America/New_York",
|
||||||
|
// Recap summarization
|
||||||
|
// RECAP_MODE: "local-first" (ollama → claude → template) or "cloud-first" (claude → ollama → template)
|
||||||
|
// RECAP_PROVIDERS overrides RECAP_MODE for full manual control.
|
||||||
|
recapProviders: (() => {
|
||||||
|
if (process.env.RECAP_PROVIDERS) {
|
||||||
|
return process.env.RECAP_PROVIDERS.split(",").map(v => v.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
const mode = process.env.RECAP_MODE ?? "local-first";
|
||||||
|
return mode === "cloud-first"
|
||||||
|
? ["claude", "ollama", "template"]
|
||||||
|
: ["ollama", "claude", "template"];
|
||||||
|
})(),
|
||||||
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? "",
|
||||||
|
ollamaBaseUrl: process.env.OLLAMA_BASE_URL ?? "http://localhost:11434",
|
||||||
|
ollamaModel: process.env.OLLAMA_MODEL ?? "llama3.2",
|
||||||
|
// Transcription (Whisper API-compatible)
|
||||||
|
openaiApiKey: process.env.OPENAI_API_KEY ?? "",
|
||||||
|
whisperBaseUrl: process.env.WHISPER_BASE_URL ?? "https://api.openai.com/v1",
|
||||||
|
whisperModel: process.env.WHISPER_MODEL ?? "whisper-1",
|
||||||
|
whisperLanguage: process.env.WHISPER_LANGUAGE ?? "en",
|
||||||
|
// Static invite code gate. If empty, the feature is disabled.
|
||||||
|
inviteCode: process.env.INVITE_CODE ?? ""
|
||||||
|
};
|
||||||
15
apps/server/src/db/client.ts
Normal file
15
apps/server/src/db/client.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
const dbPath = path.resolve(config.sqlitePath);
|
||||||
|
const dbDir = path.dirname(dbPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = new Database(dbPath);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
|
||||||
38
apps/server/src/db/migrate.ts
Normal file
38
apps/server/src/db/migrate.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { db } from "./client.js";
|
||||||
|
|
||||||
|
const migrationsDir = path.resolve("src/db/migrations");
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(migrationsDir)
|
||||||
|
.filter((f) => f.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
run_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const applied = new Set(
|
||||||
|
db.prepare("SELECT id FROM _migrations").all().map((row: any) => row.id as string)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (applied.has(file)) continue;
|
||||||
|
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf8");
|
||||||
|
db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
db.exec(sql);
|
||||||
|
db.prepare("INSERT INTO _migrations (id, run_at) VALUES (?, datetime('now'))").run(file);
|
||||||
|
db.exec("COMMIT");
|
||||||
|
console.log(`Applied migration: ${file}`);
|
||||||
|
} catch (error) {
|
||||||
|
db.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Migrations complete");
|
||||||
|
|
||||||
141
apps/server/src/db/migrations/001_init.sql
Normal file
141
apps/server/src/db/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS guilds (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
discord_guild_id TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'America/New_York',
|
||||||
|
game_night_channel_id TEXT,
|
||||||
|
notify_hour INTEGER NOT NULL DEFAULT 10,
|
||||||
|
notify_minute INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
discord_user_id TEXT UNIQUE NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS memberships (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('player', 'dm', 'admin', 'pending_dm')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(guild_id, user_id, role),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS campaigns (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'paused', 'archived')),
|
||||||
|
discord_channel_id TEXT,
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS campaign_dms (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
campaign_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(campaign_id, user_id),
|
||||||
|
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS campaign_posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
campaign_id INTEGER NOT NULL,
|
||||||
|
author_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body_md TEXT NOT NULL,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'guild',
|
||||||
|
discord_message_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_nights (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
scheduled_date TEXT NOT NULL,
|
||||||
|
selected_campaign_id INTEGER,
|
||||||
|
selected_by INTEGER,
|
||||||
|
lock_time TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(guild_id, scheduled_date),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (selected_campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (selected_by) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS characters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
campaign_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
character_name TEXT NOT NULL,
|
||||||
|
race TEXT NOT NULL,
|
||||||
|
class TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL DEFAULT 1,
|
||||||
|
alignment TEXT,
|
||||||
|
pronouns TEXT,
|
||||||
|
portrait_url TEXT,
|
||||||
|
bio TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS nickname_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
game_night_id INTEGER NOT NULL,
|
||||||
|
character_id INTEGER NOT NULL,
|
||||||
|
original_nickname TEXT,
|
||||||
|
applied_nickname TEXT NOT NULL,
|
||||||
|
reverted_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (game_night_id) REFERENCES game_nights(id),
|
||||||
|
FOREIGN KEY (character_id) REFERENCES characters(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
actor_user_id INTEGER,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (actor_user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recaps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
campaign_id INTEGER NOT NULL,
|
||||||
|
game_night_id INTEGER,
|
||||||
|
author_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body_md TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (game_night_id) REFERENCES game_nights(id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
64
apps/server/src/db/migrations/002_transcripts.sql
Normal file
64
apps/server/src/db/migrations/002_transcripts.sql
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
campaign_id INTEGER NOT NULL,
|
||||||
|
game_night_id INTEGER,
|
||||||
|
started_by INTEGER NOT NULL,
|
||||||
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
ended_at TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'ended')),
|
||||||
|
recap_status TEXT NOT NULL DEFAULT 'pending' CHECK(recap_status IN ('pending', 'generated', 'failed')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (game_night_id) REFERENCES game_nights(id),
|
||||||
|
FOREIGN KEY (started_by) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
campaign_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
discord_user_id TEXT NOT NULL,
|
||||||
|
character_id INTEGER,
|
||||||
|
character_name_snapshot TEXT,
|
||||||
|
speaker_label_snapshot TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(session_id, user_id),
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (character_id) REFERENCES characters(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transcript_segments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
campaign_id INTEGER NOT NULL,
|
||||||
|
game_night_id INTEGER,
|
||||||
|
sequence INTEGER NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
discord_user_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
|
character_id INTEGER,
|
||||||
|
character_name_snapshot TEXT,
|
||||||
|
speaker_label_snapshot TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
raw_text TEXT,
|
||||||
|
confidence REAL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(session_id, sequence),
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (game_night_id) REFERENCES game_nights(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (character_id) REFERENCES characters(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
11
apps/server/src/db/migrations/003_blackouts.sql
Normal file
11
apps/server/src/db/migrations/003_blackouts.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS blackouts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(guild_id, user_id, date),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
1
apps/server/src/db/migrations/004_guild_settings.sql
Normal file
1
apps/server/src/db/migrations/004_guild_settings.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE guilds ADD COLUMN default_game_day INTEGER; -- 0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat
|
||||||
20
apps/server/src/db/migrations/005_wiki.sql
Normal file
20
apps/server/src/db/migrations/005_wiki.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS wiki_pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
campaign_id INTEGER NOT NULL,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
author_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL CHECK(length(trim(title)) > 0),
|
||||||
|
body_md TEXT NOT NULL DEFAULT '',
|
||||||
|
category TEXT NOT NULL DEFAULT 'General'
|
||||||
|
CHECK(category IN ('NPCs','Items','Locations','Lore','General')),
|
||||||
|
note_type TEXT NOT NULL DEFAULT 'shared'
|
||||||
|
CHECK(note_type IN ('player','shared','dm')),
|
||||||
|
session_date TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wiki_pages_campaign ON wiki_pages(campaign_id, note_type, category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wiki_pages_author ON wiki_pages(campaign_id, author_id);
|
||||||
122
apps/server/src/db/migrations/006_wiki_seed.sql
Normal file
122
apps/server/src/db/migrations/006_wiki_seed.sql
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
-- Seed a handful of wiki pages for visual testing.
|
||||||
|
-- Uses the first campaign in the DB; does nothing if no campaign exists.
|
||||||
|
|
||||||
|
INSERT INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.guild_id,
|
||||||
|
c.created_by,
|
||||||
|
'The City of Silverkeep',
|
||||||
|
'# Silverkeep
|
||||||
|
|
||||||
|
A walled trade city perched at the confluence of the Arren and Mireth rivers. Population ~40,000. Governed by the **Merchant Council of Five**, each seat held by the head of a major trading house.
|
||||||
|
|
||||||
|
## Districts
|
||||||
|
|
||||||
|
- **The Warrens** — cramped lower-city tenements; where the party first arrived
|
||||||
|
- **Coppergate** — artisan quarter, home to the Artificers'' Guild
|
||||||
|
- **Highmantle** — noble estates and the Council Hall
|
||||||
|
- **The Docks** — perpetually fog-bound, controlled by the Stevedores'' Brotherhood
|
||||||
|
|
||||||
|
## Notable Figures
|
||||||
|
|
||||||
|
| Name | Role | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| Aldara Voss | Council Chair | Rumoured to deal with the Shadow Exchange |
|
||||||
|
| Dorin Tusk | City Watch Commander | Owes the party a favour |
|
||||||
|
| "Needle" | Thieves'' Guild contact | Met in Session 3 |
|
||||||
|
|
||||||
|
## Lore Hook
|
||||||
|
|
||||||
|
The party discovered a tunnel beneath the Warrens leading to a pre-city ruin. The inscription above the entrance read: *"Here the old roads remember."*',
|
||||||
|
'Locations',
|
||||||
|
'shared'
|
||||||
|
FROM campaigns c
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.guild_id,
|
||||||
|
c.created_by,
|
||||||
|
'Aldara Voss — Council Chair',
|
||||||
|
'# Aldara Voss
|
||||||
|
|
||||||
|
**Role:** Chair of the Merchant Council of Five
|
||||||
|
**Age:** ~55
|
||||||
|
**Affiliation:** House Voss (textiles & magical reagents)
|
||||||
|
|
||||||
|
## Appearance
|
||||||
|
|
||||||
|
Tall, severe features. Always wears deep violet robes with silver thread. Never seen without her signet ring — a serpent eating its tail.
|
||||||
|
|
||||||
|
## Known Facts
|
||||||
|
|
||||||
|
- Has held the Chair seat for 11 years, an unusually long tenure
|
||||||
|
- Her trade ships have never been raided by the river pirates that plague competitors
|
||||||
|
- Three political rivals have died of "natural causes" during her tenure
|
||||||
|
|
||||||
|
## DM Notes (Public)
|
||||||
|
|
||||||
|
The party has met her twice. She was cordial but clearly measuring them. She offered a *"small task"* in Session 4 that the group declined.
|
||||||
|
|
||||||
|
## Rumours
|
||||||
|
|
||||||
|
> *"They say she doesn''t sleep — that she communes with something in the dark water beneath Highmantle."*
|
||||||
|
> — overheard at the Gilded Flagon',
|
||||||
|
'NPCs',
|
||||||
|
'shared'
|
||||||
|
FROM campaigns c
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.guild_id,
|
||||||
|
c.created_by,
|
||||||
|
'The Shadow Exchange — DM Notes',
|
||||||
|
'## What It Actually Is
|
||||||
|
|
||||||
|
The Shadow Exchange is not a thieves'' guild — it is a centuries-old **information brokerage** run by a lich named Verath the Pale who was cursed to exist only as written contracts.
|
||||||
|
|
||||||
|
Aldara Voss discovered a partial contract in the ruins beneath the Warrens (see Silverkeep). She has been feeding it information about the Council in exchange for "favours" — specifically, eliminating rivals.
|
||||||
|
|
||||||
|
## The Contract Mechanic
|
||||||
|
|
||||||
|
Anyone who signs a contract with the Exchange is bound. The party may find partial contracts. If a PC signs one (even unknowingly), they are **compelled to fulfil the terms or suffer the curse** (1d6 psychic damage per day of delay, escalating).
|
||||||
|
|
||||||
|
## Planned Reveal
|
||||||
|
|
||||||
|
Session 7 — the party traces the tunnel to the ruin and finds the Exchange''s archive vault. The contracts for Voss and two other Council members are there.',
|
||||||
|
'Lore',
|
||||||
|
'dm'
|
||||||
|
FROM campaigns c
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.guild_id,
|
||||||
|
c.created_by,
|
||||||
|
'Amulet of Unwritten Paths',
|
||||||
|
'## Description
|
||||||
|
|
||||||
|
A tarnished silver amulet on a black cord. The face shows a compass rose with no cardinal markings — the needle spins freely.
|
||||||
|
|
||||||
|
## Properties (Attunement Required)
|
||||||
|
|
||||||
|
- **Wayfinding (passive):** The wearer always knows the cardinal directions and cannot become lost by mundane means.
|
||||||
|
- **Path Unwritten (1/day):** Choose a destination you have visited. The amulet tugs toward an *unexpected route* — never the obvious path. Often leads through forgotten places.
|
||||||
|
- **Whispers of the Old Roads (1/long rest):** Concentrate for 1 minute. Gain a vision of one path that once existed through the current location — may reveal hidden passages.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
Found on a dead courier in the Warrens (Session 2). The courier''s guild mark was scratched off. The party has not yet had it identified.
|
||||||
|
|
||||||
|
## DM Note
|
||||||
|
|
||||||
|
This ties directly to the pre-city ruin network. Any location the party has visited that sits atop the old tunnel system will cause the needle to spin wildly — a breadcrumb I''m leaving for when they piece it together.',
|
||||||
|
'Items',
|
||||||
|
'shared'
|
||||||
|
FROM campaigns c
|
||||||
|
LIMIT 1;
|
||||||
7
apps/server/src/db/migrations/007_dndbeyond_sync.sql
Normal file
7
apps/server/src/db/migrations/007_dndbeyond_sync.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Adds D&D Beyond sync columns to the characters table.
|
||||||
|
ALTER TABLE characters ADD COLUMN dndbeyond_id TEXT;
|
||||||
|
ALTER TABLE characters ADD COLUMN dndbeyond_last_sync TEXT;
|
||||||
|
|
||||||
|
-- Prevent two entries for the same DnD Beyond character ID within the same campaign
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_characters_dndbeyond
|
||||||
|
ON characters(campaign_id, dndbeyond_id) WHERE dndbeyond_id IS NOT NULL;
|
||||||
318
apps/server/src/db/migrations/008_player_seed.sql
Normal file
318
apps/server/src/db/migrations/008_player_seed.sql
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
-- Seed campaigns where the real user is a PLAYER, not DM.
|
||||||
|
-- Lets you test character import, refresh, and player-perspective wiki views.
|
||||||
|
--
|
||||||
|
-- Strategy:
|
||||||
|
-- 1. Insert a synthetic "seed DM" user (idempotent via OR IGNORE).
|
||||||
|
-- 2. Insert two campaigns owned by that DM, scoped to the same guild as
|
||||||
|
-- the first real campaign. Uses OR IGNORE so re-running is safe.
|
||||||
|
-- 3. "Curse of Strahd" — real user already HAS a character (tests Refresh).
|
||||||
|
-- 4. "Storm King's Thunder" — real user has NO character (tests Import).
|
||||||
|
-- 5. Seed wiki pages and a player note in the CoS campaign.
|
||||||
|
--
|
||||||
|
-- "Real user" = whoever created the first campaign in the DB.
|
||||||
|
-- All inserts are OR IGNORE / conditional on NOT EXISTS so this is idempotent.
|
||||||
|
|
||||||
|
-- ─── 1. Synthetic DM user ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO users (discord_user_id, username, avatar_url)
|
||||||
|
VALUES ('__seed_dm__', 'Mordenkainen (Seed DM)', NULL);
|
||||||
|
|
||||||
|
-- ─── 2. Give the seed DM a player+dm membership in the same guild ───────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO memberships (guild_id, user_id, role, status)
|
||||||
|
SELECT c.guild_id, u.id, 'player', 'active'
|
||||||
|
FROM campaigns c, users u
|
||||||
|
WHERE u.discord_user_id = '__seed_dm__'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO memberships (guild_id, user_id, role, status)
|
||||||
|
SELECT c.guild_id, u.id, 'dm', 'active'
|
||||||
|
FROM campaigns c, users u
|
||||||
|
WHERE u.discord_user_id = '__seed_dm__'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- ─── 3. Curse of Strahd campaign ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO campaigns (guild_id, name, description, status, created_by)
|
||||||
|
SELECT
|
||||||
|
c.guild_id,
|
||||||
|
'Curse of Strahd',
|
||||||
|
'A gothic horror campaign set in the mist-shrouded domain of Barovia. Death lurks around every corner — and Castle Ravenloft looms over all.',
|
||||||
|
'active',
|
||||||
|
u.id
|
||||||
|
FROM campaigns c, users u
|
||||||
|
WHERE u.discord_user_id = '__seed_dm__'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO campaign_dms (campaign_id, user_id)
|
||||||
|
SELECT camp.id, u.id
|
||||||
|
FROM campaigns camp, users u
|
||||||
|
WHERE camp.name = 'Curse of Strahd' AND u.discord_user_id = '__seed_dm__'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- ─── 4. Real user's character in CoS (dndbeyond_id set → tests Refresh) ──────
|
||||||
|
-- dndbeyond_last_sync is set 8 days in the past so the Refresh button is active.
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO characters
|
||||||
|
(campaign_id, user_id, character_name, race, class, level,
|
||||||
|
alignment, portrait_url, bio, is_active, dndbeyond_id, dndbeyond_last_sync)
|
||||||
|
SELECT
|
||||||
|
camp.id,
|
||||||
|
real_user.id,
|
||||||
|
'Valdris Ashmore',
|
||||||
|
'Half-Elf',
|
||||||
|
'Warlock',
|
||||||
|
7,
|
||||||
|
'Chaotic Neutral',
|
||||||
|
NULL,
|
||||||
|
'A wandering warlock bound to an Eldritch patron known only as "The Pale Accord." Valdris was drawn into Barovia while chasing a trail of missing persons — a trail that led straight through the mists.
|
||||||
|
|
||||||
|
**Ideals:** Power is a tool, not a goal. Use it and move on.
|
||||||
|
|
||||||
|
**Bonds:** Someone dear was taken by the mists years ago. Barovia may hold answers.
|
||||||
|
|
||||||
|
**Flaws:** Trusts the patron''s whispers more than his own companions.',
|
||||||
|
1,
|
||||||
|
'987654321',
|
||||||
|
datetime('now', '-8 days')
|
||||||
|
FROM campaigns camp,
|
||||||
|
(SELECT created_by AS id FROM campaigns ORDER BY created_at LIMIT 1) real_user
|
||||||
|
WHERE camp.name = 'Curse of Strahd'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM characters ch
|
||||||
|
WHERE ch.campaign_id = camp.id AND ch.user_id = real_user.id
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- ─── 5. Storm King's Thunder campaign ────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO campaigns (guild_id, name, description, status, created_by)
|
||||||
|
SELECT
|
||||||
|
c.guild_id,
|
||||||
|
'Storm King''s Thunder',
|
||||||
|
'Giants have shattered the ancient ordning and now threaten the Sword Coast. A band of heroes must discover why — before a giant war consumes civilization.',
|
||||||
|
'active',
|
||||||
|
u.id
|
||||||
|
FROM campaigns c, users u
|
||||||
|
WHERE u.discord_user_id = '__seed_dm__'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO campaign_dms (campaign_id, user_id)
|
||||||
|
SELECT camp.id, u.id
|
||||||
|
FROM campaigns camp, users u
|
||||||
|
WHERE camp.name = 'Storm King''s Thunder' AND u.discord_user_id = '__seed_dm__'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- (No character for the real user in SKT — so the Import card shows)
|
||||||
|
|
||||||
|
-- ─── 6. Second character in CoS to make the list interesting ─────────────────
|
||||||
|
-- (The seed DM plays a character too)
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO characters
|
||||||
|
(campaign_id, user_id, character_name, race, class, level,
|
||||||
|
alignment, bio, is_active, dndbeyond_id, dndbeyond_last_sync)
|
||||||
|
SELECT
|
||||||
|
camp.id,
|
||||||
|
u.id,
|
||||||
|
'Isadora Vex',
|
||||||
|
'Tiefling',
|
||||||
|
'Rogue/Sorcerer',
|
||||||
|
7,
|
||||||
|
'Neutral Evil',
|
||||||
|
'A tiefling information broker who followed the money into Barovia. She regrets it.',
|
||||||
|
0,
|
||||||
|
'111222333',
|
||||||
|
datetime('now', '-2 days')
|
||||||
|
FROM campaigns camp, users u
|
||||||
|
WHERE camp.name = 'Curse of Strahd' AND u.discord_user_id = '__seed_dm__'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM characters ch
|
||||||
|
WHERE ch.campaign_id = camp.id AND ch.user_id = u.id
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- ─── 7. Wiki pages for Curse of Strahd ───────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
camp.id,
|
||||||
|
camp.guild_id,
|
||||||
|
u.id,
|
||||||
|
'Castle Ravenloft',
|
||||||
|
'# Castle Ravenloft
|
||||||
|
|
||||||
|
The seat of Strahd von Zarovich''s power. A fortress of black stone perched atop a 1,000-foot spur of rock called Mount Baratok. Its spires pierce the permanent cloud cover; its dungeons plunge into the mountain itself.
|
||||||
|
|
||||||
|
## Key Areas
|
||||||
|
|
||||||
|
- **The Entrance** — two statues of dragons, always watching
|
||||||
|
- **The Chapel** — defiled, but faint consecrated ground remains in the east transept
|
||||||
|
- **The Study** — Strahd''s personal library; his *Tome* is here
|
||||||
|
- **The Crypts** — 40+ crypts, many sealed with powerful magic
|
||||||
|
|
||||||
|
## Known Hazards
|
||||||
|
|
||||||
|
| Threat | Location | Notes |
|
||||||
|
|--------|----------|-------|
|
||||||
|
| Rahadin | Anywhere in the castle | Personal chamberlain; reads emotions; deaf to the screams of his victims |
|
||||||
|
| Guardian Portraits | Halls of the castle | Animated; some shoot magic missiles |
|
||||||
|
| The Brazier Room | Kl''k tower | Planar gate sealed with arcane lock |
|
||||||
|
|
||||||
|
## DM Notes
|
||||||
|
|
||||||
|
The castle resets every dawn if the party leaves. Strahd is *always* aware of intruders within his castle (Scrying at-will).',
|
||||||
|
'Locations',
|
||||||
|
'dm'
|
||||||
|
FROM campaigns camp, users u
|
||||||
|
WHERE camp.name = 'Curse of Strahd' AND u.discord_user_id = '__seed_dm__'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM wiki_pages wp WHERE wp.campaign_id = camp.id AND wp.title = 'Castle Ravenloft'
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
camp.id,
|
||||||
|
camp.guild_id,
|
||||||
|
u.id,
|
||||||
|
'Strahd von Zarovich',
|
||||||
|
'# Strahd von Zarovich
|
||||||
|
|
||||||
|
**CR:** 15 · **Type:** Vampire (Unique)
|
||||||
|
**Alignment:** Lawful Evil
|
||||||
|
**Lair:** Castle Ravenloft
|
||||||
|
|
||||||
|
## The Curse
|
||||||
|
|
||||||
|
Strahd is trapped in Barovia by his own dark bargain with the Dark Powers. He desires Tatyana (and her reincarnations) above all else. He is *not* mindlessly evil — he is calculating, patient, and occasionally darkly charming.
|
||||||
|
|
||||||
|
## Personality Modes
|
||||||
|
|
||||||
|
He cycles between three personas depending on what he wants from the party:
|
||||||
|
- **The Gentleman** — invites them to dinner, offers alliances, tests their worth
|
||||||
|
- **The Tyrant** — punishes defiance with overwhelming force, never killing the party outright
|
||||||
|
- **The Monster** — when cornered or Ireena is threatened; full predator mode
|
||||||
|
|
||||||
|
## Weaknesses
|
||||||
|
|
||||||
|
- Sunlight (radiant vulnerability from *Sunsword* or *Symbol of Ravenkind*)
|
||||||
|
- Running water (can''t cross willingly)
|
||||||
|
- Must be staked in his coffin to be permanently destroyed
|
||||||
|
|
||||||
|
## Lair Actions (recharge at initiative 20)
|
||||||
|
|
||||||
|
1. Choose a creature Strahd can see; it must make DC 15 Wis save or be frightened until end of its next turn
|
||||||
|
2. Strahd calls 1d4 swarms of bats or 3 wolves from shadows
|
||||||
|
3. Strahd passes through a wall, floor, or ceiling up to 10 ft thick (no action; gaseous form sub-ability)',
|
||||||
|
'NPCs',
|
||||||
|
'dm'
|
||||||
|
FROM campaigns camp, users u
|
||||||
|
WHERE camp.name = 'Curse of Strahd' AND u.discord_user_id = '__seed_dm__'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM wiki_pages wp WHERE wp.campaign_id = camp.id AND wp.title = 'Strahd von Zarovich'
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
camp.id,
|
||||||
|
camp.guild_id,
|
||||||
|
u.id,
|
||||||
|
'The Village of Barovia',
|
||||||
|
'# The Village of Barovia
|
||||||
|
|
||||||
|
The first settlement the party encountered upon entering the mists. A crumbling village of ~100 souls who have forgotten what hope feels like.
|
||||||
|
|
||||||
|
## Notable Locations
|
||||||
|
|
||||||
|
- **Blood of the Vine Tavern** — the only "gathering place"; run by three Vistana sisters
|
||||||
|
- **The Burgomaster''s Mansion** — Ismark Kolyanovich and his sister Ireena live here
|
||||||
|
- **The Church of the Morninglord** — Father Donavich tends it; his son Doru is locked in the basement (now a vampire spawn)
|
||||||
|
|
||||||
|
## What the Party Knows
|
||||||
|
|
||||||
|
- Ireena Kolyana has been bitten twice by Strahd
|
||||||
|
- Ismark begged the party to escort Ireena to Vallaki for safety
|
||||||
|
- The church basement contains *something* that Father Donavich refuses to discuss
|
||||||
|
|
||||||
|
## Lore Hook
|
||||||
|
|
||||||
|
The old cemetery holds a tomb with the inscription: *"The roses remember what the living have forgotten."* A fresh bundle of roses was placed there the morning the party arrived.',
|
||||||
|
'Locations',
|
||||||
|
'shared'
|
||||||
|
FROM campaigns camp, users u
|
||||||
|
WHERE camp.name = 'Curse of Strahd' AND u.discord_user_id = '__seed_dm__'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM wiki_pages wp WHERE wp.campaign_id = camp.id AND wp.title = 'The Village of Barovia'
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
camp.id,
|
||||||
|
camp.guild_id,
|
||||||
|
u.id,
|
||||||
|
'The Sunsword',
|
||||||
|
'## The Sunsword
|
||||||
|
|
||||||
|
A legendary artifact — the only weapon in Barovia that can permanently end Strahd.
|
||||||
|
|
||||||
|
### Properties (when attuned)
|
||||||
|
|
||||||
|
- **Sentience:** INT 11, WIS 17, CHA 16. Lawful Good alignment. Communicates via emotion and visual impressions.
|
||||||
|
- **Luminance:** Sheds bright light in a 15-ft radius, dim light 15 ft beyond. As a bonus action, suppress or activate.
|
||||||
|
- **Radiant Strike:** Deals an extra 1d8 radiant damage on a hit. Against undead: 2d8 radiant instead.
|
||||||
|
- **Strahd''s Bane:** On a hit against Strahd, he cannot use *Lair Actions* until the start of your next turn.
|
||||||
|
|
||||||
|
### Current Status
|
||||||
|
|
||||||
|
The hilt and the blade are separated. The party recovered the **hilt** from the Amber Temple (Session 6). The **blade** is believed to be somewhere within Castle Ravenloft — possibly the treasury on level 5.',
|
||||||
|
'Items',
|
||||||
|
'shared'
|
||||||
|
FROM campaigns camp, users u
|
||||||
|
WHERE camp.name = 'Curse of Strahd' AND u.discord_user_id = '__seed_dm__'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM wiki_pages wp WHERE wp.campaign_id = camp.id AND wp.title = 'The Sunsword'
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- ─── 8. A player note from the real user in CoS ───────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
SELECT
|
||||||
|
camp.id,
|
||||||
|
camp.guild_id,
|
||||||
|
real_user.id,
|
||||||
|
'Session 6 — The Amber Temple',
|
||||||
|
'## What happened
|
||||||
|
|
||||||
|
We finally made it to the Amber Temple. The journey through the mountains nearly killed us twice — the snow and the cold are as deadly as anything inside.
|
||||||
|
|
||||||
|
Inside the temple we found:
|
||||||
|
- **The Sunsword hilt** (in a sealed alcove, guarded by a flail-wielding amber golem)
|
||||||
|
- Three amber sarcophagi, each offering a dark "gift" — we refused all of them (Isadora almost took one, I won''t forget that)
|
||||||
|
- The name *Exethanter* written in 12 places — apparently a lich who used to guard this place
|
||||||
|
|
||||||
|
## Things I want to follow up
|
||||||
|
|
||||||
|
- [ ] What happened to Exethanter? The temple feels abandoned but not empty
|
||||||
|
- [ ] The cold darkness in the western wing — something is in there, we didn''t go in
|
||||||
|
- [ ] Isadora''s interest in the dark gifts is concerning
|
||||||
|
|
||||||
|
## Valdris patron whisper
|
||||||
|
|
||||||
|
Right when I picked up the hilt, I heard the Pale Accord whisper: *"The blade remembers its purpose. Do you remember yours?"*
|
||||||
|
|
||||||
|
No idea what that means but writing it down.',
|
||||||
|
'General',
|
||||||
|
'player'
|
||||||
|
FROM campaigns camp,
|
||||||
|
(SELECT created_by AS id FROM campaigns ORDER BY created_at LIMIT 1) real_user
|
||||||
|
WHERE camp.name = 'Curse of Strahd'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM wiki_pages wp
|
||||||
|
WHERE wp.campaign_id = camp.id
|
||||||
|
AND wp.author_id = real_user.id
|
||||||
|
AND wp.title = 'Session 6 — The Amber Temple'
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
1
apps/server/src/db/migrations/009_character_stats.sql
Normal file
1
apps/server/src/db/migrations/009_character_stats.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE characters ADD COLUMN stats_json TEXT;
|
||||||
22
apps/server/src/jobs/scheduler.ts
Normal file
22
apps/server/src/jobs/scheduler.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import cron from "node-cron";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { gameNightService } from "../services/gameNightService.js";
|
||||||
|
|
||||||
|
export const scheduler = {
|
||||||
|
start() {
|
||||||
|
const expression = `${config.notifyMinute} ${config.notifyHour} * * *`;
|
||||||
|
cron.schedule(
|
||||||
|
expression,
|
||||||
|
async () => {
|
||||||
|
const guild = db
|
||||||
|
.prepare("SELECT id FROM guilds WHERE discord_guild_id = ?")
|
||||||
|
.get(config.discordGuildId) as { id: number } | undefined;
|
||||||
|
if (!guild) return;
|
||||||
|
await gameNightService.runMorningNotification(guild.id);
|
||||||
|
},
|
||||||
|
{ timezone: config.timezone }
|
||||||
|
);
|
||||||
|
console.log(`Scheduler active on ${expression} (${config.timezone})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
26
apps/server/src/main.ts
Normal file
26
apps/server/src/main.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { createApp } from "./app.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { db } from "./db/client.js";
|
||||||
|
import "./db/migrate.js";
|
||||||
|
import { scheduler } from "./jobs/scheduler.js";
|
||||||
|
import { discordService } from "./services/discordService.js";
|
||||||
|
import { authService } from "./services/authService.js";
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
authService.getOrCreateGuild();
|
||||||
|
const app = createApp();
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`Server listening on :${config.port}`);
|
||||||
|
});
|
||||||
|
scheduler.start();
|
||||||
|
await discordService.start().catch((err) => {
|
||||||
|
console.error("[discord] Failed to connect — bot features unavailable:", err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
37
apps/server/src/middleware.ts
Normal file
37
apps/server/src/middleware.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { authService } from "./services/authService.js";
|
||||||
|
import { permissionService } from "./services/permissionService.js";
|
||||||
|
import type { AuthContext, Role } from "./types.js";
|
||||||
|
|
||||||
|
export interface AuthedRequest extends Request {
|
||||||
|
auth?: AuthContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (!header?.startsWith("Bearer ")) {
|
||||||
|
res.status(401).json({ error: "Missing bearer token" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
req.auth = authService.verifyToken(header.slice(7));
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRole(roles: Role[]) {
|
||||||
|
return (req: AuthedRequest, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.auth) {
|
||||||
|
res.status(401).json({ error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!permissionService.hasRole(req.auth.guildId, req.auth.userId, roles)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
104
apps/server/src/routes/authRoutes.ts
Normal file
104
apps/server/src/routes/authRoutes.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { authService } from "../services/authService.js";
|
||||||
|
|
||||||
|
const callbackSchema = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
inviteCode: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkInviteCode(provided: string | undefined): boolean {
|
||||||
|
if (!config.inviteCode) return true; // gate disabled
|
||||||
|
return provided === config.inviteCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authRoutes = Router();
|
||||||
|
|
||||||
|
authRoutes.get("/auth/discord", (req, res) => {
|
||||||
|
if (!checkInviteCode(req.query.inviteCode as string | undefined)) {
|
||||||
|
res.status(403).json({ error: "Invalid invite code" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: config.discordClientId,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: config.discordOauthRedirectUri,
|
||||||
|
scope: "identify guilds.members.read"
|
||||||
|
});
|
||||||
|
res.json({ authorizeUrl: `https://discord.com/oauth2/authorize?${params.toString()}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRoutes.post("/auth/discord/callback", async (req, res) => {
|
||||||
|
const parsed = callbackSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!checkInviteCode(parsed.data.inviteCode)) {
|
||||||
|
res.status(403).json({ error: "Invalid invite code" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.discordClientId,
|
||||||
|
client_secret: config.discordClientSecret,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: parsed.data.code,
|
||||||
|
redirect_uri: config.discordOauthRedirectUri
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
const detail = await tokenRes.text();
|
||||||
|
res.status(502).json({ error: "OAuth token exchange failed", detail });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = (await tokenRes.json()) as { access_token: string };
|
||||||
|
const meRes = await fetch("https://discord.com/api/users/@me", {
|
||||||
|
headers: { Authorization: `Bearer ${token.access_token}` }
|
||||||
|
});
|
||||||
|
if (!meRes.ok) {
|
||||||
|
res.status(502).json({ error: "Failed to fetch Discord profile" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = (await meRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const guild = authService.getOrCreateGuild();
|
||||||
|
const userId = authService.getOrCreateUser(
|
||||||
|
me.id,
|
||||||
|
me.username,
|
||||||
|
me.avatar ? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png` : null
|
||||||
|
);
|
||||||
|
authService.ensurePlayerMembership(guild.id, userId);
|
||||||
|
if (config.discordAdminRoleIds.length > 0) {
|
||||||
|
const memberRes = await fetch(
|
||||||
|
`https://discord.com/api/users/@me/guilds/${config.discordGuildId}/member`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token.access_token}` }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (memberRes.ok) {
|
||||||
|
const member = (await memberRes.json()) as { roles?: string[] };
|
||||||
|
const isAdmin = (member.roles ?? []).some((r) => config.discordAdminRoleIds.includes(r));
|
||||||
|
if (isAdmin) {
|
||||||
|
authService.ensureMembership(guild.id, userId, "admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const appToken = authService.signToken({ userId, discordUserId: me.id, guildId: guild.id });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token: appToken,
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
discordUserId: me.id,
|
||||||
|
username: me.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
187
apps/server/src/routes/campaignRoutes.ts
Normal file
187
apps/server/src/routes/campaignRoutes.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { requireAuth, requireRole, type AuthedRequest } from "../middleware.js";
|
||||||
|
import { discordService } from "../services/discordService.js";
|
||||||
|
import { permissionService } from "../services/permissionService.js";
|
||||||
|
import { auditService } from "../services/auditService.js";
|
||||||
|
|
||||||
|
const createCampaignSchema = z.object({
|
||||||
|
name: z.string().min(2),
|
||||||
|
description: z.string().default(""),
|
||||||
|
discordChannelId: z.string().min(5).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPostSchema = z.object({
|
||||||
|
title: z.string().min(2),
|
||||||
|
bodyMd: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const campaignRoutes = Router();
|
||||||
|
|
||||||
|
campaignRoutes.get("/campaigns", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaigns = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, description, status, discord_channel_id, created_at
|
||||||
|
FROM campaigns
|
||||||
|
WHERE guild_id = ?
|
||||||
|
ORDER BY created_at DESC`
|
||||||
|
)
|
||||||
|
.all(req.auth!.guildId);
|
||||||
|
res.json(campaigns);
|
||||||
|
});
|
||||||
|
|
||||||
|
campaignRoutes.post("/campaigns", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const parsed = createCampaignSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO campaigns (guild_id, name, description, discord_channel_id, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
req.auth!.guildId,
|
||||||
|
parsed.data.name,
|
||||||
|
parsed.data.description,
|
||||||
|
parsed.data.discordChannelId ?? null,
|
||||||
|
req.auth!.userId
|
||||||
|
);
|
||||||
|
const campaignId = Number(result.lastInsertRowid);
|
||||||
|
db.prepare("INSERT INTO campaign_dms (campaign_id, user_id) VALUES (?, ?)").run(campaignId, req.auth!.userId);
|
||||||
|
|
||||||
|
// Creating a campaign makes you a DM — promote if not already dm/admin
|
||||||
|
const alreadyElevated = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id FROM memberships WHERE guild_id = ? AND user_id = ? AND role IN ('dm','admin') AND status = 'active'"
|
||||||
|
)
|
||||||
|
.get(req.auth!.guildId, req.auth!.userId);
|
||||||
|
if (!alreadyElevated) {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO memberships (guild_id, user_id, role, status) VALUES (?, ?, 'dm', 'active')"
|
||||||
|
).run(req.auth!.guildId, req.auth!.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditService.log(req.auth!.guildId, req.auth!.userId, "campaign.created", { campaignId });
|
||||||
|
res.status(201).json({ id: campaignId });
|
||||||
|
});
|
||||||
|
|
||||||
|
campaignRoutes.get("/campaigns/:id", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT * FROM campaigns WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(id, req.auth!.guildId);
|
||||||
|
if (!campaign) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const posts = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.id, p.title, p.body_md, p.created_at, u.username AS author
|
||||||
|
FROM campaign_posts p
|
||||||
|
JOIN users u ON u.id = p.author_id
|
||||||
|
WHERE p.campaign_id = ?
|
||||||
|
ORDER BY p.created_at DESC`
|
||||||
|
)
|
||||||
|
.all(id);
|
||||||
|
const characters = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.id, c.character_name, c.race, c.class, c.level, c.alignment, c.pronouns,
|
||||||
|
c.portrait_url, c.bio, c.notes, c.user_id, c.dndbeyond_id, c.dndbeyond_last_sync, c.stats_json, u.username
|
||||||
|
FROM characters c
|
||||||
|
JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE c.campaign_id = ?
|
||||||
|
ORDER BY c.created_at DESC`
|
||||||
|
)
|
||||||
|
.all(id);
|
||||||
|
const recaps = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id, r.title, r.body_md, r.created_at, u.username AS author
|
||||||
|
FROM recaps r
|
||||||
|
JOIN users u ON u.id = r.author_id
|
||||||
|
WHERE r.campaign_id = ?
|
||||||
|
ORDER BY r.created_at DESC`
|
||||||
|
)
|
||||||
|
.all(id);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const todaysGameNight = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, scheduled_date FROM game_nights WHERE guild_id = ? AND selected_campaign_id = ? AND scheduled_date = ?'
|
||||||
|
)
|
||||||
|
.get(req.auth!.guildId, id, today) as { id: number; scheduled_date: string } | undefined;
|
||||||
|
|
||||||
|
// Is the requesting user a DM for this specific campaign?
|
||||||
|
const isCampaignDm = Boolean(
|
||||||
|
db.prepare("SELECT id FROM campaign_dms WHERE campaign_id = ? AND user_id = ?")
|
||||||
|
.get(id, req.auth!.userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ campaign, posts, characters, recaps, todaysGameNight: todaysGameNight ?? null, isCampaignDm });
|
||||||
|
});
|
||||||
|
|
||||||
|
campaignRoutes.post("/campaigns/:id/posts", requireAuth, async (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.id);
|
||||||
|
if (!permissionService.ensureCanManageCampaign(req.auth!.guildId, req.auth!.userId, campaignId)) {
|
||||||
|
res.status(403).json({ error: "Only campaign DMs or admins can post" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = createPostSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT name, discord_channel_id FROM campaigns WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(campaignId, req.auth!.guildId) as { name: string; discord_channel_id: string | null } | undefined;
|
||||||
|
if (!campaign) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO campaign_posts (campaign_id, author_id, title, body_md, visibility)
|
||||||
|
VALUES (?, ?, ?, ?, 'guild')`
|
||||||
|
)
|
||||||
|
.run(campaignId, req.auth!.userId, parsed.data.title, parsed.data.bodyMd);
|
||||||
|
const postId = Number(insert.lastInsertRowid);
|
||||||
|
|
||||||
|
let discordMessageId: string | null = null;
|
||||||
|
if (campaign.discord_channel_id) {
|
||||||
|
discordMessageId = await discordService.postToChannel(
|
||||||
|
campaign.discord_channel_id,
|
||||||
|
`**${campaign.name}**\n**${parsed.data.title}**\n${parsed.data.bodyMd}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (discordMessageId) {
|
||||||
|
db.prepare("UPDATE campaign_posts SET discord_message_id = ? WHERE id = ?").run(discordMessageId, postId);
|
||||||
|
}
|
||||||
|
auditService.log(req.auth!.guildId, req.auth!.userId, "campaign.post_created", { campaignId, postId });
|
||||||
|
res.status(201).json({ id: postId, discordMessageId });
|
||||||
|
});
|
||||||
|
|
||||||
|
campaignRoutes.post("/campaigns/:id/recaps", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.id);
|
||||||
|
if (!permissionService.ensureCanManageCampaign(req.auth!.guildId, req.auth!.userId, campaignId)) {
|
||||||
|
res.status(403).json({ error: "Only campaign DMs or admins can create recaps" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = createPostSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeNight = db
|
||||||
|
.prepare("SELECT id FROM game_nights WHERE guild_id = ? ORDER BY scheduled_date DESC LIMIT 1")
|
||||||
|
.get(req.auth!.guildId) as { id: number } | undefined;
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO recaps (campaign_id, game_night_id, author_id, title, body_md) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.run(campaignId, activeNight?.id ?? null, req.auth!.userId, parsed.data.title, parsed.data.bodyMd);
|
||||||
|
res.status(201).json({ id: Number(result.lastInsertRowid) });
|
||||||
|
});
|
||||||
|
|
||||||
137
apps/server/src/routes/characterRoutes.ts
Normal file
137
apps/server/src/routes/characterRoutes.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { requireAuth, type AuthedRequest } from "../middleware.js";
|
||||||
|
|
||||||
|
const characterSchema = z.object({
|
||||||
|
campaignId: z.number().int(),
|
||||||
|
characterName: z.string().min(1),
|
||||||
|
race: z.string().min(1),
|
||||||
|
class: z.string().min(1),
|
||||||
|
level: z.number().int().min(1).max(20),
|
||||||
|
alignment: z.string().optional(),
|
||||||
|
pronouns: z.string().optional(),
|
||||||
|
portraitUrl: z.string().url().optional(),
|
||||||
|
bio: z.string().optional(),
|
||||||
|
notes: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const characterRoutes = Router();
|
||||||
|
|
||||||
|
characterRoutes.post("/characters", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const parsed = characterSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(parsed.data.campaignId, req.auth!.guildId);
|
||||||
|
if (!campaign) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO characters
|
||||||
|
(campaign_id, user_id, character_name, race, class, level, alignment, pronouns, portrait_url, bio, notes, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
parsed.data.campaignId,
|
||||||
|
req.auth!.userId,
|
||||||
|
parsed.data.characterName,
|
||||||
|
parsed.data.race,
|
||||||
|
parsed.data.class,
|
||||||
|
parsed.data.level,
|
||||||
|
parsed.data.alignment ?? null,
|
||||||
|
parsed.data.pronouns ?? null,
|
||||||
|
parsed.data.portraitUrl ?? null,
|
||||||
|
parsed.data.bio ?? null,
|
||||||
|
parsed.data.notes ?? null
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: Number(result.lastInsertRowid) });
|
||||||
|
});
|
||||||
|
|
||||||
|
characterRoutes.get("/characters/mine", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const characters = db.prepare(
|
||||||
|
`SELECT c.id, c.character_name, c.class, c.race, c.level, c.alignment,
|
||||||
|
c.portrait_url, c.bio, c.notes, c.stats_json, c.dndbeyond_id, c.dndbeyond_last_sync,
|
||||||
|
c.is_active, camp.name AS campaign_name, camp.id AS campaign_id
|
||||||
|
FROM characters c
|
||||||
|
JOIN campaigns camp ON camp.id = c.campaign_id
|
||||||
|
WHERE c.user_id = ? AND camp.guild_id = ?
|
||||||
|
ORDER BY camp.name ASC, c.character_name ASC`
|
||||||
|
).all(req.auth!.userId, req.auth!.guildId);
|
||||||
|
res.json(characters);
|
||||||
|
});
|
||||||
|
|
||||||
|
characterRoutes.get("/characters/:id", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const char = db.prepare(
|
||||||
|
`SELECT c.id, c.character_name, c.class, c.race, c.level, c.alignment, c.pronouns,
|
||||||
|
c.portrait_url, c.bio, c.notes, c.stats_json, c.dndbeyond_id, c.dndbeyond_last_sync,
|
||||||
|
c.is_active, c.user_id, camp.name AS campaign_name, camp.id AS campaign_id
|
||||||
|
FROM characters c
|
||||||
|
JOIN campaigns camp ON camp.id = c.campaign_id
|
||||||
|
WHERE c.id = ? AND camp.guild_id = ?`
|
||||||
|
).get(id, req.auth!.guildId);
|
||||||
|
if (!char) { res.status(404).json({ error: "Character not found" }); return; }
|
||||||
|
res.json(char);
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchCharacterSchema = z.object({
|
||||||
|
characterName: z.string().min(1).max(100).optional(),
|
||||||
|
race: z.string().min(1).max(100).optional(),
|
||||||
|
class: z.string().min(1).max(100).optional(),
|
||||||
|
level: z.number().int().min(1).max(20).optional(),
|
||||||
|
alignment: z.string().max(50).nullable().optional(),
|
||||||
|
pronouns: z.string().max(50).nullable().optional(),
|
||||||
|
bio: z.string().nullable().optional(),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
characterRoutes.patch("/characters/:id", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const parsed = patchCharacterSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; }
|
||||||
|
|
||||||
|
const char = db.prepare("SELECT id, user_id FROM characters WHERE id = ?").get(id) as { id: number; user_id: number } | undefined;
|
||||||
|
if (!char || char.user_id !== req.auth!.userId) { res.status(404).json({ error: "Character not found" }); return; }
|
||||||
|
|
||||||
|
const d = parsed.data;
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
|
||||||
|
if (d.characterName !== undefined) { sets.push("character_name = ?"); vals.push(d.characterName); }
|
||||||
|
if (d.race !== undefined) { sets.push("race = ?"); vals.push(d.race); }
|
||||||
|
if (d.class !== undefined) { sets.push("class = ?"); vals.push(d.class); }
|
||||||
|
if (d.level !== undefined) { sets.push("level = ?"); vals.push(d.level); }
|
||||||
|
if (d.alignment !== undefined) { sets.push("alignment = ?"); vals.push(d.alignment); }
|
||||||
|
if (d.pronouns !== undefined) { sets.push("pronouns = ?"); vals.push(d.pronouns); }
|
||||||
|
if (d.bio !== undefined) { sets.push("bio = ?"); vals.push(d.bio); }
|
||||||
|
if (d.notes !== undefined) { sets.push("notes = ?"); vals.push(d.notes); }
|
||||||
|
|
||||||
|
if (sets.length === 0) { res.json({ ok: true }); return; }
|
||||||
|
vals.push(id);
|
||||||
|
db.prepare(`UPDATE characters SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
characterRoutes.post("/characters/:id/activate", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const character = db
|
||||||
|
.prepare("SELECT id, campaign_id, user_id FROM characters WHERE id = ?")
|
||||||
|
.get(id) as { id: number; campaign_id: number; user_id: number } | undefined;
|
||||||
|
if (!character || character.user_id !== req.auth!.userId) {
|
||||||
|
res.status(404).json({ error: "Character not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE characters SET is_active = 0 WHERE campaign_id = ? AND user_id = ?").run(
|
||||||
|
character.campaign_id,
|
||||||
|
req.auth!.userId
|
||||||
|
);
|
||||||
|
db.prepare("UPDATE characters SET is_active = 1 WHERE id = ?").run(id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
63
apps/server/src/routes/dmRoutes.ts
Normal file
63
apps/server/src/routes/dmRoutes.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { requireAuth, requireRole, type AuthedRequest } from "../middleware.js";
|
||||||
|
import { auditService } from "../services/auditService.js";
|
||||||
|
|
||||||
|
export const dmRoutes = Router();
|
||||||
|
|
||||||
|
dmRoutes.post("/dm-requests", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const exists = db
|
||||||
|
.prepare("SELECT id FROM memberships WHERE guild_id = ? AND user_id = ? AND role = 'pending_dm'")
|
||||||
|
.get(req.auth!.guildId, req.auth!.userId);
|
||||||
|
if (exists) {
|
||||||
|
res.status(409).json({ error: "DM request already pending" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.prepare("INSERT INTO memberships (guild_id, user_id, role, status) VALUES (?, ?, 'pending_dm', 'active')").run(
|
||||||
|
req.auth!.guildId,
|
||||||
|
req.auth!.userId
|
||||||
|
);
|
||||||
|
auditService.log(req.auth!.guildId, req.auth!.userId, "dm.requested");
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
dmRoutes.get(
|
||||||
|
"/admin/dm-requests",
|
||||||
|
requireAuth,
|
||||||
|
requireRole(["admin"]),
|
||||||
|
(req: AuthedRequest, res) => {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.user_id AS userId, u.username, u.discord_user_id, m.created_at AS requested_at
|
||||||
|
FROM memberships m
|
||||||
|
JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.guild_id = ? AND m.role = 'pending_dm' AND m.status = 'active'
|
||||||
|
ORDER BY m.created_at ASC`
|
||||||
|
)
|
||||||
|
.all(req.auth!.guildId);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
dmRoutes.post(
|
||||||
|
"/admin/dm-requests/:userId/approve",
|
||||||
|
requireAuth,
|
||||||
|
requireRole(["admin"]),
|
||||||
|
(req: AuthedRequest, res) => {
|
||||||
|
const targetUserId = Number(req.params.userId);
|
||||||
|
db.prepare(
|
||||||
|
"DELETE FROM memberships WHERE guild_id = ? AND user_id = ? AND role = 'pending_dm'"
|
||||||
|
).run(req.auth!.guildId, targetUserId);
|
||||||
|
|
||||||
|
const hasDm = db
|
||||||
|
.prepare("SELECT id FROM memberships WHERE guild_id = ? AND user_id = ? AND role = 'dm'")
|
||||||
|
.get(req.auth!.guildId, targetUserId);
|
||||||
|
if (!hasDm) {
|
||||||
|
db.prepare("INSERT INTO memberships (guild_id, user_id, role, status) VALUES (?, ?, 'dm', 'active')")
|
||||||
|
.run(req.auth!.guildId, targetUserId);
|
||||||
|
}
|
||||||
|
auditService.log(req.auth!.guildId, req.auth!.userId, "dm.approved", { targetUserId });
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
313
apps/server/src/routes/dndBeyondRoutes.ts
Normal file
313
apps/server/src/routes/dndBeyondRoutes.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { requireAuth, type AuthedRequest } from "../middleware.js";
|
||||||
|
import { permissionService } from "../services/permissionService.js";
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const ALIGNMENT_MAP: Record<number, string> = {
|
||||||
|
1: "Lawful Good", 2: "Neutral Good", 3: "Chaotic Good",
|
||||||
|
4: "Lawful Neutral", 5: "True Neutral", 6: "Chaotic Neutral",
|
||||||
|
7: "Lawful Evil", 8: "Neutral Evil", 9: "Chaotic Evil",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── D&D Beyond fetch + parse ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchDndBeyondChar(charId: string): Promise<Record<string, unknown>> {
|
||||||
|
const url = `https://character-service.dndbeyond.com/character/v5/character/${charId}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "dnd-hub/1.0 (private campaign manager; not for scraping)" },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw Object.assign(new Error("Character not found — make sure the character is set to Public on D&D Beyond."), { status: 404 });
|
||||||
|
}
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
throw Object.assign(new Error("Character is private. Set it to Public on D&D Beyond, then try again."), { status: 422 });
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`D&D Beyond returned ${res.status}`);
|
||||||
|
const json = await res.json() as { data?: Record<string, unknown> };
|
||||||
|
if (!json.data) throw new Error("Unexpected response format from D&D Beyond");
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedChar {
|
||||||
|
character_name: string;
|
||||||
|
race: string;
|
||||||
|
class: string;
|
||||||
|
level: number;
|
||||||
|
alignment: string | null;
|
||||||
|
portrait_url: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
stats_json: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharStats {
|
||||||
|
str: number; dex: number; con: number;
|
||||||
|
int: number; wis: number; cha: number;
|
||||||
|
maxHp: number | null;
|
||||||
|
proficiencyBonus: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCharStats(data: Record<string, unknown>, level: number): string | null {
|
||||||
|
try {
|
||||||
|
const baseStats = (data.stats as Array<{id: number; value: number | null}> | undefined) ?? [];
|
||||||
|
const bonusStats = (data.bonusStats as Array<{id: number; value: number | null}> | undefined) ?? [];
|
||||||
|
const overrideStats = (data.overrideStats as Array<{id: number; value: number | null}> | undefined) ?? [];
|
||||||
|
|
||||||
|
function getAbility(id: number): number {
|
||||||
|
const ov = overrideStats.find(s => s.id === id)?.value;
|
||||||
|
if (ov != null) return ov;
|
||||||
|
const base = baseStats.find(s => s.id === id)?.value ?? 10;
|
||||||
|
const bonus = bonusStats.find(s => s.id === id)?.value ?? 0;
|
||||||
|
return base + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hpInfo = data.hitPointInfo as { override?: number | null; maximumHitPoints?: number } | undefined;
|
||||||
|
const maxHp = hpInfo?.override ?? hpInfo?.maximumHitPoints ?? null;
|
||||||
|
const proficiencyBonus = Math.ceil(level / 4) + 1;
|
||||||
|
|
||||||
|
const stats: CharStats = {
|
||||||
|
str: getAbility(1), dex: getAbility(2), con: getAbility(3),
|
||||||
|
int: getAbility(4), wis: getAbility(5), cha: getAbility(6),
|
||||||
|
maxHp: typeof maxHp === 'number' ? maxHp : null,
|
||||||
|
proficiencyBonus,
|
||||||
|
};
|
||||||
|
return JSON.stringify(stats);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDndBeyondChar(data: Record<string, unknown>): ParsedChar {
|
||||||
|
const get = <T>(obj: unknown, ...keys: string[]): T | undefined => {
|
||||||
|
let cur: unknown = obj;
|
||||||
|
for (const k of keys) {
|
||||||
|
if (cur == null || typeof cur !== "object") return undefined;
|
||||||
|
cur = (cur as Record<string, unknown>)[k];
|
||||||
|
}
|
||||||
|
return cur as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = (data.name as string | undefined) ?? "Unknown";
|
||||||
|
|
||||||
|
const raceObj = data.race as Record<string, unknown> | null | undefined;
|
||||||
|
const race = (raceObj?.fullName ?? raceObj?.baseRaceName ?? "Unknown") as string;
|
||||||
|
|
||||||
|
const classes = (data.classes as Array<Record<string, unknown>> | null | undefined) ?? [];
|
||||||
|
const classNames = classes.map(c => get<string>(c, "definition", "name") ?? "Unknown");
|
||||||
|
const classStr = classNames.length > 0 ? classNames.join("/") : "Unknown";
|
||||||
|
const level = Math.max(1, classes.reduce((sum, c) => sum + ((c.level as number) ?? 0), 0));
|
||||||
|
|
||||||
|
const alignmentId = data.alignmentId as number | null | undefined;
|
||||||
|
const alignment = alignmentId ? (ALIGNMENT_MAP[alignmentId] ?? null) : null;
|
||||||
|
|
||||||
|
const portrait_url = (get<string>(data, "decorations", "avatarUrl") ?? null) as string | null;
|
||||||
|
|
||||||
|
const traits = data.traits as Record<string, string | null> | null | undefined;
|
||||||
|
const traitParts = [
|
||||||
|
traits?.personalityTraits,
|
||||||
|
traits?.ideals ? `**Ideals:** ${traits.ideals}` : null,
|
||||||
|
traits?.bonds ? `**Bonds:** ${traits.bonds}` : null,
|
||||||
|
traits?.flaws ? `**Flaws:** ${traits.flaws}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const bio = traitParts.length > 0 ? traitParts.join("\n\n") : null;
|
||||||
|
|
||||||
|
const stats_json = parseCharStats(data, level);
|
||||||
|
|
||||||
|
return { character_name: name, race, class: classStr, level, alignment, portrait_url, bio, stats_json };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getCharacter(characterId: number) {
|
||||||
|
return db.prepare(
|
||||||
|
"SELECT id, user_id, campaign_id, dndbeyond_id, dndbeyond_last_sync FROM characters WHERE id = ?"
|
||||||
|
).get(characterId) as {
|
||||||
|
id: number; user_id: number; campaign_id: number;
|
||||||
|
dndbeyond_id: string | null; dndbeyond_last_sync: string | null;
|
||||||
|
} | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRefresh(characterId: number, fields: ParsedChar) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE characters
|
||||||
|
SET character_name = ?, race = ?, class = ?, level = ?,
|
||||||
|
alignment = ?, portrait_url = ?, bio = ?, stats_json = ?,
|
||||||
|
dndbeyond_last_sync = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(
|
||||||
|
fields.character_name, fields.race, fields.class, fields.level,
|
||||||
|
fields.alignment, fields.portrait_url, fields.bio, fields.stats_json,
|
||||||
|
characterId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Routes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const dndBeyondRoutes = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /characters/dndbeyond/preview
|
||||||
|
* Fetch & parse a D&D Beyond character without saving anything.
|
||||||
|
*/
|
||||||
|
dndBeyondRoutes.post("/characters/dndbeyond/preview", requireAuth, async (req: AuthedRequest, res) => {
|
||||||
|
const parsed = z.object({
|
||||||
|
dndbeyondCharId: z.string().trim().regex(/^\d+$/, "Must be a numeric D&D Beyond character ID"),
|
||||||
|
}).safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchDndBeyondChar(parsed.data.dndbeyondCharId);
|
||||||
|
res.json(parseDndBeyondChar(data));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { message?: string; status?: number };
|
||||||
|
res.status(e.status ?? 502).json({ error: e.message ?? "Failed to fetch character" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /characters/dndbeyond/import
|
||||||
|
* After the user confirms the preview, persist to characters table.
|
||||||
|
*/
|
||||||
|
dndBeyondRoutes.post("/characters/dndbeyond/import", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
campaignId: z.number().int(),
|
||||||
|
dndbeyondCharId: z.string().trim().regex(/^\d+$/),
|
||||||
|
character_name: z.string().min(1),
|
||||||
|
race: z.string().min(1),
|
||||||
|
class: z.string().min(1),
|
||||||
|
level: z.number().int().min(1).max(20),
|
||||||
|
alignment: z.string().nullable().optional(),
|
||||||
|
portrait_url: z.string().url().nullable().optional(),
|
||||||
|
bio: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { campaignId, dndbeyondCharId, ...fields } = parsed.data;
|
||||||
|
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(campaignId, req.auth!.guildId);
|
||||||
|
if (!campaign) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate imports of the same DnD Beyond character for this campaign
|
||||||
|
const duplicate = db
|
||||||
|
.prepare("SELECT id FROM characters WHERE campaign_id = ? AND dndbeyond_id = ?")
|
||||||
|
.get(campaignId, dndbeyondCharId);
|
||||||
|
if (duplicate) {
|
||||||
|
res.status(409).json({ error: "This D&D Beyond character is already imported for this campaign" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT INTO characters
|
||||||
|
(campaign_id, user_id, character_name, race, class, level, alignment,
|
||||||
|
portrait_url, bio, is_active, dndbeyond_id, dndbeyond_last_sync, stats_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, datetime('now'), ?)`
|
||||||
|
).run(
|
||||||
|
campaignId, req.auth!.userId,
|
||||||
|
fields.character_name, fields.race, fields.class, fields.level,
|
||||||
|
fields.alignment ?? null, fields.portrait_url ?? null, fields.bio ?? null,
|
||||||
|
dndbeyondCharId, null,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ id: Number(result.lastInsertRowid) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /characters/:characterId/dndbeyond/refresh
|
||||||
|
* Re-fetch from D&D Beyond. Rate-limited to once per 7 days per character.
|
||||||
|
* Only the character owner can call this.
|
||||||
|
*/
|
||||||
|
dndBeyondRoutes.post("/characters/:characterId/dndbeyond/refresh", requireAuth, async (req: AuthedRequest, res) => {
|
||||||
|
const characterId = Number(req.params.characterId);
|
||||||
|
const char = getCharacter(characterId);
|
||||||
|
|
||||||
|
if (!char || char.user_id !== req.auth!.userId) {
|
||||||
|
res.status(404).json({ error: "Character not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!char.dndbeyond_id) {
|
||||||
|
res.status(400).json({ error: "Character has no D&D Beyond link" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit
|
||||||
|
if (char.dndbeyond_last_sync) {
|
||||||
|
const msSince = Date.now() - new Date(char.dndbeyond_last_sync).getTime();
|
||||||
|
if (msSince < SEVEN_DAYS_MS) {
|
||||||
|
const daysLeft = Math.ceil((SEVEN_DAYS_MS - msSince) / (24 * 60 * 60 * 1000));
|
||||||
|
res.status(429).json({
|
||||||
|
error: `Refresh available in ${daysLeft} day${daysLeft === 1 ? "" : "s"}`,
|
||||||
|
daysLeft,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchDndBeyondChar(char.dndbeyond_id);
|
||||||
|
const fields = parseDndBeyondChar(data);
|
||||||
|
applyRefresh(characterId, fields);
|
||||||
|
res.json({ ok: true, character: fields });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { message?: string; status?: number };
|
||||||
|
res.status(e.status ?? 502).json({ error: e.message ?? "Failed to fetch character" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /characters/:characterId/dndbeyond/admin-refresh
|
||||||
|
* Same as refresh but bypasses the 7-day rate limit.
|
||||||
|
* Requires dm or admin role and the character must be in this guild.
|
||||||
|
*/
|
||||||
|
dndBeyondRoutes.post("/characters/:characterId/dndbeyond/admin-refresh", requireAuth, async (req: AuthedRequest, res) => {
|
||||||
|
if (!permissionService.hasRole(req.auth!.guildId, req.auth!.userId, ["dm", "admin"])) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterId = Number(req.params.characterId);
|
||||||
|
const char = getCharacter(characterId);
|
||||||
|
if (!char) {
|
||||||
|
res.status(404).json({ error: "Character not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!char.dndbeyond_id) {
|
||||||
|
res.status(400).json({ error: "Character has no D&D Beyond link" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the character belongs to a campaign in this guild
|
||||||
|
const inGuild = db
|
||||||
|
.prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(char.campaign_id, req.auth!.guildId);
|
||||||
|
if (!inGuild) {
|
||||||
|
res.status(404).json({ error: "Character not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchDndBeyondChar(char.dndbeyond_id);
|
||||||
|
const fields = parseDndBeyondChar(data);
|
||||||
|
applyRefresh(characterId, fields);
|
||||||
|
res.json({ ok: true, character: fields });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { message?: string; status?: number };
|
||||||
|
res.status(e.status ?? 502).json({ error: e.message ?? "Failed to fetch character" });
|
||||||
|
}
|
||||||
|
});
|
||||||
331
apps/server/src/routes/gameNightRoutes.ts
Normal file
331
apps/server/src/routes/gameNightRoutes.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { requireAuth, requireRole, type AuthedRequest } from "../middleware.js";
|
||||||
|
import { gameNightService } from "../services/gameNightService.js";
|
||||||
|
|
||||||
|
export const gameNightRoutes = Router();
|
||||||
|
|
||||||
|
const toDateOnly = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
function getRole(guildId: number, userId: number): string {
|
||||||
|
const memberships = db
|
||||||
|
.prepare("SELECT role FROM memberships WHERE guild_id = ? AND user_id = ? AND status = 'active'")
|
||||||
|
.all(guildId, userId) as Array<{ role: string }>;
|
||||||
|
const ROLE_PRIORITY = ["admin", "dm", "pending_dm", "player"];
|
||||||
|
const roles = memberships.map((m) => m.role);
|
||||||
|
return ROLE_PRIORITY.find((r) => roles.includes(r)) ?? "player";
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /guild/settings — public guild config (any authenticated user)
|
||||||
|
gameNightRoutes.get("/guild/settings", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const guild = db
|
||||||
|
.prepare("SELECT default_game_day FROM guilds WHERE id = ?")
|
||||||
|
.get(req.auth!.guildId) as { default_game_day: number | null } | undefined;
|
||||||
|
res.json({ defaultGameDay: guild?.default_game_day ?? null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /guild/settings — update guild config (admin only)
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
defaultGameDay: z.number().int().min(0).max(6).nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
gameNightRoutes.patch(
|
||||||
|
"/guild/settings",
|
||||||
|
requireAuth,
|
||||||
|
requireRole(["admin"]),
|
||||||
|
(req: AuthedRequest, res) => {
|
||||||
|
const parsed = settingsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE guilds SET default_game_day = ? WHERE id = ?").run(
|
||||||
|
parsed.data.defaultGameDay,
|
||||||
|
req.auth!.guildId
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /dashboard — primary scheduling data
|
||||||
|
gameNightRoutes.get("/dashboard", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const { guildId, userId } = req.auth!;
|
||||||
|
const role = getRole(guildId, userId);
|
||||||
|
const today = toDateOnly(new Date());
|
||||||
|
|
||||||
|
const guild = db
|
||||||
|
.prepare("SELECT default_game_day FROM guilds WHERE id = ?")
|
||||||
|
.get(guildId) as { default_game_day: number | null } | undefined;
|
||||||
|
const defaultGameDay = guild?.default_game_day ?? null;
|
||||||
|
|
||||||
|
const nights = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT gn.id, gn.scheduled_date, gn.selected_campaign_id, c.name as campaign_name
|
||||||
|
FROM game_nights gn
|
||||||
|
LEFT JOIN campaigns c ON c.id = gn.selected_campaign_id
|
||||||
|
WHERE gn.guild_id = ? AND gn.scheduled_date >= ?
|
||||||
|
ORDER BY gn.scheduled_date ASC
|
||||||
|
LIMIT 12`
|
||||||
|
)
|
||||||
|
.all(guildId, today) as Array<{
|
||||||
|
id: number;
|
||||||
|
scheduled_date: string;
|
||||||
|
selected_campaign_id: number | null;
|
||||||
|
campaign_name: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const dates = nights.map((n) => n.scheduled_date);
|
||||||
|
const blackouts =
|
||||||
|
dates.length > 0
|
||||||
|
? (db
|
||||||
|
.prepare(
|
||||||
|
`SELECT b.date, b.user_id, b.reason, u.username
|
||||||
|
FROM blackouts b
|
||||||
|
JOIN users u ON u.id = b.user_id
|
||||||
|
WHERE b.guild_id = ? AND b.date IN (${dates.map(() => "?").join(",")})
|
||||||
|
ORDER BY u.username`
|
||||||
|
)
|
||||||
|
.all(guildId, ...dates) as Array<{
|
||||||
|
date: string;
|
||||||
|
user_id: number;
|
||||||
|
reason: string | null;
|
||||||
|
username: string;
|
||||||
|
}>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const blackoutsByDate: Record<
|
||||||
|
string,
|
||||||
|
Array<{ userId: number; username: string; reason: string | null }>
|
||||||
|
> = {};
|
||||||
|
for (const b of blackouts) {
|
||||||
|
(blackoutsByDate[b.date] ??= []).push({
|
||||||
|
userId: b.user_id,
|
||||||
|
username: b.username,
|
||||||
|
reason: b.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign DMs for all selected campaigns (batch fetch)
|
||||||
|
const campaignIds = nights
|
||||||
|
.map((n) => n.selected_campaign_id)
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
const campaignDmRows =
|
||||||
|
campaignIds.length > 0
|
||||||
|
? (db
|
||||||
|
.prepare(
|
||||||
|
`SELECT campaign_id, user_id FROM campaign_dms WHERE campaign_id IN (${campaignIds.map(() => "?").join(",")})`
|
||||||
|
)
|
||||||
|
.all(...campaignIds) as Array<{ campaign_id: number; user_id: number }>)
|
||||||
|
: [];
|
||||||
|
const campaignDmsByCapId: Record<number, number[]> = {};
|
||||||
|
for (const d of campaignDmRows) {
|
||||||
|
(campaignDmsByCapId[d.campaign_id] ??= []).push(d.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = nights.map((n) => ({
|
||||||
|
date: n.scheduled_date,
|
||||||
|
gameNightId: n.id,
|
||||||
|
campaignId: n.selected_campaign_id,
|
||||||
|
campaignName: n.campaign_name,
|
||||||
|
blackouts: blackoutsByDate[n.scheduled_date] ?? [],
|
||||||
|
campaignDmUserIds: n.selected_campaign_id
|
||||||
|
? (campaignDmsByCapId[n.selected_campaign_id] ?? [])
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// All active guild members with their highest role
|
||||||
|
const rawMembers = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.username, u.avatar_url, GROUP_CONCAT(m.role) as roles
|
||||||
|
FROM memberships m
|
||||||
|
JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.guild_id = ? AND m.status = 'active'
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.username`
|
||||||
|
)
|
||||||
|
.all(guildId) as Array<{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
roles: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const ROLE_PRIORITY = ["admin", "dm", "pending_dm", "player"];
|
||||||
|
const members = rawMembers.map((m) => {
|
||||||
|
const roles = m.roles.split(",");
|
||||||
|
const memberRole = ROLE_PRIORITY.find((r) => roles.includes(r)) ?? "player";
|
||||||
|
return { userId: m.id, username: m.username, avatarUrl: m.avatar_url, role: memberRole };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Campaigns where current user is a DM
|
||||||
|
const myCampaigns =
|
||||||
|
role === "dm" || role === "admin"
|
||||||
|
? (db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.id, c.name
|
||||||
|
FROM campaigns c
|
||||||
|
JOIN campaign_dms cd ON cd.campaign_id = c.id
|
||||||
|
WHERE c.guild_id = ? AND cd.user_id = ? AND c.status = 'active'
|
||||||
|
ORDER BY c.name`
|
||||||
|
)
|
||||||
|
.all(guildId, userId) as Array<{ id: number; name: string }>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// All active campaigns — admin only
|
||||||
|
const allCampaigns =
|
||||||
|
role === "admin"
|
||||||
|
? (db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name FROM campaigns WHERE guild_id = ? AND status = 'active' ORDER BY name"
|
||||||
|
)
|
||||||
|
.all(guildId) as Array<{ id: number; name: string }>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
res.json({ role, myUserId: userId, defaultGameDay, members, entries, myCampaigns, allCampaigns });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /game-nights — create game night, optionally assign campaign in one step
|
||||||
|
const createSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
|
||||||
|
campaignId: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
gameNightRoutes.post(
|
||||||
|
"/game-nights",
|
||||||
|
requireAuth,
|
||||||
|
requireRole(["dm", "admin"]),
|
||||||
|
(req: AuthedRequest, res) => {
|
||||||
|
const parsed = createSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { guildId, userId } = req.auth!;
|
||||||
|
const { date, campaignId } = parsed.data;
|
||||||
|
const role = getRole(guildId, userId);
|
||||||
|
|
||||||
|
const night = gameNightService.getOrCreateForDate(
|
||||||
|
guildId,
|
||||||
|
new Date(`${date}T12:00:00.000Z`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (campaignId != null) {
|
||||||
|
if (role !== "admin") {
|
||||||
|
const isDmOf = db
|
||||||
|
.prepare("SELECT 1 FROM campaign_dms WHERE campaign_id = ? AND user_id = ?")
|
||||||
|
.get(campaignId, userId);
|
||||||
|
if (!isDmOf) {
|
||||||
|
res.status(403).json({ error: "You can only assign campaigns you are DM of" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(campaignId, guildId);
|
||||||
|
if (!campaign) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameNightService.selectCampaignForDate(
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
new Date(`${date}T12:00:00.000Z`),
|
||||||
|
campaignId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ date, gameNightId: night.id });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /game-nights/:date/select-campaign
|
||||||
|
const selectSchema = z.object({ campaignId: z.number().int() });
|
||||||
|
|
||||||
|
gameNightRoutes.post(
|
||||||
|
"/game-nights/:date/select-campaign",
|
||||||
|
requireAuth,
|
||||||
|
requireRole(["dm", "admin"]),
|
||||||
|
(req: AuthedRequest, res) => {
|
||||||
|
const { guildId, userId } = req.auth!;
|
||||||
|
const day = req.params.date;
|
||||||
|
const parsed = selectSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { campaignId } = parsed.data;
|
||||||
|
const role = getRole(guildId, userId);
|
||||||
|
|
||||||
|
if (role !== "admin") {
|
||||||
|
const isDmOf = db
|
||||||
|
.prepare("SELECT 1 FROM campaign_dms WHERE campaign_id = ? AND user_id = ?")
|
||||||
|
.get(campaignId, userId);
|
||||||
|
if (!isDmOf) {
|
||||||
|
res.status(403).json({ error: "You can only assign campaigns you are DM of" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(campaignId, guildId);
|
||||||
|
if (!campaign) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gameNightService.selectCampaignForDate(
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
new Date(`${day}T12:00:00.000Z`),
|
||||||
|
campaignId
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /game-nights/:date/campaign — remove campaign assignment
|
||||||
|
gameNightRoutes.delete(
|
||||||
|
"/game-nights/:date/campaign",
|
||||||
|
requireAuth,
|
||||||
|
requireRole(["dm", "admin"]),
|
||||||
|
(req: AuthedRequest, res) => {
|
||||||
|
const { guildId } = req.auth!;
|
||||||
|
const day = req.params.date;
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE game_nights SET selected_campaign_id = NULL, selected_by = NULL, status = 'open' WHERE guild_id = ? AND scheduled_date = ?"
|
||||||
|
).run(guildId, day);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /game-nights/:date/blackout — mark yourself unavailable
|
||||||
|
const blackoutSchema = z.object({ reason: z.string().max(200).optional() });
|
||||||
|
|
||||||
|
gameNightRoutes.post("/game-nights/:date/blackout", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const { guildId, userId } = req.auth!;
|
||||||
|
const day = req.params.date;
|
||||||
|
const parsed = blackoutSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO blackouts (guild_id, user_id, date, reason) VALUES (?, ?, ?, ?)"
|
||||||
|
).run(guildId, userId, day, parsed.data.reason ?? null);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /game-nights/:date/blackout — remove own blackout
|
||||||
|
gameNightRoutes.delete(
|
||||||
|
"/game-nights/:date/blackout",
|
||||||
|
requireAuth,
|
||||||
|
(req: AuthedRequest, res) => {
|
||||||
|
const { guildId, userId } = req.auth!;
|
||||||
|
const day = req.params.date;
|
||||||
|
db.prepare(
|
||||||
|
"DELETE FROM blackouts WHERE guild_id = ? AND user_id = ? AND date = ?"
|
||||||
|
).run(guildId, userId, day);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
98
apps/server/src/routes/sessionRoutes.ts
Normal file
98
apps/server/src/routes/sessionRoutes.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAuth, requireRole, type AuthedRequest } from "../middleware.js";
|
||||||
|
import { sessionService } from "../services/sessionService.js";
|
||||||
|
import { recapService } from "../services/recap/index.js";
|
||||||
|
|
||||||
|
const startSchema = z.object({
|
||||||
|
campaignId: z.number().int().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const segmentSchema = z.object({
|
||||||
|
discordUserId: z.string().min(3),
|
||||||
|
text: z.string().min(1),
|
||||||
|
rawText: z.string().optional(),
|
||||||
|
startedAt: z.string().datetime().optional(),
|
||||||
|
endedAt: z.string().datetime().optional(),
|
||||||
|
confidence: z.number().min(0).max(1).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessionRoutes = Router();
|
||||||
|
|
||||||
|
sessionRoutes.post("/sessions/start", requireAuth, requireRole(["dm", "admin"]), (req: AuthedRequest, res) => {
|
||||||
|
const parsed = startSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = sessionService.startSession(req.auth!.guildId, req.auth!.userId, parsed.data.campaignId);
|
||||||
|
res.status(result.alreadyActive ? 200 : 201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionRoutes.post("/sessions/:id/stop", requireAuth, requireRole(["dm", "admin"]), (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
try {
|
||||||
|
const sessionId = sessionService.stopSession(req.auth!.guildId, req.auth!.userId, id);
|
||||||
|
res.json({ ok: true, sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionRoutes.get("/sessions/active", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const active = sessionService.getActiveSession(req.auth!.guildId);
|
||||||
|
res.json({ active });
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionRoutes.post("/sessions/:id/segments", requireAuth, requireRole(["dm", "admin"]), (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const parsed = segmentSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = sessionService.appendSegment({
|
||||||
|
sessionId: id,
|
||||||
|
guildId: req.auth!.guildId,
|
||||||
|
discordUserId: parsed.data.discordUserId,
|
||||||
|
text: parsed.data.text,
|
||||||
|
rawText: parsed.data.rawText ?? null,
|
||||||
|
startedAt: parsed.data.startedAt ?? null,
|
||||||
|
endedAt: parsed.data.endedAt ?? null,
|
||||||
|
confidence: parsed.data.confidence ?? null
|
||||||
|
});
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionRoutes.get("/sessions/:id/transcript", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const transcript = sessionService.getTranscript(id, req.auth!.guildId);
|
||||||
|
if (!transcript) {
|
||||||
|
res.status(404).json({ error: "Session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(transcript);
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionRoutes.post(
|
||||||
|
"/sessions/:id/generate-recap",
|
||||||
|
requireAuth,
|
||||||
|
requireRole(["dm", "admin"]),
|
||||||
|
async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
try {
|
||||||
|
const result = await recapService.generateRecap(id, req.auth!.guildId, req.auth!.userId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
22
apps/server/src/routes/userRoutes.ts
Normal file
22
apps/server/src/routes/userRoutes.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { requireAuth, type AuthedRequest } from "../middleware.js";
|
||||||
|
|
||||||
|
export const userRoutes = Router();
|
||||||
|
|
||||||
|
userRoutes.get("/me", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const user = db
|
||||||
|
.prepare("SELECT id, discord_user_id, username, avatar_url FROM users WHERE id = ?")
|
||||||
|
.get(req.auth!.userId);
|
||||||
|
const memberships = db
|
||||||
|
.prepare("SELECT role, status FROM memberships WHERE guild_id = ? AND user_id = ? AND status = 'active'")
|
||||||
|
.all(req.auth!.guildId, req.auth!.userId) as Array<{ role: string; status: string }>;
|
||||||
|
|
||||||
|
// Derive the single highest-privilege role for the frontend to use
|
||||||
|
const ROLE_PRIORITY = ["admin", "dm", "pending_dm", "player"];
|
||||||
|
const roles = memberships.map((m) => m.role);
|
||||||
|
const role = ROLE_PRIORITY.find((r) => roles.includes(r)) ?? "player";
|
||||||
|
|
||||||
|
res.json({ user, memberships, role });
|
||||||
|
});
|
||||||
|
|
||||||
277
apps/server/src/routes/wikiRoutes.ts
Normal file
277
apps/server/src/routes/wikiRoutes.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { requireAuth, type AuthedRequest } from "../middleware.js";
|
||||||
|
import { permissionService } from "../services/permissionService.js";
|
||||||
|
import { auditService } from "../services/auditService.js";
|
||||||
|
|
||||||
|
const WIKI_CATEGORIES = ["NPCs", "Items", "Locations", "Lore", "General"] as const;
|
||||||
|
const WIKI_NOTE_TYPES = ["dm", "shared"] as const;
|
||||||
|
|
||||||
|
const createWikiPageSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
bodyMd: z.string().default(""),
|
||||||
|
category: z.enum(WIKI_CATEGORIES).default("General"),
|
||||||
|
noteType: z.enum(WIKI_NOTE_TYPES).default("shared"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWikiPageSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
bodyMd: z.string().optional(),
|
||||||
|
category: z.enum(WIKI_CATEGORIES).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPlayerNoteSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
bodyMd: z.string().default(""),
|
||||||
|
category: z.enum(WIKI_CATEGORIES).default("General"),
|
||||||
|
sessionDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePlayerNoteSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
bodyMd: z.string().optional(),
|
||||||
|
category: z.enum(WIKI_CATEGORIES).optional(),
|
||||||
|
sessionDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCampaign(campaignId: number, guildId: number) {
|
||||||
|
return db
|
||||||
|
.prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(campaignId, guildId) as { id: number } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wikiRoutes = Router();
|
||||||
|
|
||||||
|
// ── Wiki pages (dm + shared) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
wikiRoutes.get("/campaigns/:campaignId/wiki", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.campaignId);
|
||||||
|
if (!getCampaign(campaignId, req.auth!.guildId)) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const category = req.query.category as string | undefined;
|
||||||
|
const validCategory = WIKI_CATEGORIES.includes(category as (typeof WIKI_CATEGORIES)[number])
|
||||||
|
? category
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const pages = validCategory
|
||||||
|
? db.prepare(
|
||||||
|
`SELECT wp.id, wp.title, wp.body_md, wp.category, wp.note_type,
|
||||||
|
wp.author_id, u.username AS author_username, wp.created_at, wp.updated_at
|
||||||
|
FROM wiki_pages wp JOIN users u ON u.id = wp.author_id
|
||||||
|
WHERE wp.campaign_id = ? AND wp.guild_id = ? AND wp.note_type IN ('dm','shared') AND wp.category = ?
|
||||||
|
ORDER BY wp.note_type ASC, wp.created_at DESC`
|
||||||
|
).all(campaignId, req.auth!.guildId, validCategory)
|
||||||
|
: db.prepare(
|
||||||
|
`SELECT wp.id, wp.title, wp.body_md, wp.category, wp.note_type,
|
||||||
|
wp.author_id, u.username AS author_username, wp.created_at, wp.updated_at
|
||||||
|
FROM wiki_pages wp JOIN users u ON u.id = wp.author_id
|
||||||
|
WHERE wp.campaign_id = ? AND wp.guild_id = ? AND wp.note_type IN ('dm','shared')
|
||||||
|
ORDER BY wp.note_type ASC, wp.created_at DESC`
|
||||||
|
).all(campaignId, req.auth!.guildId);
|
||||||
|
|
||||||
|
res.json(pages);
|
||||||
|
});
|
||||||
|
|
||||||
|
wikiRoutes.post("/campaigns/:campaignId/wiki", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.campaignId);
|
||||||
|
if (!getCampaign(campaignId, req.auth!.guildId)) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = createWikiPageSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.data.noteType === "dm" && !permissionService.ensureCanManageCampaign(req.auth!.guildId, req.auth!.userId, campaignId)) {
|
||||||
|
res.status(403).json({ error: "Only campaign DMs or admins can create DM notes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
campaignId,
|
||||||
|
req.auth!.guildId,
|
||||||
|
req.auth!.userId,
|
||||||
|
parsed.data.title,
|
||||||
|
parsed.data.bodyMd,
|
||||||
|
parsed.data.category,
|
||||||
|
parsed.data.noteType
|
||||||
|
);
|
||||||
|
const pageId = Number(result.lastInsertRowid);
|
||||||
|
auditService.log(req.auth!.guildId, req.auth!.userId, "wiki_page.created", { campaignId, pageId });
|
||||||
|
res.status(201).json({ id: pageId });
|
||||||
|
});
|
||||||
|
|
||||||
|
wikiRoutes.patch("/campaigns/:campaignId/wiki/:pageId", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.campaignId);
|
||||||
|
const pageId = Number(req.params.pageId);
|
||||||
|
if (!getCampaign(campaignId, req.auth!.guildId)) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = db.prepare(
|
||||||
|
"SELECT id, note_type, author_id FROM wiki_pages WHERE id = ? AND campaign_id = ? AND guild_id = ? AND note_type IN ('dm','shared')"
|
||||||
|
).get(pageId, campaignId, req.auth!.guildId) as { id: number; note_type: string; author_id: number } | undefined;
|
||||||
|
if (!page) {
|
||||||
|
res.status(404).json({ error: "Wiki page not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (page.note_type === "dm" && !permissionService.ensureCanManageCampaign(req.auth!.guildId, req.auth!.userId, campaignId)) {
|
||||||
|
res.status(403).json({ error: "Only campaign DMs or admins can edit DM notes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = updateWikiPageSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
if (parsed.data.title !== undefined) { fields.push("title = ?"); values.push(parsed.data.title); }
|
||||||
|
if (parsed.data.bodyMd !== undefined) { fields.push("body_md = ?"); values.push(parsed.data.bodyMd); }
|
||||||
|
if (parsed.data.category !== undefined) { fields.push("category = ?"); values.push(parsed.data.category); }
|
||||||
|
if (fields.length === 0) {
|
||||||
|
res.status(400).json({ error: "No fields to update" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fields.push("updated_at = datetime('now')");
|
||||||
|
db.prepare(`UPDATE wiki_pages SET ${fields.join(", ")} WHERE id = ?`).run(...values, pageId);
|
||||||
|
auditService.log(req.auth!.guildId, req.auth!.userId, "wiki_page.updated", { campaignId, pageId });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
wikiRoutes.delete("/campaigns/:campaignId/wiki/:pageId", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.campaignId);
|
||||||
|
const pageId = Number(req.params.pageId);
|
||||||
|
if (!getCampaign(campaignId, req.auth!.guildId)) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = db.prepare(
|
||||||
|
"SELECT id, note_type, author_id FROM wiki_pages WHERE id = ? AND campaign_id = ? AND guild_id = ? AND note_type IN ('dm','shared')"
|
||||||
|
).get(pageId, campaignId, req.auth!.guildId) as { id: number; note_type: string; author_id: number } | undefined;
|
||||||
|
if (!page) {
|
||||||
|
res.status(404).json({ error: "Wiki page not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (page.note_type === "dm" && !permissionService.ensureCanManageCampaign(req.auth!.guildId, req.auth!.userId, campaignId)) {
|
||||||
|
res.status(403).json({ error: "Only campaign DMs or admins can delete DM notes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.prepare("DELETE FROM wiki_pages WHERE id = ?").run(pageId);
|
||||||
|
auditService.log(req.auth!.guildId, req.auth!.userId, "wiki_page.deleted", { campaignId, pageId });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Player notes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
wikiRoutes.get("/campaigns/:campaignId/notes", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.campaignId);
|
||||||
|
if (!getCampaign(campaignId, req.auth!.guildId)) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isDmOrAdmin = permissionService.ensureCanManageCampaign(req.auth!.guildId, req.auth!.userId, campaignId);
|
||||||
|
const notes = isDmOrAdmin
|
||||||
|
? db.prepare(
|
||||||
|
`SELECT wp.id, wp.title, wp.body_md, wp.category, wp.note_type, wp.session_date,
|
||||||
|
wp.author_id, u.username AS author_username, wp.created_at, wp.updated_at
|
||||||
|
FROM wiki_pages wp JOIN users u ON u.id = wp.author_id
|
||||||
|
WHERE wp.campaign_id = ? AND wp.guild_id = ? AND wp.note_type = 'player'
|
||||||
|
ORDER BY wp.session_date DESC, wp.created_at DESC`
|
||||||
|
).all(campaignId, req.auth!.guildId)
|
||||||
|
: db.prepare(
|
||||||
|
`SELECT wp.id, wp.title, wp.body_md, wp.category, wp.note_type, wp.session_date,
|
||||||
|
wp.author_id, u.username AS author_username, wp.created_at, wp.updated_at
|
||||||
|
FROM wiki_pages wp JOIN users u ON u.id = wp.author_id
|
||||||
|
WHERE wp.campaign_id = ? AND wp.guild_id = ? AND wp.note_type = 'player' AND wp.author_id = ?
|
||||||
|
ORDER BY wp.session_date DESC, wp.created_at DESC`
|
||||||
|
).all(campaignId, req.auth!.guildId, req.auth!.userId);
|
||||||
|
res.json(notes);
|
||||||
|
});
|
||||||
|
|
||||||
|
wikiRoutes.post("/campaigns/:campaignId/notes", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.campaignId);
|
||||||
|
if (!getCampaign(campaignId, req.auth!.guildId)) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = createPlayerNoteSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT INTO wiki_pages (campaign_id, guild_id, author_id, title, body_md, category, note_type, session_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'player', ?)`
|
||||||
|
).run(
|
||||||
|
campaignId,
|
||||||
|
req.auth!.guildId,
|
||||||
|
req.auth!.userId,
|
||||||
|
parsed.data.title,
|
||||||
|
parsed.data.bodyMd,
|
||||||
|
parsed.data.category,
|
||||||
|
parsed.data.sessionDate ?? null
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: Number(result.lastInsertRowid) });
|
||||||
|
});
|
||||||
|
|
||||||
|
wikiRoutes.patch("/campaigns/:campaignId/notes/:noteId", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.campaignId);
|
||||||
|
const noteId = Number(req.params.noteId);
|
||||||
|
if (!getCampaign(campaignId, req.auth!.guildId)) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const note = db.prepare(
|
||||||
|
"SELECT id, author_id FROM wiki_pages WHERE id = ? AND campaign_id = ? AND guild_id = ? AND note_type = 'player'"
|
||||||
|
).get(noteId, campaignId, req.auth!.guildId) as { id: number; author_id: number } | undefined;
|
||||||
|
if (!note) {
|
||||||
|
res.status(404).json({ error: "Note not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (note.author_id !== req.auth!.userId) {
|
||||||
|
res.status(403).json({ error: "You can only edit your own notes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = updatePlayerNoteSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
if (parsed.data.title !== undefined) { fields.push("title = ?"); values.push(parsed.data.title); }
|
||||||
|
if (parsed.data.bodyMd !== undefined) { fields.push("body_md = ?"); values.push(parsed.data.bodyMd); }
|
||||||
|
if (parsed.data.category !== undefined) { fields.push("category = ?"); values.push(parsed.data.category); }
|
||||||
|
if (parsed.data.sessionDate !== undefined) { fields.push("session_date = ?"); values.push(parsed.data.sessionDate); }
|
||||||
|
if (fields.length === 0) {
|
||||||
|
res.status(400).json({ error: "No fields to update" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fields.push("updated_at = datetime('now')");
|
||||||
|
db.prepare(`UPDATE wiki_pages SET ${fields.join(", ")} WHERE id = ?`).run(...values, noteId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
wikiRoutes.delete("/campaigns/:campaignId/notes/:noteId", requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
const campaignId = Number(req.params.campaignId);
|
||||||
|
const noteId = Number(req.params.noteId);
|
||||||
|
if (!getCampaign(campaignId, req.auth!.guildId)) {
|
||||||
|
res.status(404).json({ error: "Campaign not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = db.prepare(
|
||||||
|
"DELETE FROM wiki_pages WHERE id = ? AND campaign_id = ? AND guild_id = ? AND note_type = 'player' AND author_id = ?"
|
||||||
|
).run(noteId, campaignId, req.auth!.guildId, req.auth!.userId);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
res.status(404).json({ error: "Note not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
11
apps/server/src/services/auditService.ts
Normal file
11
apps/server/src/services/auditService.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
|
||||||
|
export const auditService = {
|
||||||
|
log(guildId: number, actorUserId: number | null, eventType: string, payload?: unknown) {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO audit_events (guild_id, actor_user_id, event_type, payload_json)
|
||||||
|
VALUES (?, ?, ?, ?)`
|
||||||
|
).run(guildId, actorUserId, eventType, payload ? JSON.stringify(payload) : null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
72
apps/server/src/services/authService.ts
Normal file
72
apps/server/src/services/authService.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import type { AuthContext } from "../types.js";
|
||||||
|
|
||||||
|
const tokenTtl = "7d";
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
signToken(ctx: AuthContext): string {
|
||||||
|
return jwt.sign(ctx, config.jwtSecret, { expiresIn: tokenTtl });
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyToken(token: string): AuthContext {
|
||||||
|
return jwt.verify(token, config.jwtSecret) as AuthContext;
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrCreateGuild(): { id: number; discord_guild_id: string } {
|
||||||
|
const existing = db
|
||||||
|
.prepare("SELECT id, discord_guild_id FROM guilds WHERE discord_guild_id = ?")
|
||||||
|
.get(config.discordGuildId) as { id: number; discord_guild_id: string } | undefined;
|
||||||
|
if (existing) return existing;
|
||||||
|
const res = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO guilds (discord_guild_id, name, timezone, game_night_channel_id, notify_hour, notify_minute)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
config.discordGuildId,
|
||||||
|
"DnD Guild",
|
||||||
|
config.timezone,
|
||||||
|
config.discordGameNightChannelId,
|
||||||
|
config.notifyHour,
|
||||||
|
config.notifyMinute
|
||||||
|
);
|
||||||
|
return { id: Number(res.lastInsertRowid), discord_guild_id: config.discordGuildId };
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrCreateUser(discordUserId: string, username: string, avatarUrl: string | null): number {
|
||||||
|
const existing = db.prepare("SELECT id FROM users WHERE discord_user_id = ?").get(discordUserId) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (existing) {
|
||||||
|
db.prepare("UPDATE users SET username = ?, avatar_url = ? WHERE id = ?").run(
|
||||||
|
username,
|
||||||
|
avatarUrl,
|
||||||
|
existing.id
|
||||||
|
);
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = db
|
||||||
|
.prepare("INSERT INTO users (discord_user_id, username, avatar_url) VALUES (?, ?, ?)")
|
||||||
|
.run(discordUserId, username, avatarUrl);
|
||||||
|
return Number(res.lastInsertRowid);
|
||||||
|
},
|
||||||
|
|
||||||
|
ensurePlayerMembership(guildId: number, userId: number) {
|
||||||
|
this.ensureMembership(guildId, userId, "player");
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureMembership(guildId: number, userId: number, role: "player" | "dm" | "admin" | "pending_dm") {
|
||||||
|
const existing = db
|
||||||
|
.prepare("SELECT id FROM memberships WHERE guild_id = ? AND user_id = ? AND role = ?")
|
||||||
|
.get(guildId, userId, role);
|
||||||
|
if (!existing) {
|
||||||
|
db.prepare("INSERT INTO memberships (guild_id, user_id, role, status) VALUES (?, ?, ?, 'active')")
|
||||||
|
.run(guildId, userId, role);
|
||||||
|
} else {
|
||||||
|
db.prepare("UPDATE memberships SET status = 'active' WHERE id = ?").run((existing as { id: number }).id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
337
apps/server/src/services/discordService.ts
Normal file
337
apps/server/src/services/discordService.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
import {
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
Client,
|
||||||
|
GatewayIntentBits,
|
||||||
|
REST,
|
||||||
|
Routes,
|
||||||
|
SlashCommandBuilder,
|
||||||
|
TextChannel,
|
||||||
|
VoiceState,
|
||||||
|
ChannelType
|
||||||
|
} from "discord.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { gameNightService } from "./gameNightService.js";
|
||||||
|
import { permissionService } from "./permissionService.js";
|
||||||
|
import { sessionService } from "./sessionService.js";
|
||||||
|
import { voiceMonitorService } from "./voiceMonitorService.js";
|
||||||
|
import { voiceAudioService } from "./voiceAudioService.js";
|
||||||
|
|
||||||
|
let client: Client | null = null;
|
||||||
|
|
||||||
|
const commandDefs = [
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName("game")
|
||||||
|
.setDescription("Game night commands")
|
||||||
|
.addSubcommand((s) => s.setName("status").setDescription("Show tonight's campaign status"))
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
s
|
||||||
|
.setName("select")
|
||||||
|
.setDescription("Select tonight's campaign")
|
||||||
|
.addStringOption((o) => o.setName("campaign").setDescription("Campaign name").setRequired(true))
|
||||||
|
),
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName("character")
|
||||||
|
.setDescription("Character nickname commands")
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
s
|
||||||
|
.setName("use")
|
||||||
|
.setDescription("Use a character nickname for tonight")
|
||||||
|
.addStringOption((o) => o.setName("name").setDescription("Character name").setRequired(true))
|
||||||
|
)
|
||||||
|
.addSubcommand((s) => s.setName("reset").setDescription("Reset your nickname")),
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName("campaign")
|
||||||
|
.setDescription("Campaign board commands")
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
s
|
||||||
|
.setName("post")
|
||||||
|
.setDescription("Post to campaign board")
|
||||||
|
.addStringOption((o) => o.setName("campaign").setDescription("Campaign name").setRequired(true))
|
||||||
|
.addStringOption((o) => o.setName("title").setDescription("Post title").setRequired(true))
|
||||||
|
.addStringOption((o) => o.setName("content").setDescription("Post content").setRequired(true))
|
||||||
|
),
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName("session")
|
||||||
|
.setDescription("Transcript session commands")
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
s
|
||||||
|
.setName("start")
|
||||||
|
.setDescription("Start transcript recording session")
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
o.setName("voice-channel").setDescription("Voice channel to monitor for transcription")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) => s.setName("stop").setDescription("Stop active transcript session"))
|
||||||
|
.addSubcommand((s) => s.setName("status").setDescription("Show active transcript session"))
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleGameStatus(interaction: ChatInputCommandInteraction, guildId: number) {
|
||||||
|
const status = gameNightService.getTonightStatus(guildId);
|
||||||
|
await interaction.reply({ content: status.message, ephemeral: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGameSelect(interaction: ChatInputCommandInteraction, guildId: number) {
|
||||||
|
const discordUserId = interaction.user.id;
|
||||||
|
const user = db.prepare("SELECT id FROM users WHERE discord_user_id = ?").get(discordUserId) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (!user) {
|
||||||
|
await interaction.reply({ content: "Sign in to the web app first.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!permissionService.hasRole(guildId, user.id, ["dm", "admin"])) {
|
||||||
|
await interaction.reply({ content: "You need DM or admin role.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const campaignName = interaction.options.getString("campaign", true);
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT id FROM campaigns WHERE guild_id = ? AND name = ?")
|
||||||
|
.get(guildId, campaignName) as { id: number } | undefined;
|
||||||
|
if (!campaign) {
|
||||||
|
await interaction.reply({ content: "Campaign not found.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameNightService.selectCampaignForDate(guildId, user.id, new Date(), campaign.id);
|
||||||
|
await interaction.reply({ content: `Selected campaign: ${campaignName}`, ephemeral: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCharacterUse(interaction: ChatInputCommandInteraction, guildId: number) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const user = db.prepare("SELECT id FROM users WHERE discord_user_id = ?").get(interaction.user.id) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (!user || !interaction.guild) {
|
||||||
|
await interaction.reply({ content: "Sign in to the web app first.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const character = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.id, c.character_name
|
||||||
|
FROM characters c
|
||||||
|
JOIN campaigns p ON p.id = c.campaign_id
|
||||||
|
WHERE c.user_id = ? AND p.guild_id = ? AND c.character_name = ?`
|
||||||
|
)
|
||||||
|
.get(user.id, guildId, name) as { id: number; character_name: string } | undefined;
|
||||||
|
if (!character) {
|
||||||
|
await interaction.reply({ content: "Character not found.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||||
|
const original = member.nickname ?? member.user.username;
|
||||||
|
await member.setNickname(character.character_name);
|
||||||
|
|
||||||
|
const tonight = gameNightService.getOrCreateForDate(guildId, new Date());
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO nickname_sessions (guild_id, user_id, game_night_id, character_id, original_nickname, applied_nickname)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(guildId, user.id, tonight.id, character.id, original, character.character_name);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Nickname changed to ${character.character_name}.`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCharacterReset(interaction: ChatInputCommandInteraction, guildId: number) {
|
||||||
|
const user = db.prepare("SELECT id FROM users WHERE discord_user_id = ?").get(interaction.user.id) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (!user || !interaction.guild) {
|
||||||
|
await interaction.reply({ content: "Sign in to the web app first.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, original_nickname
|
||||||
|
FROM nickname_sessions
|
||||||
|
WHERE guild_id = ? AND user_id = ? AND reverted_at IS NULL
|
||||||
|
ORDER BY id DESC LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(guildId, user.id) as { id: number; original_nickname: string | null } | undefined;
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({ content: "No active nickname session.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||||
|
await member.setNickname(session.original_nickname || null);
|
||||||
|
db.prepare("UPDATE nickname_sessions SET reverted_at = datetime('now') WHERE id = ?").run(session.id);
|
||||||
|
await interaction.reply({ content: "Nickname reset.", ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCampaignPost(interaction: ChatInputCommandInteraction, guildId: number) {
|
||||||
|
const user = db.prepare("SELECT id FROM users WHERE discord_user_id = ?").get(interaction.user.id) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (!user) {
|
||||||
|
await interaction.reply({ content: "Sign in to the web app first.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const campaignName = interaction.options.getString("campaign", true);
|
||||||
|
const title = interaction.options.getString("title", true);
|
||||||
|
const body = interaction.options.getString("content", true);
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT id, discord_channel_id FROM campaigns WHERE guild_id = ? AND name = ?")
|
||||||
|
.get(guildId, campaignName) as { id: number; discord_channel_id: string | null } | undefined;
|
||||||
|
if (!campaign) {
|
||||||
|
await interaction.reply({ content: "Campaign not found.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!permissionService.ensureCanManageCampaign(guildId, user.id, campaign.id)) {
|
||||||
|
await interaction.reply({ content: "Only campaign DMs/admins can post.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const insert = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO campaign_posts (campaign_id, author_id, title, body_md, visibility)
|
||||||
|
VALUES (?, ?, ?, ?, 'guild')`
|
||||||
|
)
|
||||||
|
.run(campaign.id, user.id, title, body);
|
||||||
|
const postId = Number(insert.lastInsertRowid);
|
||||||
|
let discordMessageId: string | null = null;
|
||||||
|
if (campaign.discord_channel_id) {
|
||||||
|
discordMessageId = await discordService.postToChannel(
|
||||||
|
campaign.discord_channel_id,
|
||||||
|
`**${campaignName}**\n**${title}**\n${body}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (discordMessageId) {
|
||||||
|
db.prepare("UPDATE campaign_posts SET discord_message_id = ? WHERE id = ?").run(discordMessageId, postId);
|
||||||
|
}
|
||||||
|
await interaction.reply({ content: `Posted to ${campaignName}.`, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSessionCommand(interaction: ChatInputCommandInteraction, guildId: number) {
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
const user = db.prepare("SELECT id FROM users WHERE discord_user_id = ?").get(interaction.user.id) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (!user) {
|
||||||
|
await interaction.reply({ content: "Sign in to the web app first.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "status") {
|
||||||
|
const active = sessionService.getActiveSession(guildId);
|
||||||
|
if (!active) {
|
||||||
|
await interaction.reply({ content: "No active transcript session.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Active session #${active.id} for campaign ${active.campaign_id}.`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!permissionService.hasRole(guildId, user.id, ["dm", "admin"])) {
|
||||||
|
await interaction.reply({ content: "You need DM or admin role.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "start") {
|
||||||
|
const voiceChannel = interaction.options.getChannel("voice-channel");
|
||||||
|
try {
|
||||||
|
const result = sessionService.startSession(guildId, user.id);
|
||||||
|
const message = result.alreadyActive
|
||||||
|
? `Session already active (#${result.sessionId}).`
|
||||||
|
: `Started session #${result.sessionId} (campaign ${result.campaignId}).`;
|
||||||
|
|
||||||
|
if (voiceChannel && voiceChannel.type === ChannelType.GuildVoice) {
|
||||||
|
if (!client) {
|
||||||
|
await interaction.reply({ content: "Discord client not initialized.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
voiceMonitorService.startVoiceSession(guildId, voiceChannel.id, result.sessionId, result.campaignId);
|
||||||
|
await voiceAudioService.joinVoiceChannel(client, guildId, voiceChannel.id, result.sessionId);
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${message} Voice monitoring started in ${voiceChannel.name}.`,
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: message, ephemeral: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await interaction.reply({ content: String(error), ephemeral: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "stop") {
|
||||||
|
try {
|
||||||
|
const active = sessionService.getActiveSession(guildId);
|
||||||
|
if (!active) {
|
||||||
|
await interaction.reply({ content: "No active transcript session.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
voiceMonitorService.stopVoiceSession(guildId);
|
||||||
|
voiceAudioService.leaveVoiceChannel(guildId);
|
||||||
|
sessionService.stopSession(guildId, user.id, active.id);
|
||||||
|
await interaction.reply({ content: `Stopped session #${active.id}.`, ephemeral: false });
|
||||||
|
} catch (error) {
|
||||||
|
await interaction.reply({ content: String(error), ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onInteraction(interaction: ChatInputCommandInteraction) {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
const guild = db
|
||||||
|
.prepare("SELECT id FROM guilds WHERE discord_guild_id = ?")
|
||||||
|
.get(interaction.guildId ?? "") as { id: number } | undefined;
|
||||||
|
if (!guild) {
|
||||||
|
await interaction.reply({ content: "Guild is not initialized in app.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const guildId = guild.id;
|
||||||
|
if (interaction.commandName === "game" && interaction.options.getSubcommand() === "status") {
|
||||||
|
await handleGameStatus(interaction, guildId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.commandName === "game" && interaction.options.getSubcommand() === "select") {
|
||||||
|
await handleGameSelect(interaction, guildId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.commandName === "character" && interaction.options.getSubcommand() === "use") {
|
||||||
|
await handleCharacterUse(interaction, guildId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.commandName === "character" && interaction.options.getSubcommand() === "reset") {
|
||||||
|
await handleCharacterReset(interaction, guildId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.commandName === "campaign" && interaction.options.getSubcommand() === "post") {
|
||||||
|
await handleCampaignPost(interaction, guildId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.commandName === "session") {
|
||||||
|
await handleSessionCommand(interaction, guildId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const discordService = {
|
||||||
|
async start() {
|
||||||
|
client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildVoiceStates]
|
||||||
|
});
|
||||||
|
client.on("interactionCreate", async (i) => onInteraction(i as ChatInputCommandInteraction));
|
||||||
|
client.on("voiceStateUpdate", async (oldState, newState) => {
|
||||||
|
await voiceMonitorService.onVoiceStateUpdate(oldState, newState);
|
||||||
|
});
|
||||||
|
await client.login(config.discordBotToken);
|
||||||
|
|
||||||
|
const rest = new REST({ version: "10" }).setToken(config.discordBotToken);
|
||||||
|
await rest.put(Routes.applicationGuildCommands(config.discordClientId, config.discordGuildId), {
|
||||||
|
body: commandDefs.map((c) => c.toJSON())
|
||||||
|
});
|
||||||
|
console.log("Discord bot online and commands registered.");
|
||||||
|
},
|
||||||
|
|
||||||
|
async postToChannel(channelId: string, content: string): Promise<string | null> {
|
||||||
|
if (!client) return null;
|
||||||
|
const channel = await client.channels.fetch(channelId);
|
||||||
|
if (!channel || !(channel instanceof TextChannel)) return null;
|
||||||
|
const msg = await channel.send({ content });
|
||||||
|
return msg.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
async postGameNightMessage(content: string): Promise<string | null> {
|
||||||
|
return this.postToChannel(config.discordGameNightChannelId, content);
|
||||||
|
}
|
||||||
|
};
|
||||||
76
apps/server/src/services/gameNightService.ts
Normal file
76
apps/server/src/services/gameNightService.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { auditService } from "./auditService.js";
|
||||||
|
import { discordService } from "./discordService.js";
|
||||||
|
|
||||||
|
const toDateOnly = (date: Date): string => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export const gameNightService = {
|
||||||
|
getOrCreateForDate(guildId: number, date: Date) {
|
||||||
|
const day = toDateOnly(date);
|
||||||
|
const existing = db
|
||||||
|
.prepare("SELECT id, selected_campaign_id FROM game_nights WHERE guild_id = ? AND scheduled_date = ?")
|
||||||
|
.get(guildId, day) as { id: number; selected_campaign_id: number | null } | undefined;
|
||||||
|
if (existing) return existing;
|
||||||
|
const res = db
|
||||||
|
.prepare("INSERT INTO game_nights (guild_id, scheduled_date, status) VALUES (?, ?, 'open')")
|
||||||
|
.run(guildId, day);
|
||||||
|
return { id: Number(res.lastInsertRowid), selected_campaign_id: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
selectCampaignForDate(guildId: number, userId: number, date: Date, campaignId: number) {
|
||||||
|
const day = toDateOnly(date);
|
||||||
|
this.getOrCreateForDate(guildId, date);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE game_nights
|
||||||
|
SET selected_campaign_id = ?, selected_by = ?, status = 'selected'
|
||||||
|
WHERE guild_id = ? AND scheduled_date = ?`
|
||||||
|
).run(campaignId, userId, guildId, day);
|
||||||
|
auditService.log(guildId, userId, "game_night.campaign_selected", { day, campaignId });
|
||||||
|
},
|
||||||
|
|
||||||
|
getTonightStatus(guildId: number) {
|
||||||
|
const day = toDateOnly(new Date());
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT gn.id, c.name as campaign_name
|
||||||
|
FROM game_nights gn
|
||||||
|
LEFT JOIN campaigns c ON c.id = gn.selected_campaign_id
|
||||||
|
WHERE gn.guild_id = ? AND gn.scheduled_date = ?`
|
||||||
|
)
|
||||||
|
.get(guildId, day) as { id: number; campaign_name: string | null } | undefined;
|
||||||
|
if (!row || !row.campaign_name) {
|
||||||
|
return { selected: false, message: "No campaign selected yet for tonight." };
|
||||||
|
}
|
||||||
|
return { selected: true, message: `Tonight: ${row.campaign_name}` };
|
||||||
|
},
|
||||||
|
|
||||||
|
async runMorningNotification(guildId: number) {
|
||||||
|
const day = toDateOnly(new Date());
|
||||||
|
|
||||||
|
// Only notify if a game night was intentionally scheduled for today.
|
||||||
|
// This avoids spamming on non-game nights when the day shifts week to week.
|
||||||
|
const scheduled = db
|
||||||
|
.prepare("SELECT id FROM game_nights WHERE guild_id = ? AND scheduled_date = ?")
|
||||||
|
.get(guildId, day);
|
||||||
|
if (!scheduled) return;
|
||||||
|
|
||||||
|
const lockKey = `notify:${guildId}:${day}`;
|
||||||
|
const dup = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id FROM audit_events WHERE guild_id = ? AND event_type = 'game_night.notification_sent' AND payload_json LIKE ?"
|
||||||
|
)
|
||||||
|
.get(guildId, `%${lockKey}%`);
|
||||||
|
if (dup) return;
|
||||||
|
|
||||||
|
const status = this.getTonightStatus(guildId);
|
||||||
|
if (!status.selected) {
|
||||||
|
await discordService.postGameNightMessage(
|
||||||
|
"No campaign selected yet for tonight. DMs/Admins, please select one."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await discordService.postGameNightMessage(status.message);
|
||||||
|
}
|
||||||
|
auditService.log(guildId, null, "game_night.notification_sent", { lockKey, selected: status.selected });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
37
apps/server/src/services/permissionService.ts
Normal file
37
apps/server/src/services/permissionService.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import type { Role } from "../types.js";
|
||||||
|
|
||||||
|
export const permissionService = {
|
||||||
|
hasRole(guildId: number, userId: number, roles: Role[]): boolean {
|
||||||
|
const placeholders = roles.map(() => "?").join(", ");
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM memberships
|
||||||
|
WHERE guild_id = ? AND user_id = ? AND status = 'active' AND role IN (${placeholders})
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(guildId, userId, ...roles);
|
||||||
|
return Boolean(row);
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureCanManageCampaign(guildId: number, userId: number, campaignId: number): boolean {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.id
|
||||||
|
FROM campaigns c
|
||||||
|
LEFT JOIN campaign_dms cd ON cd.campaign_id = c.id AND cd.user_id = ?
|
||||||
|
WHERE c.id = ? AND c.guild_id = ?
|
||||||
|
AND (
|
||||||
|
cd.id IS NOT NULL OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM memberships m
|
||||||
|
WHERE m.guild_id = c.guild_id AND m.user_id = ? AND m.status = 'active' AND m.role = 'admin'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(userId, campaignId, guildId, userId);
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
3
apps/server/src/services/recap/index.ts
Normal file
3
apps/server/src/services/recap/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { recapService } from "./recapService.js";
|
||||||
|
export { selectProvider } from "./providerRegistry.js";
|
||||||
|
export type { SummaryProvider, RecapContext, RecapParticipant, RecapSegment } from "./types.js";
|
||||||
41
apps/server/src/services/recap/prompt.ts
Normal file
41
apps/server/src/services/recap/prompt.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { RecapContext } from "./types.js";
|
||||||
|
|
||||||
|
export function buildPrompt(context: RecapContext): string {
|
||||||
|
const participantList = context.participants
|
||||||
|
.map((p) =>
|
||||||
|
p.characterName
|
||||||
|
? `- ${p.discordUsername} playing ${p.characterName} (${p.race ?? "unknown race"} ${p.class ?? "unknown class"})`
|
||||||
|
: `- ${p.discordUsername} (no active character)`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const transcriptText = context.segments
|
||||||
|
.map((s) => `${s.speakerLabel}: ${s.text}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const previousSection = context.previousRecapSummary
|
||||||
|
? `\n## Previously...\n${context.previousRecapSummary}\n`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `You are the scribe for a D&D campaign. Write a session recap in the style of a fantasy chronicle — engaging and readable for the players who were there. Follow these rules:
|
||||||
|
|
||||||
|
- Use character names, not player names
|
||||||
|
- Write 3–5 paragraphs
|
||||||
|
- Do not invent events that are not in the transcript
|
||||||
|
- Keep a narrative, in-world tone without being purple prose
|
||||||
|
- If a "Previously..." section is included, open with a brief callback to it before covering this session
|
||||||
|
|
||||||
|
## Campaign
|
||||||
|
${context.campaignName}
|
||||||
|
|
||||||
|
## Session Date
|
||||||
|
${context.sessionDate}
|
||||||
|
|
||||||
|
## Players & Characters
|
||||||
|
${participantList}
|
||||||
|
${previousSection}
|
||||||
|
## Session Transcript
|
||||||
|
${transcriptText}
|
||||||
|
|
||||||
|
Write the recap now:`;
|
||||||
|
}
|
||||||
36
apps/server/src/services/recap/providerRegistry.ts
Normal file
36
apps/server/src/services/recap/providerRegistry.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { config } from "../../config.js";
|
||||||
|
import { claudeProvider } from "./providers/claudeProvider.js";
|
||||||
|
import { ollamaProvider } from "./providers/ollamaProvider.js";
|
||||||
|
import { templateProvider } from "./providers/templateProvider.js";
|
||||||
|
import type { SummaryProvider } from "./types.js";
|
||||||
|
|
||||||
|
const PROVIDERS: Record<string, SummaryProvider> = {
|
||||||
|
claude: claudeProvider,
|
||||||
|
ollama: ollamaProvider,
|
||||||
|
template: templateProvider
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the priority list from config and returns the first available provider.
|
||||||
|
* templateProvider is always available so this never returns null in practice,
|
||||||
|
* but we fall back to it explicitly as a safety net.
|
||||||
|
*/
|
||||||
|
export async function selectProvider(): Promise<SummaryProvider> {
|
||||||
|
for (const name of config.recapProviders) {
|
||||||
|
const provider = PROVIDERS[name];
|
||||||
|
if (!provider) {
|
||||||
|
console.warn(`[recap] Unknown provider "${name}" in RECAP_PROVIDERS — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (await provider.isAvailable()) {
|
||||||
|
console.log(`[recap] Using provider: ${name}`);
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[recap] Provider "${name}" availability check failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.warn("[recap] All configured providers unavailable, falling back to template");
|
||||||
|
return templateProvider;
|
||||||
|
}
|
||||||
24
apps/server/src/services/recap/providers/claudeProvider.ts
Normal file
24
apps/server/src/services/recap/providers/claudeProvider.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import { config } from "../../../config.js";
|
||||||
|
import { buildPrompt } from "../prompt.js";
|
||||||
|
import type { SummaryProvider, RecapContext } from "../types.js";
|
||||||
|
|
||||||
|
export const claudeProvider: SummaryProvider = {
|
||||||
|
name: "claude",
|
||||||
|
|
||||||
|
async isAvailable() {
|
||||||
|
return Boolean(config.anthropicApiKey);
|
||||||
|
},
|
||||||
|
|
||||||
|
async summarize(context: RecapContext) {
|
||||||
|
const client = new Anthropic({ apiKey: config.anthropicApiKey });
|
||||||
|
const message = await client.messages.create({
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
max_tokens: 2048,
|
||||||
|
messages: [{ role: "user", content: buildPrompt(context) }]
|
||||||
|
});
|
||||||
|
const block = message.content[0];
|
||||||
|
if (block.type !== "text") throw new Error("Unexpected non-text response from Claude");
|
||||||
|
return block.text;
|
||||||
|
}
|
||||||
|
};
|
||||||
40
apps/server/src/services/recap/providers/ollamaProvider.ts
Normal file
40
apps/server/src/services/recap/providers/ollamaProvider.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { config } from "../../../config.js";
|
||||||
|
import { buildPrompt } from "../prompt.js";
|
||||||
|
import type { SummaryProvider, RecapContext } from "../types.js";
|
||||||
|
|
||||||
|
export const ollamaProvider: SummaryProvider = {
|
||||||
|
name: "ollama",
|
||||||
|
|
||||||
|
async isAvailable() {
|
||||||
|
if (!config.ollamaBaseUrl) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${config.ollamaBaseUrl}/api/tags`, {
|
||||||
|
signal: AbortSignal.timeout(3000)
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = (await res.json()) as { models?: { name: string }[] };
|
||||||
|
const models = data.models ?? [];
|
||||||
|
return models.some(m => m.name === config.ollamaModel || m.name.startsWith(`${config.ollamaModel}:`));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async summarize(context: RecapContext) {
|
||||||
|
const res = await fetch(`${config.ollamaBaseUrl}/api/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.ollamaModel,
|
||||||
|
prompt: buildPrompt(context),
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`Ollama error (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { response: string };
|
||||||
|
return data.response;
|
||||||
|
}
|
||||||
|
};
|
||||||
42
apps/server/src/services/recap/providers/templateProvider.ts
Normal file
42
apps/server/src/services/recap/providers/templateProvider.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { SummaryProvider, RecapContext } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always-available fallback. No AI required — formats the session transcript
|
||||||
|
* into clean, readable markdown so recaps are never blocked by provider outages.
|
||||||
|
*/
|
||||||
|
export const templateProvider: SummaryProvider = {
|
||||||
|
name: "template",
|
||||||
|
|
||||||
|
async isAvailable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async summarize(context: RecapContext) {
|
||||||
|
const lines: string[] = [
|
||||||
|
`# Session Recap — ${context.campaignName}`,
|
||||||
|
`**Date:** ${context.sessionDate}`,
|
||||||
|
""
|
||||||
|
];
|
||||||
|
|
||||||
|
if (context.previousRecapSummary) {
|
||||||
|
lines.push("## Previously...", context.previousRecapSummary, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("## Players");
|
||||||
|
for (const p of context.participants) {
|
||||||
|
if (p.characterName) {
|
||||||
|
const details = [p.race, p.class].filter(Boolean).join(" ");
|
||||||
|
lines.push(`- **${p.characterName}**${details ? ` (${details})` : ""} — ${p.discordUsername}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`- ${p.discordUsername} (no active character)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("", "## Transcript");
|
||||||
|
for (const s of context.segments) {
|
||||||
|
lines.push(`**${s.speakerLabel}:** ${s.text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
};
|
||||||
95
apps/server/src/services/recap/recapService.ts
Normal file
95
apps/server/src/services/recap/recapService.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { db } from "../../db/client.js";
|
||||||
|
import { auditService } from "../auditService.js";
|
||||||
|
import { selectProvider } from "./providerRegistry.js";
|
||||||
|
import type { RecapContext, RecapParticipant, RecapSegment } from "./types.js";
|
||||||
|
|
||||||
|
function buildContext(sessionId: number, guildId: number): RecapContext | null {
|
||||||
|
const session = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, campaign_id, game_night_id, started_at FROM sessions WHERE id = ? AND guild_id = ?"
|
||||||
|
)
|
||||||
|
.get(sessionId, guildId) as
|
||||||
|
| { id: number; campaign_id: number; game_night_id: number | null; started_at: string }
|
||||||
|
| undefined;
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const campaign = db
|
||||||
|
.prepare("SELECT name FROM campaigns WHERE id = ?")
|
||||||
|
.get(session.campaign_id) as { name: string } | undefined;
|
||||||
|
|
||||||
|
const participants = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.username AS discordUsername,
|
||||||
|
sp.character_name_snapshot AS characterName,
|
||||||
|
c.race, c.class
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN users u ON u.id = sp.user_id
|
||||||
|
LEFT JOIN characters c ON c.id = sp.character_id
|
||||||
|
WHERE sp.session_id = ?`
|
||||||
|
)
|
||||||
|
.all(sessionId) as RecapParticipant[];
|
||||||
|
|
||||||
|
const segments = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT sequence,
|
||||||
|
speaker_label_snapshot AS speakerLabel,
|
||||||
|
text,
|
||||||
|
started_at AS startedAt
|
||||||
|
FROM transcript_segments
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY sequence ASC`
|
||||||
|
)
|
||||||
|
.all(sessionId) as RecapSegment[];
|
||||||
|
|
||||||
|
const prevRecap = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT body_md FROM recaps WHERE campaign_id = ? ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
.get(session.campaign_id) as { body_md: string } | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaignName: campaign?.name ?? "Unknown Campaign",
|
||||||
|
sessionDate: session.started_at.slice(0, 10),
|
||||||
|
participants,
|
||||||
|
segments,
|
||||||
|
previousRecapSummary: prevRecap?.body_md ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recapService = {
|
||||||
|
async generateRecap(sessionId: number, guildId: number, requestedBy: number) {
|
||||||
|
const context = buildContext(sessionId, guildId);
|
||||||
|
if (!context) throw new Error("Session not found");
|
||||||
|
if (context.segments.length === 0) throw new Error("No transcript segments — nothing to summarize");
|
||||||
|
|
||||||
|
const provider = await selectProvider();
|
||||||
|
const summary = await provider.summarize(context);
|
||||||
|
|
||||||
|
const session = db
|
||||||
|
.prepare("SELECT campaign_id, game_night_id FROM sessions WHERE id = ?")
|
||||||
|
.get(sessionId) as { campaign_id: number; game_night_id: number | null };
|
||||||
|
|
||||||
|
const insert = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO recaps (campaign_id, game_night_id, author_id, title, body_md, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
session.campaign_id,
|
||||||
|
session.game_night_id,
|
||||||
|
requestedBy,
|
||||||
|
`Session Recap — ${context.sessionDate}`,
|
||||||
|
summary
|
||||||
|
);
|
||||||
|
|
||||||
|
const recapId = Number(insert.lastInsertRowid);
|
||||||
|
|
||||||
|
auditService.log(guildId, requestedBy, "recap.generated", {
|
||||||
|
sessionId,
|
||||||
|
recapId,
|
||||||
|
providerUsed: provider.name
|
||||||
|
});
|
||||||
|
|
||||||
|
return { recapId, summary, providerUsed: provider.name };
|
||||||
|
}
|
||||||
|
};
|
||||||
27
apps/server/src/services/recap/types.ts
Normal file
27
apps/server/src/services/recap/types.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
export interface RecapParticipant {
|
||||||
|
discordUsername: string;
|
||||||
|
characterName: string | null;
|
||||||
|
race: string | null;
|
||||||
|
class: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecapSegment {
|
||||||
|
sequence: number;
|
||||||
|
speakerLabel: string;
|
||||||
|
text: string;
|
||||||
|
startedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecapContext {
|
||||||
|
campaignName: string;
|
||||||
|
sessionDate: string;
|
||||||
|
participants: RecapParticipant[];
|
||||||
|
segments: RecapSegment[];
|
||||||
|
previousRecapSummary: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryProvider {
|
||||||
|
readonly name: string;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
summarize(context: RecapContext): Promise<string>;
|
||||||
|
}
|
||||||
190
apps/server/src/services/sessionService.ts
Normal file
190
apps/server/src/services/sessionService.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { auditService } from "./auditService.js";
|
||||||
|
import { gameNightService } from "./gameNightService.js";
|
||||||
|
|
||||||
|
const isoNow = () => new Date().toISOString();
|
||||||
|
const dateOnly = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
type ParticipantSnapshot = {
|
||||||
|
userId: number;
|
||||||
|
discordUserId: string;
|
||||||
|
characterId: number | null;
|
||||||
|
characterNameSnapshot: string | null;
|
||||||
|
speakerLabelSnapshot: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getParticipantSnapshot(guildId: number, campaignId: number, discordUserId: string): ParticipantSnapshot {
|
||||||
|
const user = db.prepare("SELECT id, username FROM users WHERE discord_user_id = ?").get(discordUserId) as
|
||||||
|
| { id: number; username: string }
|
||||||
|
| undefined;
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
userId: 0,
|
||||||
|
discordUserId,
|
||||||
|
characterId: null,
|
||||||
|
characterNameSnapshot: null,
|
||||||
|
speakerLabelSnapshot: `${discordUserId} (unassigned)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, character_name
|
||||||
|
FROM characters
|
||||||
|
WHERE user_id = ? AND campaign_id = ? AND is_active = 1
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(user.id, campaignId) as { id: number; character_name: string } | undefined;
|
||||||
|
|
||||||
|
const characterName = character?.character_name ?? null;
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
discordUserId,
|
||||||
|
characterId: character?.id ?? null,
|
||||||
|
characterNameSnapshot: characterName,
|
||||||
|
speakerLabelSnapshot: characterName ? `${user.username} (${characterName})` : `${user.username} (unassigned)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionService = {
|
||||||
|
getActiveSession(guildId: number): { id: number; campaign_id: number; game_night_id: number | null } | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, campaign_id, game_night_id FROM sessions WHERE guild_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
.get(guildId) as { id: number; campaign_id: number; game_night_id: number | null } | undefined;
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
startSession(guildId: number, startedBy: number, campaignId?: number) {
|
||||||
|
const existing = this.getActiveSession(guildId);
|
||||||
|
if (existing) {
|
||||||
|
return { alreadyActive: true, sessionId: existing.id, campaignId: existing.campaign_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedCampaignId = campaignId ?? null;
|
||||||
|
if (!selectedCampaignId) {
|
||||||
|
const today = dateOnly(new Date());
|
||||||
|
const night = db
|
||||||
|
.prepare("SELECT id, selected_campaign_id FROM game_nights WHERE guild_id = ? AND scheduled_date = ?")
|
||||||
|
.get(guildId, today) as { id: number; selected_campaign_id: number | null } | undefined;
|
||||||
|
selectedCampaignId = night?.selected_campaign_id ?? null;
|
||||||
|
}
|
||||||
|
if (!selectedCampaignId) {
|
||||||
|
throw new Error("No campaign selected for today. Select campaign first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayNight = gameNightService.getOrCreateForDate(guildId, new Date());
|
||||||
|
const insert = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO sessions (guild_id, campaign_id, game_night_id, started_by, started_at, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'active')`
|
||||||
|
)
|
||||||
|
.run(guildId, selectedCampaignId, todayNight.id, startedBy, isoNow());
|
||||||
|
const sessionId = Number(insert.lastInsertRowid);
|
||||||
|
auditService.log(guildId, startedBy, "session.started", { sessionId, campaignId: selectedCampaignId });
|
||||||
|
return { alreadyActive: false, sessionId, campaignId: selectedCampaignId };
|
||||||
|
},
|
||||||
|
|
||||||
|
stopSession(guildId: number, endedBy: number, sessionId?: number) {
|
||||||
|
const active = sessionId
|
||||||
|
? (db
|
||||||
|
.prepare("SELECT id FROM sessions WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(sessionId, guildId) as { id: number } | undefined)
|
||||||
|
: this.getActiveSession(guildId);
|
||||||
|
if (!active) throw new Error("No active session found.");
|
||||||
|
db.prepare("UPDATE sessions SET status = 'ended', ended_at = ? WHERE id = ?").run(isoNow(), active.id);
|
||||||
|
auditService.log(guildId, endedBy, "session.ended", { sessionId: active.id });
|
||||||
|
return active.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
appendSegment(input: {
|
||||||
|
sessionId: number;
|
||||||
|
guildId: number;
|
||||||
|
discordUserId: string;
|
||||||
|
text: string;
|
||||||
|
rawText?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
endedAt?: string | null;
|
||||||
|
confidence?: number | null;
|
||||||
|
}) {
|
||||||
|
const session = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, campaign_id, game_night_id FROM sessions WHERE id = ? AND guild_id = ? AND status IN ('active', 'ended')"
|
||||||
|
)
|
||||||
|
.get(input.sessionId, input.guildId) as
|
||||||
|
| { id: number; campaign_id: number; game_night_id: number | null }
|
||||||
|
| undefined;
|
||||||
|
if (!session) throw new Error("Session not found.");
|
||||||
|
|
||||||
|
const seqRow = db
|
||||||
|
.prepare("SELECT COALESCE(MAX(sequence), 0) AS seq FROM transcript_segments WHERE session_id = ?")
|
||||||
|
.get(session.id) as { seq: number };
|
||||||
|
const sequence = seqRow.seq + 1;
|
||||||
|
|
||||||
|
const snapshot = getParticipantSnapshot(input.guildId, session.campaign_id, input.discordUserId);
|
||||||
|
if (snapshot.userId > 0) {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO session_participants
|
||||||
|
(session_id, guild_id, campaign_id, user_id, discord_user_id, character_id, character_name_snapshot, speaker_label_snapshot)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id, user_id) DO UPDATE SET
|
||||||
|
character_id = excluded.character_id,
|
||||||
|
character_name_snapshot = excluded.character_name_snapshot,
|
||||||
|
speaker_label_snapshot = excluded.speaker_label_snapshot`
|
||||||
|
).run(
|
||||||
|
session.id,
|
||||||
|
input.guildId,
|
||||||
|
session.campaign_id,
|
||||||
|
snapshot.userId,
|
||||||
|
snapshot.discordUserId,
|
||||||
|
snapshot.characterId,
|
||||||
|
snapshot.characterNameSnapshot,
|
||||||
|
snapshot.speakerLabelSnapshot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentInsert = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO transcript_segments
|
||||||
|
(session_id, guild_id, campaign_id, game_night_id, sequence, started_at, ended_at, discord_user_id, user_id, character_id, character_name_snapshot, speaker_label_snapshot, text, raw_text, confidence)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
session.id,
|
||||||
|
input.guildId,
|
||||||
|
session.campaign_id,
|
||||||
|
session.game_night_id,
|
||||||
|
sequence,
|
||||||
|
input.startedAt ?? isoNow(),
|
||||||
|
input.endedAt ?? null,
|
||||||
|
input.discordUserId,
|
||||||
|
snapshot.userId || null,
|
||||||
|
snapshot.characterId,
|
||||||
|
snapshot.characterNameSnapshot,
|
||||||
|
snapshot.speakerLabelSnapshot,
|
||||||
|
input.text,
|
||||||
|
input.rawText ?? null,
|
||||||
|
input.confidence ?? null
|
||||||
|
);
|
||||||
|
const segmentId = Number(segmentInsert.lastInsertRowid);
|
||||||
|
return { segmentId, sequence };
|
||||||
|
},
|
||||||
|
|
||||||
|
getTranscript(sessionId: number, guildId: number) {
|
||||||
|
const session = db
|
||||||
|
.prepare("SELECT * FROM sessions WHERE id = ? AND guild_id = ?")
|
||||||
|
.get(sessionId, guildId);
|
||||||
|
if (!session) return null;
|
||||||
|
const segments = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT sequence, started_at, ended_at, discord_user_id, user_id, character_id, character_name_snapshot, speaker_label_snapshot, text, raw_text, confidence
|
||||||
|
FROM transcript_segments
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY sequence ASC`
|
||||||
|
)
|
||||||
|
.all(sessionId);
|
||||||
|
return { session, segments };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
179
apps/server/src/services/transcriptionService.ts
Normal file
179
apps/server/src/services/transcriptionService.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { sessionService } from "./sessionService.js";
|
||||||
|
import FormData from "form-data";
|
||||||
|
|
||||||
|
type TranscriptionResult = {
|
||||||
|
text: string;
|
||||||
|
confidence?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TranscriptionBuffer {
|
||||||
|
private chunks: Buffer[] = [];
|
||||||
|
private startTime: number | null = null;
|
||||||
|
private lastActivity: number | null = null;
|
||||||
|
private silenceTimeout: number;
|
||||||
|
|
||||||
|
constructor(silenceTimeoutMs: number = 2000) {
|
||||||
|
this.silenceTimeout = silenceTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
addChunk(audioData: Buffer): void {
|
||||||
|
this.chunks.push(audioData);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!this.startTime) this.startTime = now;
|
||||||
|
this.lastActivity = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSilent(): boolean {
|
||||||
|
if (!this.lastActivity) return true;
|
||||||
|
return Date.now() - this.lastActivity > this.silenceTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAudioBuffer(): Buffer | null {
|
||||||
|
if (this.chunks.length === 0) return null;
|
||||||
|
return Buffer.concat(this.chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.chunks = [];
|
||||||
|
this.startTime = null;
|
||||||
|
this.lastActivity = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStartTime(): string | null {
|
||||||
|
if (!this.startTime) return null;
|
||||||
|
return new Date(this.startTime).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userBuffers = new Map<string, TranscriptionBuffer>();
|
||||||
|
const activeTranscriptions = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
async function transcribeWithWhisper(audioBuffer: Buffer): Promise<TranscriptionResult> {
|
||||||
|
if (!config.openaiApiKey) {
|
||||||
|
throw new Error("OpenAI API key not configured for transcription");
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", audioBuffer, { filename: "audio.wav", contentType: "audio/wav" });
|
||||||
|
form.append("model", config.whisperModel);
|
||||||
|
form.append("language", config.whisperLanguage);
|
||||||
|
|
||||||
|
const response = await new Promise<Response>((resolve, reject) => {
|
||||||
|
const req = form.submit(`${config.whisperBaseUrl}/audio/transcriptions`);
|
||||||
|
req.setHeader("Authorization", `Bearer ${config.openaiApiKey}`);
|
||||||
|
|
||||||
|
req.on("response", (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(`Whisper API error: ${res.statusCode} ${body.toString()}`));
|
||||||
|
} else {
|
||||||
|
resolve(new Response(body, { status: res.statusCode }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Whisper API error: ${response.status} ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return {
|
||||||
|
text: result.text || "",
|
||||||
|
confidence: 1.0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processBuffer(
|
||||||
|
discordUserId: string,
|
||||||
|
guildId: number,
|
||||||
|
sessionId: number
|
||||||
|
): Promise<void> {
|
||||||
|
const buffer = userBuffers.get(discordUserId);
|
||||||
|
if (!buffer) return;
|
||||||
|
|
||||||
|
const audioData = buffer.getAudioBuffer();
|
||||||
|
if (!audioData || audioData.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await transcribeWithWhisper(audioData);
|
||||||
|
|
||||||
|
if (result.text.trim()) {
|
||||||
|
sessionService.appendSegment({
|
||||||
|
sessionId,
|
||||||
|
guildId,
|
||||||
|
discordUserId,
|
||||||
|
text: result.text.trim(),
|
||||||
|
rawText: result.text,
|
||||||
|
startedAt: buffer.getStartTime() ?? undefined,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
confidence: result.confidence
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Transcription failed for user ${discordUserId}:`, error);
|
||||||
|
} finally {
|
||||||
|
buffer.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBufferProcessing(
|
||||||
|
discordUserId: string,
|
||||||
|
guildId: number,
|
||||||
|
sessionId: number
|
||||||
|
): void {
|
||||||
|
const existing = activeTranscriptions.get(discordUserId);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
processBuffer(discordUserId, guildId, sessionId).catch(console.error);
|
||||||
|
activeTranscriptions.delete(discordUserId);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
activeTranscriptions.set(discordUserId, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transcriptionService = {
|
||||||
|
async processAudioChunk(input: {
|
||||||
|
guildId: number;
|
||||||
|
discordUserId: string;
|
||||||
|
audioData: Buffer;
|
||||||
|
sessionId: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { guildId, discordUserId, audioData, sessionId } = input;
|
||||||
|
|
||||||
|
let buffer = userBuffers.get(discordUserId);
|
||||||
|
if (!buffer) {
|
||||||
|
buffer = new TranscriptionBuffer();
|
||||||
|
userBuffers.set(discordUserId, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.addChunk(audioData);
|
||||||
|
scheduleBufferProcessing(discordUserId, guildId, sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearUserBuffer(discordUserId: string): void {
|
||||||
|
const buffer = userBuffers.get(discordUserId);
|
||||||
|
if (buffer) {
|
||||||
|
buffer.reset();
|
||||||
|
}
|
||||||
|
const timeout = activeTranscriptions.get(discordUserId);
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
activeTranscriptions.delete(discordUserId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async transcribeAudioFile(audioBuffer: Buffer): Promise<TranscriptionResult> {
|
||||||
|
return transcribeWithWhisper(audioBuffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
160
apps/server/src/services/voiceAudioService.ts
Normal file
160
apps/server/src/services/voiceAudioService.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import {
|
||||||
|
joinVoiceChannel,
|
||||||
|
VoiceConnection,
|
||||||
|
VoiceConnectionStatus,
|
||||||
|
AudioReceiveStream,
|
||||||
|
createAudioPlayer,
|
||||||
|
AudioPlayerStatus
|
||||||
|
} from "@discordjs/voice";
|
||||||
|
import { ChannelType, Client } from "discord.js";
|
||||||
|
import { voiceMonitorService } from "./voiceMonitorService.js";
|
||||||
|
import { transcriptionService } from "./transcriptionService.js";
|
||||||
|
import { VoiceChannel } from "discord.js";
|
||||||
|
|
||||||
|
type ActiveVoiceConnection = {
|
||||||
|
guildId: number;
|
||||||
|
connection: VoiceConnection;
|
||||||
|
voiceChannelId: string;
|
||||||
|
transcriptSessionId: number;
|
||||||
|
userStreams: Map<string, AudioReceiveStream>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeConnections = new Map<number, ActiveVoiceConnection>();
|
||||||
|
|
||||||
|
function createAudioPacketBuffer(): Buffer[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processAudioPacket(
|
||||||
|
guildId: number,
|
||||||
|
discordUserId: string,
|
||||||
|
packet: Buffer
|
||||||
|
): Promise<void> {
|
||||||
|
const session = voiceMonitorService.getActiveVoiceSession(guildId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
await transcriptionService.processAudioChunk({
|
||||||
|
guildId,
|
||||||
|
discordUserId,
|
||||||
|
audioData: packet,
|
||||||
|
sessionId: session.transcriptSessionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupUserAudioReceiver(
|
||||||
|
connection: VoiceConnection,
|
||||||
|
discordUserId: string,
|
||||||
|
guildId: number
|
||||||
|
): void {
|
||||||
|
const activeConnection = activeConnections.get(guildId);
|
||||||
|
if (!activeConnection) return;
|
||||||
|
|
||||||
|
if (activeConnection.userStreams.has(discordUserId)) return;
|
||||||
|
|
||||||
|
const subscription = connection.receiver.subscribe(discordUserId);
|
||||||
|
if (!subscription) return;
|
||||||
|
|
||||||
|
activeConnection.userStreams.set(discordUserId, subscription);
|
||||||
|
|
||||||
|
const packetBuffer = createAudioPacketBuffer();
|
||||||
|
let packetCount = 0;
|
||||||
|
const MAX_PACKETS = 50;
|
||||||
|
|
||||||
|
subscription.on("data", async (data: Buffer) => {
|
||||||
|
if (data.length === 0 || data.every((b) => b === 0)) return;
|
||||||
|
|
||||||
|
packetBuffer.push(data);
|
||||||
|
packetCount++;
|
||||||
|
|
||||||
|
if (packetCount >= MAX_PACKETS) {
|
||||||
|
const combined = Buffer.concat(packetBuffer);
|
||||||
|
await processAudioPacket(guildId, discordUserId, combined);
|
||||||
|
packetBuffer.length = 0;
|
||||||
|
packetCount = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
subscription.on("end", async () => {
|
||||||
|
if (packetBuffer.length > 0) {
|
||||||
|
const combined = Buffer.concat(packetBuffer);
|
||||||
|
await processAudioPacket(guildId, discordUserId, combined);
|
||||||
|
}
|
||||||
|
activeConnection.userStreams.delete(discordUserId);
|
||||||
|
transcriptionService.clearUserBuffer(discordUserId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceAudioService = {
|
||||||
|
async joinVoiceChannel(
|
||||||
|
client: Client,
|
||||||
|
guildId: number,
|
||||||
|
voiceChannelId: string,
|
||||||
|
transcriptSessionId: number
|
||||||
|
): Promise<VoiceConnection | null> {
|
||||||
|
const existing = activeConnections.get(guildId);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Already connected to voice channel ${existing.voiceChannelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await client.channels.fetch(voiceChannelId);
|
||||||
|
if (!channel || channel.type !== ChannelType.GuildVoice) {
|
||||||
|
throw new Error("Invalid voice channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = joinVoiceChannel({
|
||||||
|
channelId: channel.id,
|
||||||
|
guildId: channel.guild.id,
|
||||||
|
adapterCreator: channel.guild.voiceAdapterCreator,
|
||||||
|
selfDeaf: false,
|
||||||
|
selfMute: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const voiceConnection: ActiveVoiceConnection = {
|
||||||
|
guildId,
|
||||||
|
connection,
|
||||||
|
voiceChannelId,
|
||||||
|
transcriptSessionId,
|
||||||
|
userStreams: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
activeConnections.set(guildId, voiceConnection);
|
||||||
|
|
||||||
|
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (connection.state.status !== VoiceConnectionStatus.Destroyed && connection.state.status !== VoiceConnectionStatus.Connecting) {
|
||||||
|
connection.destroy();
|
||||||
|
activeConnections.delete(guildId);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||||
|
activeConnections.delete(guildId);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.receiver.speaking.on("start", (userId: string) => {
|
||||||
|
setupUserAudioReceiver(connection, userId, guildId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveVoiceChannel(guildId: number): boolean {
|
||||||
|
const activeConnection = activeConnections.get(guildId);
|
||||||
|
if (!activeConnection) return false;
|
||||||
|
|
||||||
|
activeConnection.connection.destroy();
|
||||||
|
activeConnections.delete(guildId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
getActiveConnection(guildId: number): ActiveVoiceConnection | null {
|
||||||
|
return activeConnections.get(guildId) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getConnectedUsers(guildId: number): string[] {
|
||||||
|
const activeConnection = activeConnections.get(guildId);
|
||||||
|
if (!activeConnection) return [];
|
||||||
|
return Array.from(activeConnection.userStreams.keys());
|
||||||
|
}
|
||||||
|
};
|
||||||
130
apps/server/src/services/voiceMonitorService.ts
Normal file
130
apps/server/src/services/voiceMonitorService.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { VoiceChannel, VoiceState } from "discord.js";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
|
||||||
|
type VoiceParticipant = {
|
||||||
|
discordUserId: string;
|
||||||
|
discordUserName: string;
|
||||||
|
voiceChannelId: string;
|
||||||
|
voiceChannelName: string;
|
||||||
|
joinedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActiveVoiceSession = {
|
||||||
|
guildId: number;
|
||||||
|
voiceChannelId: string;
|
||||||
|
transcriptSessionId: number;
|
||||||
|
campaignId: number;
|
||||||
|
participants: Map<string, VoiceParticipant>;
|
||||||
|
startedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const guildSessions = new Map<number, ActiveVoiceSession>();
|
||||||
|
|
||||||
|
function getOrCreateUser(discordUserId: string, discordUserName: string): number {
|
||||||
|
const existing = db.prepare("SELECT id FROM users WHERE discord_user_id = ?").get(discordUserId) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (existing) return existing.id;
|
||||||
|
|
||||||
|
const insert = db
|
||||||
|
.prepare("INSERT INTO users (discord_user_id, username) VALUES (?, ?)")
|
||||||
|
.run(discordUserId, discordUserName);
|
||||||
|
return Number(insert.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceMonitorService = {
|
||||||
|
getActiveVoiceSession(guildId: number): ActiveVoiceSession | null {
|
||||||
|
return guildSessions.get(guildId) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
startVoiceSession(guildId: number, voiceChannelId: string, transcriptSessionId: number, campaignId: number): ActiveVoiceSession {
|
||||||
|
const existing = guildSessions.get(guildId);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Voice session already active in channel ${existing.voiceChannelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: ActiveVoiceSession = {
|
||||||
|
guildId,
|
||||||
|
voiceChannelId,
|
||||||
|
transcriptSessionId,
|
||||||
|
campaignId,
|
||||||
|
participants: new Map(),
|
||||||
|
startedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
guildSessions.set(guildId, session);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
|
||||||
|
stopVoiceSession(guildId: number): ActiveVoiceSession | null {
|
||||||
|
const session = guildSessions.get(guildId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
guildSessions.delete(guildId);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
|
||||||
|
async onVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
||||||
|
const guild = db.prepare("SELECT id FROM guilds WHERE discord_guild_id = ?").get(oldState.guild.id) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (!guild) return;
|
||||||
|
|
||||||
|
const guildId = guild.id;
|
||||||
|
const session = guildSessions.get(guildId);
|
||||||
|
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const userId = newState.member?.user.id ?? oldState.member?.user.id;
|
||||||
|
const userName = newState.member?.user.username ?? oldState.member?.user.username ?? "unknown";
|
||||||
|
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const oldChannel = oldState.channel;
|
||||||
|
const newChannel = newState.channel;
|
||||||
|
|
||||||
|
if (newChannel && newChannel.id === session.voiceChannelId) {
|
||||||
|
if (!session.participants.has(userId)) {
|
||||||
|
session.participants.set(userId, {
|
||||||
|
discordUserId: userId,
|
||||||
|
discordUserName: userName,
|
||||||
|
voiceChannelId: newChannel.id,
|
||||||
|
voiceChannelName: newChannel.name,
|
||||||
|
joinedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbUserId = getOrCreateUser(userId, userName);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO session_participants
|
||||||
|
(session_id, guild_id, campaign_id, user_id, discord_user_id, speaker_label_snapshot)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id, user_id) DO UPDATE SET
|
||||||
|
discord_user_id = excluded.discord_user_id,
|
||||||
|
speaker_label_snapshot = excluded.speaker_label_snapshot`
|
||||||
|
).run(
|
||||||
|
session.transcriptSessionId,
|
||||||
|
guildId,
|
||||||
|
session.campaignId,
|
||||||
|
dbUserId,
|
||||||
|
userId,
|
||||||
|
userName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newChannel && oldChannel?.id === session.voiceChannelId) {
|
||||||
|
session.participants.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newChannel && newChannel.id !== session.voiceChannelId && oldChannel?.id === session.voiceChannelId) {
|
||||||
|
session.participants.delete(userId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getParticipants(guildId: number): VoiceParticipant[] {
|
||||||
|
const session = guildSessions.get(guildId);
|
||||||
|
if (!session) return [];
|
||||||
|
return Array.from(session.participants.values());
|
||||||
|
}
|
||||||
|
};
|
||||||
8
apps/server/src/types.ts
Normal file
8
apps/server/src/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export type Role = "player" | "dm" | "admin" | "pending_dm";
|
||||||
|
|
||||||
|
export interface AuthContext {
|
||||||
|
userId: number;
|
||||||
|
discordUserId: string;
|
||||||
|
guildId: number;
|
||||||
|
}
|
||||||
|
|
||||||
10
apps/server/test/gameNightService.test.ts
Normal file
10
apps/server/test/gameNightService.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { gameNightService } from "../src/services/gameNightService.js";
|
||||||
|
|
||||||
|
describe("gameNightService", () => {
|
||||||
|
it("exposes required scheduler methods", () => {
|
||||||
|
expect(typeof gameNightService.getOrCreateForDate).toBe("function");
|
||||||
|
expect(typeof gameNightService.selectCampaignForDate).toBe("function");
|
||||||
|
expect(typeof gameNightService.runMorningNotification).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
9
apps/server/test/permissionService.test.ts
Normal file
9
apps/server/test/permissionService.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { permissionService } from "../src/services/permissionService.js";
|
||||||
|
|
||||||
|
describe("permissionService", () => {
|
||||||
|
it("exposes role-check function", () => {
|
||||||
|
expect(typeof permissionService.hasRole).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
12
apps/server/test/sessionService.test.ts
Normal file
12
apps/server/test/sessionService.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sessionService } from "../src/services/sessionService.js";
|
||||||
|
|
||||||
|
describe("sessionService", () => {
|
||||||
|
it("exposes transcript session methods", () => {
|
||||||
|
expect(typeof sessionService.startSession).toBe("function");
|
||||||
|
expect(typeof sessionService.stopSession).toBe("function");
|
||||||
|
expect(typeof sessionService.appendSegment).toBe("function");
|
||||||
|
expect(typeof sessionService.getTranscript).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
97
apps/server/test/voiceTranscription.test.ts
Normal file
97
apps/server/test/voiceTranscription.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { voiceMonitorService } from "../src/services/voiceMonitorService.js";
|
||||||
|
import { transcriptionService } from "../src/services/transcriptionService.js";
|
||||||
|
|
||||||
|
describe("Voice Transcription", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clean up any existing sessions
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up after tests
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("voiceMonitorService", () => {
|
||||||
|
it("should create and retrieve active voice session", () => {
|
||||||
|
const guildId = 1;
|
||||||
|
const voiceChannelId = "voice-channel-123";
|
||||||
|
const transcriptSessionId = 1;
|
||||||
|
const campaignId = 1;
|
||||||
|
|
||||||
|
const session = voiceMonitorService.startVoiceSession(
|
||||||
|
guildId,
|
||||||
|
voiceChannelId,
|
||||||
|
transcriptSessionId,
|
||||||
|
campaignId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(session).toBeDefined();
|
||||||
|
expect(session.guildId).toBe(guildId);
|
||||||
|
expect(session.voiceChannelId).toBe(voiceChannelId);
|
||||||
|
expect(session.transcriptSessionId).toBe(transcriptSessionId);
|
||||||
|
|
||||||
|
const retrieved = voiceMonitorService.getActiveVoiceSession(guildId);
|
||||||
|
expect(retrieved).not.toBeNull();
|
||||||
|
expect(retrieved?.voiceChannelId).toBe(voiceChannelId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when starting duplicate session", () => {
|
||||||
|
const guildId = 2;
|
||||||
|
const voiceChannelId = "voice-channel-456";
|
||||||
|
const transcriptSessionId = 2;
|
||||||
|
const campaignId = 1;
|
||||||
|
|
||||||
|
voiceMonitorService.startVoiceSession(guildId, voiceChannelId, transcriptSessionId, campaignId);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
voiceMonitorService.startVoiceSession(guildId, voiceChannelId, transcriptSessionId, campaignId);
|
||||||
|
}).toThrow("Voice session already active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stop voice session", () => {
|
||||||
|
const guildId = 3;
|
||||||
|
const voiceChannelId = "voice-channel-789";
|
||||||
|
const transcriptSessionId = 3;
|
||||||
|
const campaignId = 1;
|
||||||
|
|
||||||
|
voiceMonitorService.startVoiceSession(guildId, voiceChannelId, transcriptSessionId, campaignId);
|
||||||
|
|
||||||
|
const stopped = voiceMonitorService.stopVoiceSession(guildId);
|
||||||
|
|
||||||
|
expect(stopped).not.toBeNull();
|
||||||
|
expect(stopped?.voiceChannelId).toBe(voiceChannelId);
|
||||||
|
|
||||||
|
const retrieved = voiceMonitorService.getActiveVoiceSession(guildId);
|
||||||
|
expect(retrieved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty participants list when no participants", () => {
|
||||||
|
const participants = voiceMonitorService.getParticipants(999);
|
||||||
|
expect(participants).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transcriptionService", () => {
|
||||||
|
it("should clear user buffer without error", () => {
|
||||||
|
expect(() => {
|
||||||
|
transcriptionService.clearUserBuffer("test-user-123");
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty audio buffer", async () => {
|
||||||
|
const emptyBuffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
await expect(transcriptionService.transcribeAudioFile(emptyBuffer))
|
||||||
|
.rejects
|
||||||
|
.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail transcription without API key", async () => {
|
||||||
|
const audioBuffer = Buffer.from([0x00, 0x01, 0x02]);
|
||||||
|
|
||||||
|
await expect(transcriptionService.transcribeAudioFile(audioBuffer))
|
||||||
|
.rejects
|
||||||
|
.toThrow(/OpenAI API key not configured/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
14
apps/server/tsconfig.json
Normal file
14
apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
15
apps/web/index.html
Normal file
15
apps/web/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Campaign Hub</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700&family=Cinzel:wght@400;600;700&family=Crimson+Pro:ital,wght@0,400;0,600;1,400&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
apps/web/package.json
Normal file
26
apps/web/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "@dnd-hub/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"vitest": "^3.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/web/src/App.js
Normal file
80
apps/web/src/App.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import { api } from "./api/client";
|
||||||
|
import { CharacterSheetDrawer } from "./components/CharacterSheetDrawer";
|
||||||
|
import { SrdDrawer } from "./components/SrdDrawer";
|
||||||
|
import { Toast } from "./components/Toast";
|
||||||
|
import { CharacterSheetProvider } from "./contexts/CharacterSheetContext";
|
||||||
|
import { DebugProvider, useDebugMode } from "./contexts/DebugContext";
|
||||||
|
import { ToastProvider, useToast } from "./contexts/ToastContext";
|
||||||
|
import { AdminPage } from "./pages/AdminPage";
|
||||||
|
import { CallbackPage } from "./pages/CallbackPage";
|
||||||
|
import { CampaignDetailPage } from "./pages/CampaignDetailPage";
|
||||||
|
import { CampaignsPage } from "./pages/CampaignsPage";
|
||||||
|
import { CharacterPage } from "./pages/CharacterPage";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
// ─── SVG icon components ───────────────────────────────────────────────────────
|
||||||
|
function IconHome() {
|
||||||
|
return (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" }), _jsx("polyline", { points: "9,22 9,12 15,12 15,22" })] }));
|
||||||
|
}
|
||||||
|
function IconScroll() {
|
||||||
|
return (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }), _jsx("polyline", { points: "14,2 14,8 20,8" }), _jsx("line", { x1: "16", y1: "13", x2: "8", y2: "13" }), _jsx("line", { x1: "16", y1: "17", x2: "8", y2: "17" }), _jsx("polyline", { points: "10,9 9,9 8,9" })] }));
|
||||||
|
}
|
||||||
|
function IconSettings() {
|
||||||
|
return (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "12", cy: "12", r: "3" }), _jsx("path", { d: "M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" })] }));
|
||||||
|
}
|
||||||
|
function IconLogout() {
|
||||||
|
return (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" }), _jsx("polyline", { points: "16,17 21,12 16,7" }), _jsx("line", { x1: "21", y1: "12", x2: "9", y2: "12" })] }));
|
||||||
|
}
|
||||||
|
// ─── Nav item ─────────────────────────────────────────────────────────────────
|
||||||
|
function NavItem({ to, icon, label, active }) {
|
||||||
|
return (_jsxs(Link, { to: to, className: `nav-item${active ? " active" : ""}`, children: [icon, label] }));
|
||||||
|
}
|
||||||
|
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
|
function Sidebar({ me, onLogout }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const { debugMode, toggle } = useDebugMode();
|
||||||
|
// Real role from server — never affected by debug mode
|
||||||
|
const realRole = me?.role;
|
||||||
|
// Effective role shown in the UI
|
||||||
|
const effectiveRole = debugMode ? "player" : realRole;
|
||||||
|
// Show the debug toggle when the real role is elevated OR debug is already on.
|
||||||
|
// This prevents lock-out: even with debug on, the button stays visible.
|
||||||
|
const showDebugToggle = realRole === "admin" || realRole === "dm" || debugMode;
|
||||||
|
return (_jsxs("aside", { className: "sidebar", children: [_jsxs("div", { className: "sidebar-logo", children: [_jsx("div", { className: "sidebar-logo-mark", children: "\u2694\uFE0F" }), _jsxs("div", { className: "sidebar-logo-text", children: [_jsx("div", { className: "sidebar-logo-title", children: "Campaign Hub" }), _jsx("div", { className: "sidebar-logo-sub", children: "D&D Session Manager" })] })] }), _jsxs("nav", { className: "sidebar-nav", children: [_jsx(NavItem, { to: "/dashboard", icon: _jsx(IconHome, {}), label: "Dashboard", active: location.pathname === "/dashboard" }), _jsx(NavItem, { to: "/campaigns", icon: _jsx(IconScroll, {}), label: "Campaigns", active: location.pathname.startsWith("/campaigns") }), (realRole === "admin" || realRole === "dm") && !debugMode && (_jsx(NavItem, { to: "/admin", icon: _jsx(IconSettings, {}), label: "Admin", active: location.pathname === "/admin" }))] }), showDebugToggle && (_jsxs("button", { className: `debug-toggle${debugMode ? " active" : ""}`, onClick: toggle, title: debugMode ? "Debug mode ON — click to restore your role" : "Toggle player debug mode", children: [_jsx("span", { className: "debug-toggle-dot" }), _jsx("span", { className: "debug-toggle-label", children: debugMode ? "Player view (debug)" : "Debug: Player View" })] })), _jsxs("div", { className: "sidebar-footer", children: [_jsx("div", { className: "user-avatar-placeholder", children: me?.user?.username?.[0]?.toUpperCase() ?? "?" }), _jsxs("div", { className: "user-info", children: [_jsx("div", { className: "user-name", children: me?.user.username ?? "Loading…" }), _jsxs("div", { className: "user-role", children: [effectiveRole ?? "player", debugMode && _jsxs("span", { className: "debug-role-tag", children: [" (real: ", realRole, ")"] })] })] }), _jsx("button", { className: "logout-btn", onClick: onLogout, title: "Sign out", children: _jsx(IconLogout, {}) })] })] }));
|
||||||
|
}
|
||||||
|
// ─── Protected route ──────────────────────────────────────────────────────────
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
if (!localStorage.getItem("token"))
|
||||||
|
return _jsx(Navigate, { to: "/login", replace: true });
|
||||||
|
return _jsx(_Fragment, { children: children });
|
||||||
|
}
|
||||||
|
// ─── Auth-only routes (no sidebar) ───────────────────────────────────────────
|
||||||
|
const AUTH_ROUTES = ["/login", "/callback"];
|
||||||
|
// ─── App root ─────────────────────────────────────────────────────────────────
|
||||||
|
function AppInner() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [me, setMe] = useState(null);
|
||||||
|
const { toasts, removeToast, addToast } = useToast();
|
||||||
|
const { debugMode } = useDebugMode();
|
||||||
|
const isAuthRoute = AUTH_ROUTES.includes(location.pathname);
|
||||||
|
const hasToken = Boolean(localStorage.getItem("token"));
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasToken && !isAuthRoute) {
|
||||||
|
api("/me").then(setMe).catch(() => { });
|
||||||
|
}
|
||||||
|
}, [hasToken, isAuthRoute]);
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
if (isAuthRoute || !hasToken) {
|
||||||
|
return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: _jsx(LoginPage, {}) }), _jsx(Route, { path: "/callback", element: _jsx(CallbackPage, {}) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/login", replace: true }) })] }));
|
||||||
|
}
|
||||||
|
return (_jsxs("div", { className: "layout", children: [_jsx(Sidebar, { me: me, onLogout: logout }), _jsxs("div", { className: "layout-body", children: [_jsx(CharacterSheetDrawer, {}), _jsxs("main", { className: "main", children: [_jsx(Toast, { toasts: toasts, removeToast: removeToast }), debugMode && (_jsxs("div", { style: { position: "fixed", bottom: "20px", right: "20px", zIndex: 999, display: "flex", gap: "8px" }, children: [_jsx("button", { className: "btn btn-primary btn-sm", onClick: () => addToast("Test success toast", "success"), children: "Toast \u2705" }), _jsx("button", { className: "btn btn-primary btn-sm", onClick: () => addToast("Test error toast", "error"), children: "Toast \u274C" })] })), _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/dashboard", replace: true }) }), _jsx(Route, { path: "/dashboard", element: _jsx(ProtectedRoute, { children: _jsx(DashboardPage, {}) }) }), _jsx(Route, { path: "/campaigns", element: _jsx(ProtectedRoute, { children: _jsx(CampaignsPage, {}) }) }), _jsx(Route, { path: "/campaigns/:id", element: _jsx(ProtectedRoute, { children: _jsx(CampaignDetailPage, {}) }) }), _jsx(Route, { path: "/campaigns/:id/characters/:charId", element: _jsx(ProtectedRoute, { children: _jsx(CharacterPage, {}) }) }), _jsx(Route, { path: "/admin", element: _jsx(ProtectedRoute, { children: _jsx(AdminPage, {}) }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/dashboard", replace: true }) })] })] }), _jsx(SrdDrawer, {})] })] }));
|
||||||
|
}
|
||||||
|
export function App() {
|
||||||
|
return (_jsx(ToastProvider, { children: _jsx(DebugProvider, { children: _jsx(CharacterSheetProvider, { children: _jsx(AppInner, {}) }) }) }));
|
||||||
|
}
|
||||||
237
apps/web/src/App.tsx
Normal file
237
apps/web/src/App.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import { api } from "./api/client";
|
||||||
|
import { CharacterSheetDrawer } from "./components/CharacterSheetDrawer";
|
||||||
|
import { SrdDrawer } from "./components/SrdDrawer";
|
||||||
|
import { Toast } from "./components/Toast";
|
||||||
|
import { CharacterSheetProvider } from "./contexts/CharacterSheetContext";
|
||||||
|
import { DebugProvider, useDebugMode } from "./contexts/DebugContext";
|
||||||
|
import { ToastProvider, useToast } from "./contexts/ToastContext";
|
||||||
|
import { AdminPage } from "./pages/AdminPage";
|
||||||
|
import { CallbackPage } from "./pages/CallbackPage";
|
||||||
|
import { CampaignDetailPage } from "./pages/CampaignDetailPage";
|
||||||
|
import { CampaignsPage } from "./pages/CampaignsPage";
|
||||||
|
import { CharacterPage } from "./pages/CharacterPage";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
|
||||||
|
interface Me {
|
||||||
|
user: { id: number; username: string; avatar_url: string | null; discord_user_id: string };
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SVG icon components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function IconHome() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||||
|
<polyline points="9,22 9,12 15,12 15,22" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconScroll() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10,9 9,9 8,9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconSettings() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconLogout() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
|
||||||
|
<polyline points="16,17 21,12 16,7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nav item ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NavItem({ to, icon, label, active }: { to: string; icon: React.ReactNode; label: string; active: boolean }) {
|
||||||
|
return (
|
||||||
|
<Link to={to} className={`nav-item${active ? " active" : ""}`}>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Sidebar({ me, onLogout }: { me: Me | null; onLogout: () => void }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const { debugMode, toggle } = useDebugMode();
|
||||||
|
|
||||||
|
// Real role from server — never affected by debug mode
|
||||||
|
const realRole = me?.role;
|
||||||
|
// Effective role shown in the UI
|
||||||
|
const effectiveRole = debugMode ? "player" : realRole;
|
||||||
|
|
||||||
|
// Show the debug toggle when the real role is elevated OR debug is already on.
|
||||||
|
// This prevents lock-out: even with debug on, the button stays visible.
|
||||||
|
const showDebugToggle = realRole === "admin" || realRole === "dm" || debugMode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-logo">
|
||||||
|
<div className="sidebar-logo-mark">⚔️</div>
|
||||||
|
<div className="sidebar-logo-text">
|
||||||
|
<div className="sidebar-logo-title">Campaign Hub</div>
|
||||||
|
<div className="sidebar-logo-sub">D&D Session Manager</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<NavItem
|
||||||
|
to="/dashboard"
|
||||||
|
icon={<IconHome />}
|
||||||
|
label="Dashboard"
|
||||||
|
active={location.pathname === "/dashboard"}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/campaigns"
|
||||||
|
icon={<IconScroll />}
|
||||||
|
label="Campaigns"
|
||||||
|
active={location.pathname.startsWith("/campaigns")}
|
||||||
|
/>
|
||||||
|
{(realRole === "admin" || realRole === "dm") && !debugMode && (
|
||||||
|
<NavItem
|
||||||
|
to="/admin"
|
||||||
|
icon={<IconSettings />}
|
||||||
|
label="Admin"
|
||||||
|
active={location.pathname === "/admin"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{showDebugToggle && (
|
||||||
|
<button
|
||||||
|
className={`debug-toggle${debugMode ? " active" : ""}`}
|
||||||
|
onClick={toggle}
|
||||||
|
title={debugMode ? "Debug mode ON — click to restore your role" : "Toggle player debug mode"}
|
||||||
|
>
|
||||||
|
<span className="debug-toggle-dot" />
|
||||||
|
<span className="debug-toggle-label">
|
||||||
|
{debugMode ? "Player view (debug)" : "Debug: Player View"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<div className="user-avatar-placeholder">
|
||||||
|
{me?.user?.username?.[0]?.toUpperCase() ?? "?"}
|
||||||
|
</div>
|
||||||
|
<div className="user-info">
|
||||||
|
<div className="user-name">{me?.user.username ?? "Loading…"}</div>
|
||||||
|
<div className="user-role">
|
||||||
|
{effectiveRole ?? "player"}
|
||||||
|
{debugMode && <span className="debug-role-tag"> (real: {realRole})</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="logout-btn" onClick={onLogout} title="Sign out">
|
||||||
|
<IconLogout />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Protected route ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
if (!localStorage.getItem("token")) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth-only routes (no sidebar) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const AUTH_ROUTES = ["/login", "/callback"];
|
||||||
|
|
||||||
|
// ─── App root ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AppInner() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const { toasts, removeToast, addToast } = useToast();
|
||||||
|
const { debugMode } = useDebugMode();
|
||||||
|
const isAuthRoute = AUTH_ROUTES.includes(location.pathname);
|
||||||
|
const hasToken = Boolean(localStorage.getItem("token"));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasToken && !isAuthRoute) {
|
||||||
|
api<Me>("/me").then(setMe).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [hasToken, isAuthRoute]);
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthRoute || !hasToken) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<Sidebar me={me} onLogout={logout} />
|
||||||
|
<div className="layout-body">
|
||||||
|
<CharacterSheetDrawer />
|
||||||
|
<main className="main">
|
||||||
|
<Toast toasts={toasts} removeToast={removeToast} />
|
||||||
|
{debugMode && (
|
||||||
|
<div style={{ position: "fixed", bottom: "28px", right: "calc(var(--srd-total-w) + 20px)", zIndex: 999, display: "flex", gap: "8px" }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => addToast("Test success toast", "success")}>Toast ✅</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => addToast("Test error toast", "error")}>Toast ❌</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/campaigns" element={<ProtectedRoute><CampaignsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/campaigns/:id" element={<ProtectedRoute><CampaignDetailPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/campaigns/:id/characters/:charId" element={<ProtectedRoute><CharacterPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<SrdDrawer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<DebugProvider>
|
||||||
|
<CharacterSheetProvider>
|
||||||
|
<AppInner />
|
||||||
|
</CharacterSheetProvider>
|
||||||
|
</DebugProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/api/client.js
Normal file
24
apps/web/src/api/client.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:4000";
|
||||||
|
function token() {
|
||||||
|
return localStorage.getItem("token");
|
||||||
|
}
|
||||||
|
export async function api(path, init) {
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers
|
||||||
|
};
|
||||||
|
if (token()) {
|
||||||
|
headers.Authorization = `Bearer ${token()}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`${res.status}: ${body}`);
|
||||||
|
}
|
||||||
|
return (await res.json());
|
||||||
|
}
|
||||||
26
apps/web/src/api/client.ts
Normal file
26
apps/web/src/api/client.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:4000";
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
return localStorage.getItem("token");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers as Record<string, string> | undefined)
|
||||||
|
};
|
||||||
|
if (token()) {
|
||||||
|
headers.Authorization = `Bearer ${token()}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`${res.status}: ${body}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
83
apps/web/src/components/CharacterSheetDrawer.js
Normal file
83
apps/web/src/components/CharacterSheetDrawer.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useCharacterSheet } from "../contexts/CharacterSheetContext";
|
||||||
|
const ABILITIES = [
|
||||||
|
{ key: "str", label: "STR" },
|
||||||
|
{ key: "dex", label: "DEX" },
|
||||||
|
{ key: "con", label: "CON" },
|
||||||
|
{ key: "int", label: "INT" },
|
||||||
|
{ key: "wis", label: "WIS" },
|
||||||
|
{ key: "cha", label: "CHA" },
|
||||||
|
];
|
||||||
|
function abilityMod(score) {
|
||||||
|
const m = Math.floor((score - 10) / 2);
|
||||||
|
return m >= 0 ? `+${m}` : `${m}`;
|
||||||
|
}
|
||||||
|
const DEFAULT_WIDTH = 340;
|
||||||
|
const MIN_WIDTH = 220;
|
||||||
|
const MAX_WIDTH = 580;
|
||||||
|
export function CharacterSheetDrawer() {
|
||||||
|
const { open, toggle } = useCharacterSheet();
|
||||||
|
const [view, setView] = useState("list");
|
||||||
|
const [characters, setCharacters] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
// Resize state
|
||||||
|
const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH);
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startWidth = useRef(DEFAULT_WIDTH);
|
||||||
|
// Keep --cs-total-w CSS var in sync (tab 28px + panel when open)
|
||||||
|
useEffect(() => {
|
||||||
|
const w = open ? 28 + panelWidth : 28;
|
||||||
|
document.documentElement.style.setProperty("--cs-total-w", `${w}px`);
|
||||||
|
}, [open, panelWidth]);
|
||||||
|
// Fetch characters when drawer opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || characters.length > 0)
|
||||||
|
return;
|
||||||
|
setLoading(true);
|
||||||
|
api("/characters/mine")
|
||||||
|
.then(setCharacters)
|
||||||
|
.catch(() => { })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open]);
|
||||||
|
const onResizeStart = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startX.current = e.clientX;
|
||||||
|
startWidth.current = panelWidth;
|
||||||
|
setResizing(true);
|
||||||
|
function onMove(e) {
|
||||||
|
const delta = e.clientX - startX.current;
|
||||||
|
setPanelWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta)));
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
setResizing(false);
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}, [panelWidth]);
|
||||||
|
function selectCharacter(char) {
|
||||||
|
setSelected(char);
|
||||||
|
setView("sheet");
|
||||||
|
}
|
||||||
|
const stats = (() => {
|
||||||
|
if (!selected?.stats_json)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(selected.stats_json);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return (_jsxs("div", { className: "cs-drawer-wrap", children: [_jsxs("div", { className: `cs-panel${open ? " open" : ""}${resizing ? " resizing" : ""}`, style: open ? { width: panelWidth } : undefined, children: [view === "list" ? (
|
||||||
|
/* ── List view ── */
|
||||||
|
_jsxs("div", { className: "cs-sheet", children: [_jsx("div", { className: "cs-header", children: _jsxs("div", { className: "cs-header-info", style: { flex: 1 }, children: [_jsx("div", { className: "cs-char-name", style: { fontSize: "13px" }, children: "My Characters" }), _jsx("div", { className: "cs-char-sub", children: "Across all campaigns" })] }) }), _jsxs("div", { className: "cs-body", children: [loading && _jsx("div", { className: "cs-no-stats", children: "Loading\u2026" }), !loading && characters.length === 0 && (_jsxs("div", { className: "cs-empty", children: [_jsx("div", { className: "cs-empty-icon", children: "\u2694\uFE0F" }), _jsx("div", { className: "cs-empty-text", children: "No characters yet. Import or create one from a campaign's Characters tab." })] })), characters.map(char => (_jsxs("div", { className: "cs-list-item", onClick: () => selectCharacter(char), children: [char.portrait_url ? (_jsx("img", { src: char.portrait_url, className: "cs-list-portrait", alt: char.character_name })) : (_jsx("div", { className: "cs-list-portrait cs-portrait-fallback", style: { fontSize: "16px" }, children: char.character_name[0]?.toUpperCase() })), _jsxs("div", { className: "cs-list-info", children: [_jsx("div", { className: "cs-list-name", children: char.character_name }), _jsx("div", { className: "cs-list-sub", children: [char.class, char.race, char.level ? `Lv ${char.level}` : null].filter(Boolean).join(" · ") }), _jsx("div", { className: "cs-list-campaign", children: char.campaign_name })] }), _jsx("span", { className: "cs-list-chevron", children: "\u203A" })] }, char.id)))] })] })) : selected ? (
|
||||||
|
/* ── Sheet view ── */
|
||||||
|
_jsxs("div", { className: "cs-sheet", children: [_jsxs("div", { className: "cs-header", children: [selected.portrait_url ? (_jsx("img", { src: selected.portrait_url, className: "cs-portrait", alt: selected.character_name })) : (_jsx("div", { className: "cs-portrait cs-portrait-fallback", children: selected.character_name[0]?.toUpperCase() })), _jsxs("div", { className: "cs-header-info", children: [_jsx("button", { className: "cs-back-btn", onClick: () => setView("list"), children: "\u2039 All Characters" }), _jsx("div", { className: "cs-char-name", children: selected.character_name }), _jsxs("div", { className: "cs-char-sub", children: [[selected.class, selected.race].filter(Boolean).join(" · "), selected.level ? ` · Level ${selected.level}` : ""] }), selected.alignment && _jsx("span", { className: "cs-alignment-badge", children: selected.alignment })] })] }), _jsxs("div", { className: "cs-body", children: [stats && (_jsxs("div", { className: "cs-quick-stats", children: [stats.maxHp != null && (_jsxs("div", { className: "cs-quick-stat", children: [_jsx("div", { className: "cs-quick-stat-value", children: stats.maxHp }), _jsx("div", { className: "cs-quick-stat-label", children: "Max HP" })] })), _jsxs("div", { className: "cs-quick-stat", children: [_jsxs("div", { className: "cs-quick-stat-value", children: ["+", stats.proficiencyBonus] }), _jsx("div", { className: "cs-quick-stat-label", children: "Prof." })] }), _jsxs("div", { className: "cs-quick-stat", children: [_jsx("div", { className: "cs-quick-stat-value", children: abilityMod(stats.dex) }), _jsx("div", { className: "cs-quick-stat-label", children: "Init." })] })] })), stats ? (_jsxs("div", { className: "cs-section", children: [_jsx("div", { className: "cs-section-label", children: "Ability Scores" }), _jsx("div", { className: "cs-ability-grid", children: ABILITIES.map(({ key, label }) => (_jsxs("div", { className: "cs-ability-cell", children: [_jsx("div", { className: "cs-ability-label", children: label }), _jsx("div", { className: "cs-ability-score", children: stats[key] }), _jsx("div", { className: "cs-ability-mod", children: abilityMod(stats[key]) })] }, key))) })] })) : (_jsx("div", { className: "cs-section", children: _jsx("div", { className: "cs-no-stats", children: selected.dndbeyond_id ? "Sync from D&D Beyond to load full stats." : "No ability scores on record." }) })), selected.bio && (_jsxs("div", { className: "cs-section", children: [_jsx("div", { className: "cs-section-label", children: "Biography" }), _jsx("p", { className: "cs-bio", children: selected.bio })] })), _jsx(Link, { to: `/campaigns/${selected.campaign_id}/characters/${selected.id}`, className: "cs-ddb-link", children: "Full character sheet \u2192" }), selected.dndbeyond_id && (_jsx("a", { className: "cs-ddb-link", href: `https://www.dndbeyond.com/characters/${selected.dndbeyond_id}`, target: "_blank", rel: "noopener noreferrer", children: "View on D&D Beyond \u2192" }))] })] })) : null, open && (_jsx("div", { className: "cs-resize-handle", onMouseDown: onResizeStart }))] }), _jsxs("button", { className: `drawer-tab cs-tab${open ? " open" : ""}`, onClick: toggle, title: open ? "Close character sheet" : "My characters", "aria-label": "Toggle character sheet drawer", children: [_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }), _jsx("polyline", { points: "14 2 14 8 20 8" }), _jsx("line", { x1: "16", y1: "13", x2: "8", y2: "13" }), _jsx("line", { x1: "16", y1: "17", x2: "8", y2: "17" }), _jsx("line", { x1: "10", y1: "9", x2: "8", y2: "9" })] }), _jsx("span", { className: "drawer-tab-label", children: "SHEET" })] })] }));
|
||||||
|
}
|
||||||
263
apps/web/src/components/CharacterSheetDrawer.tsx
Normal file
263
apps/web/src/components/CharacterSheetDrawer.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useCharacterSheet } from "../contexts/CharacterSheetContext";
|
||||||
|
|
||||||
|
interface MyCharacter {
|
||||||
|
id: number;
|
||||||
|
character_name: string;
|
||||||
|
class: string | null;
|
||||||
|
race: string | null;
|
||||||
|
level: number;
|
||||||
|
alignment: string | null;
|
||||||
|
portrait_url: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
stats_json: string | null;
|
||||||
|
dndbeyond_id: string | null;
|
||||||
|
campaign_name: string;
|
||||||
|
campaign_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharStats {
|
||||||
|
str: number; dex: number; con: number;
|
||||||
|
int: number; wis: number; cha: number;
|
||||||
|
maxHp: number | null;
|
||||||
|
proficiencyBonus: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABILITIES = [
|
||||||
|
{ key: "str" as const, label: "STR" },
|
||||||
|
{ key: "dex" as const, label: "DEX" },
|
||||||
|
{ key: "con" as const, label: "CON" },
|
||||||
|
{ key: "int" as const, label: "INT" },
|
||||||
|
{ key: "wis" as const, label: "WIS" },
|
||||||
|
{ key: "cha" as const, label: "CHA" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function abilityMod(score: number): string {
|
||||||
|
const m = Math.floor((score - 10) / 2);
|
||||||
|
return m >= 0 ? `+${m}` : `${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WIDTH = 340;
|
||||||
|
const MIN_WIDTH = 220;
|
||||||
|
const MAX_WIDTH = 580;
|
||||||
|
|
||||||
|
export function CharacterSheetDrawer() {
|
||||||
|
const { open, toggle } = useCharacterSheet();
|
||||||
|
|
||||||
|
const [view, setView] = useState<"list" | "sheet">("list");
|
||||||
|
const [characters, setCharacters] = useState<MyCharacter[]>([]);
|
||||||
|
const [selected, setSelected] = useState<MyCharacter | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Resize state
|
||||||
|
const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH);
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startWidth = useRef(DEFAULT_WIDTH);
|
||||||
|
|
||||||
|
// Keep --cs-total-w CSS var in sync (tab 28px + panel when open)
|
||||||
|
useEffect(() => {
|
||||||
|
const w = open ? 28 + panelWidth : 28;
|
||||||
|
document.documentElement.style.setProperty("--cs-total-w", `${w}px`);
|
||||||
|
}, [open, panelWidth]);
|
||||||
|
|
||||||
|
// Fetch characters when drawer opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || characters.length > 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
api<MyCharacter[]>("/characters/mine")
|
||||||
|
.then(setCharacters)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const onResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startX.current = e.clientX;
|
||||||
|
startWidth.current = panelWidth;
|
||||||
|
setResizing(true);
|
||||||
|
|
||||||
|
function onMove(e: MouseEvent) {
|
||||||
|
const delta = e.clientX - startX.current;
|
||||||
|
setPanelWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta)));
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
setResizing(false);
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}, [panelWidth]);
|
||||||
|
|
||||||
|
function selectCharacter(char: MyCharacter) {
|
||||||
|
setSelected(char);
|
||||||
|
setView("sheet");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: CharStats | null = (() => {
|
||||||
|
if (!selected?.stats_json) return null;
|
||||||
|
try { return JSON.parse(selected.stats_json) as CharStats; } catch { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cs-drawer-wrap">
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
className={`cs-panel${open ? " open" : ""}${resizing ? " resizing" : ""}`}
|
||||||
|
style={open ? { width: panelWidth } : undefined}
|
||||||
|
>
|
||||||
|
{view === "list" ? (
|
||||||
|
/* ── List view ── */
|
||||||
|
<div className="cs-sheet">
|
||||||
|
<div className="cs-header">
|
||||||
|
<div className="cs-header-info" style={{ flex: 1 }}>
|
||||||
|
<div className="cs-char-name" style={{ fontSize: "13px" }}>My Characters</div>
|
||||||
|
<div className="cs-char-sub">Across all campaigns</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cs-body">
|
||||||
|
{loading && <div className="cs-no-stats">Loading…</div>}
|
||||||
|
{!loading && characters.length === 0 && (
|
||||||
|
<div className="cs-empty">
|
||||||
|
<div className="cs-empty-icon">⚔️</div>
|
||||||
|
<div className="cs-empty-text">No characters yet. Import or create one from a campaign's Characters tab.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{characters.map(char => (
|
||||||
|
<div key={char.id} className="cs-list-item" onClick={() => selectCharacter(char)}>
|
||||||
|
{char.portrait_url ? (
|
||||||
|
<img src={char.portrait_url} className="cs-list-portrait" alt={char.character_name} />
|
||||||
|
) : (
|
||||||
|
<div className="cs-list-portrait cs-portrait-fallback" style={{ fontSize: "16px" }}>
|
||||||
|
{char.character_name[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="cs-list-info">
|
||||||
|
<div className="cs-list-name">{char.character_name}</div>
|
||||||
|
<div className="cs-list-sub">{[char.class, char.race, char.level ? `Lv ${char.level}` : null].filter(Boolean).join(" · ")}</div>
|
||||||
|
<div className="cs-list-campaign">{char.campaign_name}</div>
|
||||||
|
</div>
|
||||||
|
<span className="cs-list-chevron">›</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selected ? (
|
||||||
|
/* ── Sheet view ── */
|
||||||
|
<div className="cs-sheet">
|
||||||
|
<div className="cs-header">
|
||||||
|
{selected.portrait_url ? (
|
||||||
|
<img src={selected.portrait_url} className="cs-portrait" alt={selected.character_name} />
|
||||||
|
) : (
|
||||||
|
<div className="cs-portrait cs-portrait-fallback">
|
||||||
|
{selected.character_name[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="cs-header-info">
|
||||||
|
<button className="cs-back-btn" onClick={() => setView("list")}>‹ All Characters</button>
|
||||||
|
<div className="cs-char-name">{selected.character_name}</div>
|
||||||
|
<div className="cs-char-sub">
|
||||||
|
{[selected.class, selected.race].filter(Boolean).join(" · ")}
|
||||||
|
{selected.level ? ` · Level ${selected.level}` : ""}
|
||||||
|
</div>
|
||||||
|
{selected.alignment && <span className="cs-alignment-badge">{selected.alignment}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cs-body">
|
||||||
|
{stats && (
|
||||||
|
<div className="cs-quick-stats">
|
||||||
|
{stats.maxHp != null && (
|
||||||
|
<div className="cs-quick-stat">
|
||||||
|
<div className="cs-quick-stat-value">{stats.maxHp}</div>
|
||||||
|
<div className="cs-quick-stat-label">Max HP</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="cs-quick-stat">
|
||||||
|
<div className="cs-quick-stat-value">+{stats.proficiencyBonus}</div>
|
||||||
|
<div className="cs-quick-stat-label">Prof.</div>
|
||||||
|
</div>
|
||||||
|
<div className="cs-quick-stat">
|
||||||
|
<div className="cs-quick-stat-value">{abilityMod(stats.dex)}</div>
|
||||||
|
<div className="cs-quick-stat-label">Init.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats ? (
|
||||||
|
<div className="cs-section">
|
||||||
|
<div className="cs-section-label">Ability Scores</div>
|
||||||
|
<div className="cs-ability-grid">
|
||||||
|
{ABILITIES.map(({ key, label }) => (
|
||||||
|
<div key={key} className="cs-ability-cell">
|
||||||
|
<div className="cs-ability-label">{label}</div>
|
||||||
|
<div className="cs-ability-score">{stats[key]}</div>
|
||||||
|
<div className="cs-ability-mod">{abilityMod(stats[key])}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="cs-section">
|
||||||
|
<div className="cs-no-stats">
|
||||||
|
{selected.dndbeyond_id ? "Sync from D&D Beyond to load full stats." : "No ability scores on record."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.bio && (
|
||||||
|
<div className="cs-section">
|
||||||
|
<div className="cs-section-label">Biography</div>
|
||||||
|
<p className="cs-bio">{selected.bio}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/campaigns/${selected.campaign_id}/characters/${selected.id}`}
|
||||||
|
className="cs-ddb-link"
|
||||||
|
>
|
||||||
|
Full character sheet →
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{selected.dndbeyond_id && (
|
||||||
|
<a
|
||||||
|
className="cs-ddb-link"
|
||||||
|
href={`https://www.dndbeyond.com/characters/${selected.dndbeyond_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
View on D&D Beyond →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Resize handle — right edge of panel */}
|
||||||
|
{open && (
|
||||||
|
<div className="cs-resize-handle" onMouseDown={onResizeStart} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab strip */}
|
||||||
|
<button
|
||||||
|
className={`drawer-tab cs-tab${open ? " open" : ""}`}
|
||||||
|
onClick={toggle}
|
||||||
|
title={open ? "Close character sheet" : "My characters"}
|
||||||
|
aria-label="Toggle character sheet drawer"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<line x1="10" y1="9" x2="8" y2="9"/>
|
||||||
|
</svg>
|
||||||
|
<span className="drawer-tab-label">SHEET</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/web/src/components/SrdDrawer.js
Normal file
137
apps/web/src/components/SrdDrawer.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
const SRD_BASE = "https://www.dnd5eapi.co/api";
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ key: "spells", label: "Spells", icon: "✨" },
|
||||||
|
{ key: "monsters", label: "Monsters", icon: "🐉" },
|
||||||
|
{ key: "equipment", label: "Equipment", icon: "⚔️" },
|
||||||
|
{ key: "conditions", label: "Conditions", icon: "🩹" },
|
||||||
|
{ key: "magic-items", label: "Magic Items", icon: "💎" },
|
||||||
|
];
|
||||||
|
// ─── Detail renderers ─────────────────────────────────────────────────────────
|
||||||
|
function mod(score) {
|
||||||
|
const m = Math.floor((score - 10) / 2);
|
||||||
|
return m >= 0 ? `+${m}` : `${m}`;
|
||||||
|
}
|
||||||
|
function SpellView({ d }) {
|
||||||
|
const level = d.level === 0 ? "Cantrip" : `Level ${d.level}`;
|
||||||
|
const tags = [level, d.school.name, d.concentration ? "Concentration" : null, d.ritual ? "Ritual" : null].filter(Boolean);
|
||||||
|
return (_jsxs("div", { className: "srd-detail", children: [_jsx("div", { className: "srd-detail-name", children: d.name }), _jsx("div", { className: "srd-detail-tags", children: tags.map(t => _jsx("span", { className: "srd-tag", children: t }, t)) }), _jsxs("dl", { className: "srd-stat-grid", children: [_jsx("dt", { children: "Casting Time" }), _jsx("dd", { children: d.casting_time }), _jsx("dt", { children: "Range" }), _jsx("dd", { children: d.range }), _jsx("dt", { children: "Components" }), _jsx("dd", { children: d.components.join(", ") }), _jsx("dt", { children: "Duration" }), _jsx("dd", { children: d.duration }), _jsx("dt", { children: "Classes" }), _jsx("dd", { children: d.classes.map(c => c.name).join(", ") })] }), d.desc.map((p, i) => _jsx("p", { className: "srd-desc", children: p }, i)), d.higher_level?.length ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "srd-section-label", children: "At Higher Levels" }), d.higher_level.map((p, i) => _jsx("p", { className: "srd-desc", children: p }, i))] })) : null] }));
|
||||||
|
}
|
||||||
|
function MonsterView({ d }) {
|
||||||
|
const ac = d.armor_class?.[0];
|
||||||
|
const speeds = Object.entries(d.speed ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
||||||
|
return (_jsxs("div", { className: "srd-detail", children: [_jsx("div", { className: "srd-detail-name", children: d.name }), _jsxs("div", { className: "srd-detail-tags", children: [_jsxs("span", { className: "srd-tag", children: [d.size, " ", d.type] }), _jsxs("span", { className: "srd-tag", children: ["CR ", d.challenge_rating] }), _jsx("span", { className: "srd-tag", children: d.alignment })] }), _jsxs("dl", { className: "srd-stat-grid", children: [ac && _jsxs(_Fragment, { children: [_jsx("dt", { children: "AC" }), _jsxs("dd", { children: [ac.value, " (", ac.type, ")"] })] }), _jsx("dt", { children: "HP" }), _jsxs("dd", { children: [d.hit_points, " (", d.hit_dice, ")"] }), _jsx("dt", { children: "Speed" }), _jsx("dd", { children: speeds })] }), _jsx("div", { className: "srd-ability-row", children: [["STR", d.strength], ["DEX", d.dexterity], ["CON", d.constitution],
|
||||||
|
["INT", d.intelligence], ["WIS", d.wisdom], ["CHA", d.charisma]].map(([k, v]) => (_jsxs("div", { className: "srd-ability", children: [_jsx("div", { className: "srd-ability-label", children: k }), _jsx("div", { className: "srd-ability-score", children: v }), _jsx("div", { className: "srd-ability-mod", children: mod(v) })] }, k))) }), d.desc && _jsx("p", { className: "srd-desc", children: d.desc }), d.special_abilities?.slice(0, 3).map(a => (_jsxs("div", { className: "srd-ability-block", children: [_jsxs("span", { className: "srd-ability-block-name", children: [a.name, ". "] }), a.desc] }, a.name)))] }));
|
||||||
|
}
|
||||||
|
function EquipmentView({ d }) {
|
||||||
|
return (_jsxs("div", { className: "srd-detail", children: [_jsx("div", { className: "srd-detail-name", children: d.name }), _jsxs("div", { className: "srd-detail-tags", children: [_jsx("span", { className: "srd-tag", children: d.equipment_category.name }), d.properties?.map(p => _jsx("span", { className: "srd-tag", children: p.name }, p.name))] }), _jsxs("dl", { className: "srd-stat-grid", children: [_jsx("dt", { children: "Cost" }), _jsxs("dd", { children: [d.cost.quantity, " ", d.cost.unit] }), d.weight != null && _jsxs(_Fragment, { children: [_jsx("dt", { children: "Weight" }), _jsxs("dd", { children: [d.weight, " lb"] })] }), d.damage && _jsxs(_Fragment, { children: [_jsx("dt", { children: "Damage" }), _jsxs("dd", { children: [d.damage.damage_dice, " ", d.damage.damage_type.name] })] }), d.armor_class && _jsxs(_Fragment, { children: [_jsx("dt", { children: "AC" }), _jsx("dd", { children: d.armor_class.base })] })] }), d.desc?.map((p, i) => _jsx("p", { className: "srd-desc", children: p }, i))] }));
|
||||||
|
}
|
||||||
|
function ConditionView({ d }) {
|
||||||
|
return (_jsxs("div", { className: "srd-detail", children: [_jsx("div", { className: "srd-detail-name", children: d.name }), d.desc.map((p, i) => _jsx("p", { className: "srd-desc", children: p }, i))] }));
|
||||||
|
}
|
||||||
|
function MagicItemView({ d }) {
|
||||||
|
return (_jsxs("div", { className: "srd-detail", children: [_jsx("div", { className: "srd-detail-name", children: d.name }), _jsxs("div", { className: "srd-detail-tags", children: [_jsx("span", { className: "srd-tag", children: d.equipment_category.name }), _jsx("span", { className: "srd-tag", children: d.rarity.name })] }), d.desc.map((p, i) => _jsx("p", { className: "srd-desc", children: p }, i))] }));
|
||||||
|
}
|
||||||
|
function DetailView({ category, data }) {
|
||||||
|
if (category === "spells")
|
||||||
|
return _jsx(SpellView, { d: data });
|
||||||
|
if (category === "monsters")
|
||||||
|
return _jsx(MonsterView, { d: data });
|
||||||
|
if (category === "equipment")
|
||||||
|
return _jsx(EquipmentView, { d: data });
|
||||||
|
if (category === "conditions")
|
||||||
|
return _jsx(ConditionView, { d: data });
|
||||||
|
if (category === "magic-items")
|
||||||
|
return _jsx(MagicItemView, { d: data });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
export function SrdDrawer() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [category, setCategory] = useState("spells");
|
||||||
|
// Keep --srd-total-w CSS var in sync (tab 28px + 420px panel when open)
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.style.setProperty("--srd-total-w", open ? "448px" : "28px");
|
||||||
|
}, [open]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
// Focus search input when drawer opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open)
|
||||||
|
setTimeout(() => searchRef.current?.focus(), 120);
|
||||||
|
}, [open]);
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current)
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => doSearch(query.trim(), category), 350);
|
||||||
|
return () => { if (debounceRef.current)
|
||||||
|
clearTimeout(debounceRef.current); };
|
||||||
|
}, [query, category]);
|
||||||
|
// Reset results when category changes
|
||||||
|
useEffect(() => {
|
||||||
|
setResults([]);
|
||||||
|
setSelectedIndex(null);
|
||||||
|
setDetail(null);
|
||||||
|
if (query.trim())
|
||||||
|
doSearch(query.trim(), category);
|
||||||
|
}, [category]);
|
||||||
|
async function doSearch(q, cat) {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSelectedIndex(null);
|
||||||
|
setDetail(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SRD_BASE}/${cat}?name=${encodeURIComponent(q)}`);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`SRD returned ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(data.results ?? []);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadDetail(item) {
|
||||||
|
if (selectedIndex === item.index) {
|
||||||
|
setSelectedIndex(null);
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedIndex(item.index);
|
||||||
|
setDetail(null);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://www.dnd5eapi.co${item.url}`);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`SRD returned ${res.status}`);
|
||||||
|
setDetail(await res.json());
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (_jsxs("div", { className: "srd-drawer-wrap", children: [_jsxs("button", { className: `drawer-tab srd-tab${open ? " open" : ""}`, onClick: () => setOpen(o => !o), title: "5e SRD Reference", "aria-label": "Toggle SRD reference drawer", children: [_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" }), _jsx("path", { d: "M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" })] }), _jsx("span", { className: "drawer-tab-label", children: "SRD" })] }), _jsxs("div", { className: `srd-panel${open ? " open" : ""}`, role: "complementary", "aria-label": "5e SRD Reference", children: [_jsx("div", { className: "srd-drawer-header", children: _jsxs("div", { className: "srd-drawer-title", children: [_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" }), _jsx("path", { d: "M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" })] }), "5e SRD Reference"] }) }), _jsx("div", { className: "srd-cats", children: CATEGORIES.map(c => (_jsxs("button", { className: `srd-cat${category === c.key ? " active" : ""}`, onClick: () => setCategory(c.key), children: [_jsx("span", { children: c.icon }), _jsx("span", { children: c.label })] }, c.key))) }), _jsxs("div", { className: "srd-search-wrap", children: [_jsxs("svg", { className: "srd-search-icon", width: "15", height: "15", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "11", cy: "11", r: "8" }), _jsx("path", { d: "m21 21-4.35-4.35" })] }), _jsx("input", { ref: searchRef, className: "srd-search", value: query, onChange: e => setQuery(e.target.value), placeholder: `Search ${CATEGORIES.find(c => c.key === category)?.label.toLowerCase()}…` }), query && (_jsx("button", { className: "srd-search-clear", onClick: () => { setQuery(""); setResults([]); }, "aria-label": "Clear", children: "\u2715" }))] }), _jsxs("div", { className: "srd-results", children: [error && _jsx("div", { className: "srd-error", children: error }), loading && _jsx("div", { className: "srd-loading", children: "Searching\u2026" }), !loading && query && results.length === 0 && !error && (_jsxs("div", { className: "srd-empty", children: ["No results for \"", query, "\""] })), !query && (_jsx("div", { className: "srd-hint", children: "Type to search the 5e SRD \u2014 spells, monsters, items, and more." })), results.map(item => (_jsxs("div", { className: "srd-result-group", children: [_jsxs("div", { className: `srd-result-item${selectedIndex === item.index ? " active" : ""}`, onClick: () => loadDetail(item), children: [_jsx("span", { className: "srd-result-name", children: item.name }), _jsx("span", { className: "srd-result-chevron", children: selectedIndex === item.index ? "▲" : "▼" })] }), selectedIndex === item.index && (_jsx("div", { className: "srd-result-detail", children: detailLoading
|
||||||
|
? _jsx("div", { className: "srd-loading", children: "Loading\u2026" })
|
||||||
|
: detail ? _jsx(DetailView, { category: category, data: detail }) : null }))] }, item.index)))] })] })] }));
|
||||||
|
}
|
||||||
374
apps/web/src/components/SrdDrawer.tsx
Normal file
374
apps/web/src/components/SrdDrawer.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SrdListItem {
|
||||||
|
index: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SrdListResponse {
|
||||||
|
count: number;
|
||||||
|
results: SrdListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific detail shapes we care about
|
||||||
|
interface SpellDetail {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
school: { name: string };
|
||||||
|
casting_time: string;
|
||||||
|
range: string;
|
||||||
|
duration: string;
|
||||||
|
components: string[];
|
||||||
|
concentration: boolean;
|
||||||
|
ritual: boolean;
|
||||||
|
desc: string[];
|
||||||
|
higher_level?: string[];
|
||||||
|
classes: { name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonsterDetail {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: string;
|
||||||
|
alignment: string;
|
||||||
|
challenge_rating: number;
|
||||||
|
hit_points: number;
|
||||||
|
hit_dice: string;
|
||||||
|
armor_class: { type: string; value: number }[];
|
||||||
|
speed: Record<string, string>;
|
||||||
|
strength: number; dexterity: number; constitution: number;
|
||||||
|
intelligence: number; wisdom: number; charisma: number;
|
||||||
|
desc?: string;
|
||||||
|
special_abilities?: { name: string; desc: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentDetail {
|
||||||
|
name: string;
|
||||||
|
equipment_category: { name: string };
|
||||||
|
cost: { quantity: number; unit: string };
|
||||||
|
weight?: number;
|
||||||
|
desc?: string[];
|
||||||
|
damage?: { damage_dice: string; damage_type: { name: string } };
|
||||||
|
armor_class?: { base: number };
|
||||||
|
properties?: { name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConditionDetail {
|
||||||
|
name: string;
|
||||||
|
desc: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MagicItemDetail {
|
||||||
|
name: string;
|
||||||
|
equipment_category: { name: string };
|
||||||
|
rarity: { name: string };
|
||||||
|
desc: string[];
|
||||||
|
variants?: { name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailData = SpellDetail | MonsterDetail | EquipmentDetail | ConditionDetail | MagicItemDetail | Record<string, unknown>;
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SRD_BASE = "https://www.dnd5eapi.co/api";
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ key: "spells", label: "Spells", icon: "✨" },
|
||||||
|
{ key: "monsters", label: "Monsters", icon: "🐉" },
|
||||||
|
{ key: "equipment", label: "Equipment", icon: "⚔️" },
|
||||||
|
{ key: "conditions", label: "Conditions", icon: "🩹" },
|
||||||
|
{ key: "magic-items", label: "Magic Items", icon: "💎" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Category = typeof CATEGORIES[number]["key"];
|
||||||
|
|
||||||
|
// ─── Detail renderers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function mod(score: number) {
|
||||||
|
const m = Math.floor((score - 10) / 2);
|
||||||
|
return m >= 0 ? `+${m}` : `${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellView({ d }: { d: SpellDetail }) {
|
||||||
|
const level = d.level === 0 ? "Cantrip" : `Level ${d.level}`;
|
||||||
|
const tags = [level, d.school.name, d.concentration ? "Concentration" : null, d.ritual ? "Ritual" : null].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="srd-detail">
|
||||||
|
<div className="srd-detail-name">{d.name}</div>
|
||||||
|
<div className="srd-detail-tags">
|
||||||
|
{tags.map(t => <span key={t} className="srd-tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
<dl className="srd-stat-grid">
|
||||||
|
<dt>Casting Time</dt><dd>{d.casting_time}</dd>
|
||||||
|
<dt>Range</dt><dd>{d.range}</dd>
|
||||||
|
<dt>Components</dt><dd>{d.components.join(", ")}</dd>
|
||||||
|
<dt>Duration</dt><dd>{d.duration}</dd>
|
||||||
|
<dt>Classes</dt><dd>{d.classes.map(c => c.name).join(", ")}</dd>
|
||||||
|
</dl>
|
||||||
|
{d.desc.map((p, i) => <p key={i} className="srd-desc">{p}</p>)}
|
||||||
|
{d.higher_level?.length ? (
|
||||||
|
<>
|
||||||
|
<div className="srd-section-label">At Higher Levels</div>
|
||||||
|
{d.higher_level.map((p, i) => <p key={i} className="srd-desc">{p}</p>)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MonsterView({ d }: { d: MonsterDetail }) {
|
||||||
|
const ac = d.armor_class?.[0];
|
||||||
|
const speeds = Object.entries(d.speed ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
||||||
|
return (
|
||||||
|
<div className="srd-detail">
|
||||||
|
<div className="srd-detail-name">{d.name}</div>
|
||||||
|
<div className="srd-detail-tags">
|
||||||
|
<span className="srd-tag">{d.size} {d.type}</span>
|
||||||
|
<span className="srd-tag">CR {d.challenge_rating}</span>
|
||||||
|
<span className="srd-tag">{d.alignment}</span>
|
||||||
|
</div>
|
||||||
|
<dl className="srd-stat-grid">
|
||||||
|
{ac && <><dt>AC</dt><dd>{ac.value} ({ac.type})</dd></>}
|
||||||
|
<dt>HP</dt><dd>{d.hit_points} ({d.hit_dice})</dd>
|
||||||
|
<dt>Speed</dt><dd>{speeds}</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="srd-ability-row">
|
||||||
|
{[["STR", d.strength], ["DEX", d.dexterity], ["CON", d.constitution],
|
||||||
|
["INT", d.intelligence], ["WIS", d.wisdom], ["CHA", d.charisma]].map(([k, v]) => (
|
||||||
|
<div key={k as string} className="srd-ability">
|
||||||
|
<div className="srd-ability-label">{k as string}</div>
|
||||||
|
<div className="srd-ability-score">{v as number}</div>
|
||||||
|
<div className="srd-ability-mod">{mod(v as number)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{d.desc && <p className="srd-desc">{d.desc}</p>}
|
||||||
|
{d.special_abilities?.slice(0, 3).map(a => (
|
||||||
|
<div key={a.name} className="srd-ability-block">
|
||||||
|
<span className="srd-ability-block-name">{a.name}. </span>
|
||||||
|
{a.desc}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipmentView({ d }: { d: EquipmentDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="srd-detail">
|
||||||
|
<div className="srd-detail-name">{d.name}</div>
|
||||||
|
<div className="srd-detail-tags">
|
||||||
|
<span className="srd-tag">{d.equipment_category.name}</span>
|
||||||
|
{d.properties?.map(p => <span key={p.name} className="srd-tag">{p.name}</span>)}
|
||||||
|
</div>
|
||||||
|
<dl className="srd-stat-grid">
|
||||||
|
<dt>Cost</dt><dd>{d.cost.quantity} {d.cost.unit}</dd>
|
||||||
|
{d.weight != null && <><dt>Weight</dt><dd>{d.weight} lb</dd></>}
|
||||||
|
{d.damage && <><dt>Damage</dt><dd>{d.damage.damage_dice} {d.damage.damage_type.name}</dd></>}
|
||||||
|
{d.armor_class && <><dt>AC</dt><dd>{d.armor_class.base}</dd></>}
|
||||||
|
</dl>
|
||||||
|
{d.desc?.map((p, i) => <p key={i} className="srd-desc">{p}</p>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionView({ d }: { d: ConditionDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="srd-detail">
|
||||||
|
<div className="srd-detail-name">{d.name}</div>
|
||||||
|
{d.desc.map((p, i) => <p key={i} className="srd-desc">{p}</p>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MagicItemView({ d }: { d: MagicItemDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="srd-detail">
|
||||||
|
<div className="srd-detail-name">{d.name}</div>
|
||||||
|
<div className="srd-detail-tags">
|
||||||
|
<span className="srd-tag">{d.equipment_category.name}</span>
|
||||||
|
<span className="srd-tag">{d.rarity.name}</span>
|
||||||
|
</div>
|
||||||
|
{d.desc.map((p, i) => <p key={i} className="srd-desc">{p}</p>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailView({ category, data }: { category: Category; data: DetailData }) {
|
||||||
|
if (category === "spells") return <SpellView d={data as SpellDetail} />;
|
||||||
|
if (category === "monsters") return <MonsterView d={data as MonsterDetail} />;
|
||||||
|
if (category === "equipment") return <EquipmentView d={data as EquipmentDetail} />;
|
||||||
|
if (category === "conditions") return <ConditionView d={data as ConditionDetail} />;
|
||||||
|
if (category === "magic-items") return <MagicItemView d={data as MagicItemDetail} />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SrdDrawer() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [category, setCategory] = useState<Category>("spells");
|
||||||
|
|
||||||
|
// Keep --srd-total-w CSS var in sync (tab 28px + 420px panel when open)
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.style.setProperty("--srd-total-w", open ? "448px" : "28px");
|
||||||
|
}, [open]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SrdListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<string | null>(null);
|
||||||
|
const [detail, setDetail] = useState<DetailData | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Focus search input when drawer opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setTimeout(() => searchRef.current?.focus(), 120);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
if (!query.trim()) { setResults([]); return; }
|
||||||
|
debounceRef.current = setTimeout(() => doSearch(query.trim(), category), 350);
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
|
}, [query, category]);
|
||||||
|
|
||||||
|
// Reset results when category changes
|
||||||
|
useEffect(() => {
|
||||||
|
setResults([]);
|
||||||
|
setSelectedIndex(null);
|
||||||
|
setDetail(null);
|
||||||
|
if (query.trim()) doSearch(query.trim(), category);
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
async function doSearch(q: string, cat: Category) {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSelectedIndex(null);
|
||||||
|
setDetail(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SRD_BASE}/${cat}?name=${encodeURIComponent(q)}`);
|
||||||
|
if (!res.ok) throw new Error(`SRD returned ${res.status}`);
|
||||||
|
const data = await res.json() as SrdListResponse;
|
||||||
|
setResults(data.results ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(item: SrdListItem) {
|
||||||
|
if (selectedIndex === item.index) { setSelectedIndex(null); setDetail(null); return; }
|
||||||
|
setSelectedIndex(item.index);
|
||||||
|
setDetail(null);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://www.dnd5eapi.co${item.url}`);
|
||||||
|
if (!res.ok) throw new Error(`SRD returned ${res.status}`);
|
||||||
|
setDetail(await res.json());
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="srd-drawer-wrap">
|
||||||
|
<button
|
||||||
|
className={`drawer-tab srd-tab${open ? " open" : ""}`}
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
title="5e SRD Reference"
|
||||||
|
aria-label="Toggle SRD reference drawer"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="drawer-tab-label">SRD</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={`srd-panel${open ? " open" : ""}`} role="complementary" aria-label="5e SRD Reference">
|
||||||
|
<div className="srd-drawer-header">
|
||||||
|
<div className="srd-drawer-title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||||
|
</svg>
|
||||||
|
5e SRD Reference
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div className="srd-cats">
|
||||||
|
{CATEGORIES.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
className={`srd-cat${category === c.key ? " active" : ""}`}
|
||||||
|
onClick={() => setCategory(c.key)}
|
||||||
|
>
|
||||||
|
<span>{c.icon}</span>
|
||||||
|
<span>{c.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="srd-search-wrap">
|
||||||
|
<svg className="srd-search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
className="srd-search"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder={`Search ${CATEGORIES.find(c => c.key === category)?.label.toLowerCase()}…`}
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button className="srd-search-clear" onClick={() => { setQuery(""); setResults([]); }} aria-label="Clear">✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="srd-results">
|
||||||
|
{error && <div className="srd-error">{error}</div>}
|
||||||
|
{loading && <div className="srd-loading">Searching…</div>}
|
||||||
|
{!loading && query && results.length === 0 && !error && (
|
||||||
|
<div className="srd-empty">No results for "{query}"</div>
|
||||||
|
)}
|
||||||
|
{!query && (
|
||||||
|
<div className="srd-hint">Type to search the 5e SRD — spells, monsters, items, and more.</div>
|
||||||
|
)}
|
||||||
|
{results.map(item => (
|
||||||
|
<div key={item.index} className="srd-result-group">
|
||||||
|
<div
|
||||||
|
className={`srd-result-item${selectedIndex === item.index ? " active" : ""}`}
|
||||||
|
onClick={() => loadDetail(item)}
|
||||||
|
>
|
||||||
|
<span className="srd-result-name">{item.name}</span>
|
||||||
|
<span className="srd-result-chevron">{selectedIndex === item.index ? "▲" : "▼"}</span>
|
||||||
|
</div>
|
||||||
|
{selectedIndex === item.index && (
|
||||||
|
<div className="srd-result-detail">
|
||||||
|
{detailLoading
|
||||||
|
? <div className="srd-loading">Loading…</div>
|
||||||
|
: detail ? <DetailView category={category} data={detail} /> : null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/web/src/components/Toast.js
Normal file
45
apps/web/src/components/Toast.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
export function Toast({ toasts, removeToast }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (toasts.length > 0) {
|
||||||
|
setVisible(true);
|
||||||
|
const timer = setTimeout(() => setVisible(false), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
}, [toasts.length]);
|
||||||
|
if (!visible || toasts.length === 0)
|
||||||
|
return null;
|
||||||
|
return (_jsx("div", { className: "toast-container", style: {
|
||||||
|
position: "fixed",
|
||||||
|
top: "20px",
|
||||||
|
right: "20px",
|
||||||
|
zIndex: 1000,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}, children: toasts.map(toast => (_jsx("div", { className: `toast toast-${toast.type}`, style: {
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: visible ? "translateX(0)" : "translateX(100%)",
|
||||||
|
transition: "opacity 0.3s ease, transform 0.3s ease",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}, children: _jsxs("div", { style: { display: "flex", alignItems: "center", gap: "12px" }, children: [_jsx("span", { className: "toast-icon", children: getIcon(toast.type) }), _jsx("span", { style: { flex: 1, fontSize: "14px", color: "var(--text)" }, children: toast.message }), _jsx("button", { className: "toast-close", onClick: () => removeToast(toast.id), style: {
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}, "aria-label": "Close notification", children: "\u00D7" })] }) }, toast.id))) }));
|
||||||
|
}
|
||||||
|
function getIcon(type) {
|
||||||
|
switch (type) {
|
||||||
|
case "success": return "✅";
|
||||||
|
case "error": return "❌";
|
||||||
|
case "info": return "ℹ️";
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/web/src/components/Toast.tsx
Normal file
55
apps/web/src/components/Toast.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type ToastType = "success" | "error" | "info";
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
toasts: ToastItem[];
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleToast({ toast, onRemove }: { toast: ToastItem; onRemove: () => void }) {
|
||||||
|
const [exiting, setExiting] = useState(false);
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
if (exiting) return;
|
||||||
|
setExiting(true);
|
||||||
|
setTimeout(onRemove, 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin exit animation slightly before the context auto-removes at 5000ms
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setExiting(true), 4700);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`toast toast-${toast.type}${exiting ? " toast-exit" : ""}`}
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
<span className="toast-message">{toast.message}</span>
|
||||||
|
<button className="toast-close" onClick={dismiss} aria-label="Dismiss notification">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toast({ toasts, removeToast }: ToastProps) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toast-container" aria-label="Notifications">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<SingleToast key={toast.id} toast={toast} onRemove={() => removeToast(toast.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/src/contexts/CharacterSheetContext.js
Normal file
18
apps/web/src/contexts/CharacterSheetContext.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { createContext, useContext, useState } from "react";
|
||||||
|
const CharacterSheetContext = createContext({
|
||||||
|
open: false,
|
||||||
|
toggle: () => { },
|
||||||
|
close: () => { },
|
||||||
|
});
|
||||||
|
export function CharacterSheetProvider({ children }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (_jsx(CharacterSheetContext.Provider, { value: {
|
||||||
|
open,
|
||||||
|
toggle: () => setOpen(o => !o),
|
||||||
|
close: () => setOpen(false),
|
||||||
|
}, children: children }));
|
||||||
|
}
|
||||||
|
export function useCharacterSheet() {
|
||||||
|
return useContext(CharacterSheetContext);
|
||||||
|
}
|
||||||
30
apps/web/src/contexts/CharacterSheetContext.tsx
Normal file
30
apps/web/src/contexts/CharacterSheetContext.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { createContext, useContext, useState } from "react";
|
||||||
|
|
||||||
|
interface CharacterSheetContextValue {
|
||||||
|
open: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CharacterSheetContext = createContext<CharacterSheetContextValue>({
|
||||||
|
open: false,
|
||||||
|
toggle: () => {},
|
||||||
|
close: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CharacterSheetProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<CharacterSheetContext.Provider value={{
|
||||||
|
open,
|
||||||
|
toggle: () => setOpen(o => !o),
|
||||||
|
close: () => setOpen(false),
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</CharacterSheetContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCharacterSheet() {
|
||||||
|
return useContext(CharacterSheetContext);
|
||||||
|
}
|
||||||
20
apps/web/src/contexts/DebugContext.js
Normal file
20
apps/web/src/contexts/DebugContext.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { createContext, useContext, useState } from "react";
|
||||||
|
const DebugContext = createContext({
|
||||||
|
debugMode: false,
|
||||||
|
toggle: () => { },
|
||||||
|
});
|
||||||
|
export function DebugProvider({ children }) {
|
||||||
|
const [debugMode, setDebugMode] = useState(() => localStorage.getItem("debugMode") === "1");
|
||||||
|
function toggle() {
|
||||||
|
setDebugMode(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem("debugMode", next ? "1" : "0");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _jsx(DebugContext.Provider, { value: { debugMode, toggle }, children: children });
|
||||||
|
}
|
||||||
|
export function useDebugMode() {
|
||||||
|
return useContext(DebugContext);
|
||||||
|
}
|
||||||
29
apps/web/src/contexts/DebugContext.tsx
Normal file
29
apps/web/src/contexts/DebugContext.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { createContext, useContext, useState } from "react";
|
||||||
|
|
||||||
|
interface DebugContextValue {
|
||||||
|
debugMode: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DebugContext = createContext<DebugContextValue>({
|
||||||
|
debugMode: false,
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function DebugProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [debugMode, setDebugMode] = useState(() => localStorage.getItem("debugMode") === "1");
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
setDebugMode(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
localStorage.setItem("debugMode", next ? "1" : "0");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DebugContext.Provider value={{ debugMode, toggle }}>{children}</DebugContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDebugMode() {
|
||||||
|
return useContext(DebugContext);
|
||||||
|
}
|
||||||
26
apps/web/src/contexts/ToastContext.js
Normal file
26
apps/web/src/contexts/ToastContext.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { createContext, useContext, useState, useCallback } from "react";
|
||||||
|
const ToastContext = createContext(null);
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useToast must be used within a ToastProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
export function ToastProvider({ children }) {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const addToast = useCallback((message, type = "info") => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
setToasts(prev => [...prev, { id, message, type }]);
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, 5000);
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
const removeToast = useCallback((id) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
return (_jsx(ToastContext.Provider, { value: { toasts, addToast, removeToast }, children: children }));
|
||||||
|
}
|
||||||
53
apps/web/src/contexts/ToastContext.tsx
Normal file
53
apps/web/src/contexts/ToastContext.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||||
|
|
||||||
|
type ToastType = "success" | "error" | "info";
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
toasts: ToastItem[];
|
||||||
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | null>(null);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useToast must be used within a ToastProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: ToastProviderProps) {
|
||||||
|
const [toasts, setToasts] = useState<Array<{ id: string; message: string; type: ToastType }>>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((message: string, type: ToastType = "info") => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
setToasts(prev => [...prev, { id, message, type }]);
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, 5000);
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/web/src/main.js
Normal file
7
apps/web/src/main.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(_jsx(React.StrictMode, { children: _jsx(BrowserRouter, { children: _jsx(App, {}) }) }));
|
||||||
14
apps/web/src/main.tsx
Normal file
14
apps/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
58
apps/web/src/pages/AdminPage.js
Normal file
58
apps/web/src/pages/AdminPage.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
function formatDate(ts) {
|
||||||
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
export function AdminPage() {
|
||||||
|
const [role, setRole] = useState(null);
|
||||||
|
const [requests, setRequests] = useState([]);
|
||||||
|
const [settings, setSettings] = useState(null);
|
||||||
|
const [defaultDay, setDefaultDay] = useState("");
|
||||||
|
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
const loadRequests = () => api("/admin/dm-requests")
|
||||||
|
.then(setRequests)
|
||||||
|
.catch(() => { });
|
||||||
|
useEffect(() => {
|
||||||
|
api("/me").then(me => setRole(me.role ?? "player"));
|
||||||
|
loadRequests();
|
||||||
|
api("/guild/settings").then(s => {
|
||||||
|
setSettings(s);
|
||||||
|
setDefaultDay(s.defaultGameDay != null ? String(s.defaultGameDay) : "");
|
||||||
|
}).catch(() => { });
|
||||||
|
}, []);
|
||||||
|
async function approve(userId, username) {
|
||||||
|
try {
|
||||||
|
await api(`/admin/dm-requests/${userId}/approve`, { method: "POST" });
|
||||||
|
toast.addToast(`${username} approved as DM.`, "success");
|
||||||
|
await loadRequests();
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.addToast("Failed to approve request. Please try again.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveSettings() {
|
||||||
|
setSettingsSaving(true);
|
||||||
|
try {
|
||||||
|
await api("/guild/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ defaultGameDay: defaultDay !== "" ? Number(defaultDay) : null }),
|
||||||
|
});
|
||||||
|
setSettings({ defaultGameDay: defaultDay !== "" ? Number(defaultDay) : null });
|
||||||
|
toast.addToast("Settings saved.", "success");
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.addToast("Failed to save settings. Please try again.", "error");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setSettingsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const settingsChanged = settings !== null &&
|
||||||
|
String(settings.defaultGameDay ?? "") !== defaultDay;
|
||||||
|
return (_jsxs("div", { className: "page", children: [_jsxs("div", { className: "page-header", children: [_jsx("h1", { className: "page-title", children: "Management" }), _jsx("p", { className: "page-subtitle", children: isAdmin ? "Guild administration and DM approvals" : "Request and manage DM access" })] }), _jsxs("div", { className: "tabs", children: [isAdmin && (_jsxs("div", { className: "card", style: { marginBottom: "24px" }, children: [_jsx("div", { className: "card-header", children: _jsx("span", { className: "card-title", children: "Guild Settings" }) }), _jsx("div", { className: "card-body", children: _jsxs("div", { className: "form-group", style: { marginBottom: "0" }, children: [_jsx("label", { className: "form-label", children: "Default Game Night" }), _jsx("p", { className: "text-muted", style: { fontSize: "12px", marginBottom: "10px" }, children: "The dashboard will auto-suggest the next unscheduled occurrence of this day when scheduling a session." }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("select", { className: "form-input", style: { maxWidth: "180px" }, value: defaultDay, onChange: e => setDefaultDay(e.target.value), children: [_jsx("option", { value: "", children: "\u2014 no default \u2014" }), WEEKDAYS.map((name, i) => (_jsx("option", { value: i, children: name }, i)))] }), _jsx("button", { className: "btn btn-primary", onClick: saveSettings, disabled: !settingsChanged || settingsSaving, children: settingsSaving ? _jsx("span", { className: "spinner" }) : "Save" })] })] }) })] })), isAdmin && (_jsxs("div", { className: "card", children: [_jsxs("div", { className: "card-header", children: [_jsx("span", { className: "card-title", children: "Pending DM Requests" }), requests.length > 0 && (_jsx("span", { className: "badge badge-pending", children: requests.length }))] }), _jsx("div", { className: "card-body", style: { padding: "0 20px" }, children: requests.length === 0 ? (_jsxs("div", { className: "empty-state", children: [_jsx("div", { className: "empty-state-icon", children: "\u2705" }), _jsx("div", { className: "empty-state-title", children: "No pending requests" }), _jsx("div", { className: "empty-state-text", children: "All caught up." })] })) : (requests.map(r => (_jsxs("div", { className: "request-item", children: [_jsxs("div", { children: [_jsx("div", { className: "request-user", children: r.username }), _jsxs("div", { className: "request-meta", children: ["Requested DM role \u00B7 ", formatDate(r.requested_at)] })] }), _jsx("button", { className: "btn btn-primary btn-sm", onClick: () => approve(r.userId, r.username), children: "Approve" })] }, r.userId)))) })] }))] })] }));
|
||||||
|
}
|
||||||
162
apps/web/src/pages/AdminPage.tsx
Normal file
162
apps/web/src/pages/AdminPage.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
|
||||||
|
interface DmRequest {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
discord_user_id: string;
|
||||||
|
requested_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Me {
|
||||||
|
user: { id: number; username: string };
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GuildSettings {
|
||||||
|
defaultGameDay: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
|
function formatDate(ts: string) {
|
||||||
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const [role, setRole] = useState<string | null>(null);
|
||||||
|
const [requests, setRequests] = useState<DmRequest[]>([]);
|
||||||
|
const [settings, setSettings] = useState<GuildSettings | null>(null);
|
||||||
|
const [defaultDay, setDefaultDay] = useState<string>("");
|
||||||
|
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
|
||||||
|
const loadRequests = () =>
|
||||||
|
api<DmRequest[]>("/admin/dm-requests")
|
||||||
|
.then(setRequests)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api<Me>("/me").then(me => setRole(me.role ?? "player"));
|
||||||
|
loadRequests();
|
||||||
|
api<GuildSettings>("/guild/settings").then(s => {
|
||||||
|
setSettings(s);
|
||||||
|
setDefaultDay(s.defaultGameDay != null ? String(s.defaultGameDay) : "");
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function approve(userId: number, username: string) {
|
||||||
|
try {
|
||||||
|
await api(`/admin/dm-requests/${userId}/approve`, { method: "POST" });
|
||||||
|
toast.addToast(`${username} approved as DM.`, "success");
|
||||||
|
await loadRequests();
|
||||||
|
} catch (e) {
|
||||||
|
toast.addToast("Failed to approve request. Please try again.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
setSettingsSaving(true);
|
||||||
|
try {
|
||||||
|
await api("/guild/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ defaultGameDay: defaultDay !== "" ? Number(defaultDay) : null }),
|
||||||
|
});
|
||||||
|
setSettings({ defaultGameDay: defaultDay !== "" ? Number(defaultDay) : null });
|
||||||
|
toast.addToast("Settings saved.", "success");
|
||||||
|
} catch (e) {
|
||||||
|
toast.addToast("Failed to save settings. Please try again.", "error");
|
||||||
|
} finally {
|
||||||
|
setSettingsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsChanged =
|
||||||
|
settings !== null &&
|
||||||
|
String(settings.defaultGameDay ?? "") !== defaultDay;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Management</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
{isAdmin ? "Guild administration and DM approvals" : "Request and manage DM access"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="card" style={{ marginBottom: "24px" }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">Guild Settings</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="form-group" style={{ marginBottom: "0" }}>
|
||||||
|
<label className="form-label">Default Game Night</label>
|
||||||
|
<p className="text-muted" style={{ fontSize: "12px", marginBottom: "10px" }}>
|
||||||
|
The dashboard will auto-suggest the next unscheduled occurrence of this day when scheduling a session.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: "180px" }}
|
||||||
|
value={defaultDay}
|
||||||
|
onChange={e => setDefaultDay(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— no default —</option>
|
||||||
|
{WEEKDAYS.map((name, i) => (
|
||||||
|
<option key={i} value={i}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={saveSettings}
|
||||||
|
disabled={!settingsChanged || settingsSaving}
|
||||||
|
>
|
||||||
|
{settingsSaving ? <span className="spinner" /> : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DM request approvals — admin only */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">Pending DM Requests</span>
|
||||||
|
{requests.length > 0 && (
|
||||||
|
<span className="badge badge-pending">{requests.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ padding: "0 20px" }}>
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">✅</div>
|
||||||
|
<div className="empty-state-title">No pending requests</div>
|
||||||
|
<div className="empty-state-text">All caught up.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
requests.map(r => (
|
||||||
|
<div key={r.userId} className="request-item">
|
||||||
|
<div>
|
||||||
|
<div className="request-user">{r.username}</div>
|
||||||
|
<div className="request-meta">Requested DM role · {formatDate(r.requested_at)}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => approve(r.userId, r.username)}>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/src/pages/CallbackPage.js
Normal file
32
apps/web/src/pages/CallbackPage.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
export function CallbackPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
useEffect(() => {
|
||||||
|
const code = new URLSearchParams(window.location.search).get("code");
|
||||||
|
if (!code) {
|
||||||
|
toast.addToast("No authorization code found in URL. Try signing in again.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inviteCode = sessionStorage.getItem("dnd_invite_code") ?? undefined;
|
||||||
|
api("/auth/discord/callback", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ code, inviteCode })
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
localStorage.setItem("token", result.token);
|
||||||
|
toast.addToast("Successfully signed in!", "success");
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
})
|
||||||
|
.catch(e => toast.addToast("Sign-in failed: " + String(e), "error"));
|
||||||
|
}, []);
|
||||||
|
return (_jsx("div", { className: "login-page", children: _jsxs("div", { className: "login-card", style: { textAlign: "center" }, children: [_jsx("div", { style: { display: "flex", justifyContent: "center", marginBottom: "16px" }, children: _jsx("div", { className: "spinner", style: {
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderWidth: "3px",
|
||||||
|
borderColor: "rgba(255,255,255,0.15)",
|
||||||
|
borderTopColor: "var(--teal)"
|
||||||
|
} }) }), _jsx("p", { style: { color: "var(--text-muted)" }, children: "Signing you in\u2026" })] }) }));
|
||||||
|
}
|
||||||
46
apps/web/src/pages/CallbackPage.tsx
Normal file
46
apps/web/src/pages/CallbackPage.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
|
||||||
|
export function CallbackPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const code = new URLSearchParams(window.location.search).get("code");
|
||||||
|
if (!code) {
|
||||||
|
toast.addToast("No authorization code found in URL. Try signing in again.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inviteCode = sessionStorage.getItem("dnd_invite_code") ?? undefined;
|
||||||
|
api<{ token: string }>("/auth/discord/callback", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ code, inviteCode })
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
localStorage.setItem("token", result.token);
|
||||||
|
toast.addToast("Successfully signed in!", "success");
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
})
|
||||||
|
.catch(e => toast.addToast("Sign-in failed: " + String(e), "error"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card" style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", marginBottom: "16px" }}>
|
||||||
|
<div
|
||||||
|
className="spinner"
|
||||||
|
style={{
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderWidth: "3px",
|
||||||
|
borderColor: "rgba(255,255,255,0.15)",
|
||||||
|
borderTopColor: "var(--teal)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: "var(--text-muted)" }}>Signing you in…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
447
apps/web/src/pages/CampaignDetailPage.js
Normal file
447
apps/web/src/pages/CampaignDetailPage.js
Normal file
File diff suppressed because one or more lines are too long
1129
apps/web/src/pages/CampaignDetailPage.tsx
Normal file
1129
apps/web/src/pages/CampaignDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
46
apps/web/src/pages/CampaignsPage.js
Normal file
46
apps/web/src/pages/CampaignsPage.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
const STATUS_BADGE = {
|
||||||
|
active: "badge badge-active",
|
||||||
|
paused: "badge badge-paused",
|
||||||
|
archived: "badge badge-archived"
|
||||||
|
};
|
||||||
|
export function CampaignsPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [campaigns, setCampaigns] = useState([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const load = () => api("/campaigns")
|
||||||
|
.then(setCampaigns)
|
||||||
|
.catch(e => toast.addToast("Failed to load campaigns. Please try again.", "error"));
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
async function handleCreate(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api("/campaigns", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, description })
|
||||||
|
});
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setShowForm(false);
|
||||||
|
await load();
|
||||||
|
toast.addToast("Campaign created successfully", "success");
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.addToast("Failed to create campaign. Please try again.", "error");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (_jsxs("div", { className: "page", children: [_jsxs("div", { className: "page-header flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "page-title", children: "Campaigns" }), _jsxs("p", { className: "page-subtitle", children: [campaigns.length, " campaign", campaigns.length !== 1 ? "s" : ""] })] }), _jsx("button", { className: "btn btn-primary", onClick: () => setShowForm(!showForm), children: showForm ? "Cancel" : "+ New Campaign" })] }), showForm && (_jsxs("div", { className: "card", style: { marginBottom: "24px" }, children: [_jsx("div", { className: "card-header", children: _jsx("span", { className: "card-title", children: "New Campaign" }) }), _jsx("div", { className: "card-body", children: _jsxs("form", { onSubmit: handleCreate, children: [_jsxs("div", { className: "form-group", children: [_jsx("label", { className: "form-label", children: "Campaign Name" }), _jsx("input", { className: "form-input", value: name, onChange: e => setName(e.target.value), placeholder: "e.g. Curse of Strahd", required: true })] }), _jsxs("div", { className: "form-group", children: [_jsx("label", { className: "form-label", children: "Description" }), _jsx("textarea", { className: "form-textarea", value: description, onChange: e => setDescription(e.target.value), placeholder: "A brief description of the campaign\u2026" })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", className: "btn btn-secondary", onClick: () => setShowForm(false), children: "Cancel" }), _jsx("button", { type: "submit", className: "btn btn-primary", disabled: saving, children: saving ? _jsx("span", { className: "spinner" }) : "Create Campaign" })] })] }) })] })), campaigns.length === 0 && !showForm ? (_jsxs("div", { className: "empty-state", children: [_jsx("div", { className: "empty-state-icon", children: "\uD83D\uDCDC" }), _jsx("div", { className: "empty-state-title", children: "No campaigns yet" }), _jsx("div", { className: "empty-state-text", children: "Create your first campaign to get started." })] })) : (_jsx("div", { className: "campaign-grid", children: campaigns.map(c => (_jsxs(Link, { to: `/campaigns/${c.id}`, className: "campaign-card", children: [_jsx("div", { className: "campaign-card-name", children: c.name }), _jsx("div", { className: "campaign-card-desc", children: c.description || "No description" }), _jsx("div", { className: "campaign-card-footer", children: _jsx("span", { className: STATUS_BADGE[c.status] ?? "badge badge-archived", children: c.status }) })] }, c.id))) }))] }));
|
||||||
|
}
|
||||||
136
apps/web/src/pages/CampaignsPage.tsx
Normal file
136
apps/web/src/pages/CampaignsPage.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: "active" | "paused" | "archived";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
|
active: "badge badge-active",
|
||||||
|
paused: "badge badge-paused",
|
||||||
|
archived: "badge badge-archived"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CampaignsPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = () =>
|
||||||
|
api<Campaign[]>("/campaigns")
|
||||||
|
.then(setCampaigns)
|
||||||
|
.catch(e => toast.addToast("Failed to load campaigns. Please try again.", "error"));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCreate(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api("/campaigns", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, description })
|
||||||
|
});
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setShowForm(false);
|
||||||
|
await load();
|
||||||
|
toast.addToast("Campaign created successfully", "success");
|
||||||
|
} catch (e) {
|
||||||
|
toast.addToast("Failed to create campaign. Please try again.", "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Campaigns</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
{campaigns.length} campaign{campaigns.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||||
|
{showForm ? "Cancel" : "+ New Campaign"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="card" style={{ marginBottom: "24px" }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">New Campaign</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Campaign Name</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Curse of Strahd"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Description</label>
|
||||||
|
<textarea
|
||||||
|
className="form-textarea"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="A brief description of the campaign…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? <span className="spinner" /> : "Create Campaign"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{campaigns.length === 0 && !showForm ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">📜</div>
|
||||||
|
<div className="empty-state-title">No campaigns yet</div>
|
||||||
|
<div className="empty-state-text">Create your first campaign to get started.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="campaign-grid">
|
||||||
|
{campaigns.map(c => (
|
||||||
|
<Link key={c.id} to={`/campaigns/${c.id}`} className="campaign-card">
|
||||||
|
<div className="campaign-card-name">{c.name}</div>
|
||||||
|
<div className="campaign-card-desc">{c.description || "No description"}</div>
|
||||||
|
<div className="campaign-card-footer">
|
||||||
|
<span className={STATUS_BADGE[c.status] ?? "badge badge-archived"}>
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/src/pages/CharacterPage.js
Normal file
99
apps/web/src/pages/CharacterPage.js
Normal file
File diff suppressed because one or more lines are too long
330
apps/web/src/pages/CharacterPage.tsx
Normal file
330
apps/web/src/pages/CharacterPage.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
|
||||||
|
interface CharacterDetail {
|
||||||
|
id: number;
|
||||||
|
character_name: string;
|
||||||
|
class: string | null;
|
||||||
|
race: string | null;
|
||||||
|
level: number;
|
||||||
|
alignment: string | null;
|
||||||
|
pronouns: string | null;
|
||||||
|
portrait_url: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
stats_json: string | null;
|
||||||
|
dndbeyond_id: string | null;
|
||||||
|
dndbeyond_last_sync: string | null;
|
||||||
|
is_active: number;
|
||||||
|
user_id: number;
|
||||||
|
campaign_name: string;
|
||||||
|
campaign_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharStats {
|
||||||
|
str: number; dex: number; con: number;
|
||||||
|
int: number; wis: number; cha: number;
|
||||||
|
maxHp: number | null;
|
||||||
|
proficiencyBonus: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABILITIES: { key: keyof CharStats; label: string; fullName: string }[] = [
|
||||||
|
{ key: "str", label: "STR", fullName: "Strength" },
|
||||||
|
{ key: "dex", label: "DEX", fullName: "Dexterity" },
|
||||||
|
{ key: "con", label: "CON", fullName: "Constitution" },
|
||||||
|
{ key: "int", label: "INT", fullName: "Intelligence" },
|
||||||
|
{ key: "wis", label: "WIS", fullName: "Wisdom" },
|
||||||
|
{ key: "cha", label: "CHA", fullName: "Charisma" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function abilityMod(score: number): string {
|
||||||
|
const m = Math.floor((score - 10) / 2);
|
||||||
|
return m >= 0 ? `+${m}` : `${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CharacterPage() {
|
||||||
|
const { id: campaignId, charId } = useParams<{ id: string; charId: string }>();
|
||||||
|
const [data, setData] = useState<CharacterDetail | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editClass, setEditClass] = useState("");
|
||||||
|
const [editRace, setEditRace] = useState("");
|
||||||
|
const [editLevel, setEditLevel] = useState(1);
|
||||||
|
const [editAlignment, setEditAlignment] = useState("");
|
||||||
|
const [editPronouns, setEditPronouns] = useState("");
|
||||||
|
const [editBio, setEditBio] = useState("");
|
||||||
|
const [editNotes, setEditNotes] = useState("");
|
||||||
|
|
||||||
|
function loadData() {
|
||||||
|
api<CharacterDetail>(`/characters/${charId}`)
|
||||||
|
.then(d => { setData(d); })
|
||||||
|
.catch(e => setError(String(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [charId]);
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (!data) return;
|
||||||
|
setEditName(data.character_name);
|
||||||
|
setEditClass(data.class ?? "");
|
||||||
|
setEditRace(data.race ?? "");
|
||||||
|
setEditLevel(data.level);
|
||||||
|
setEditAlignment(data.alignment ?? "");
|
||||||
|
setEditPronouns(data.pronouns ?? "");
|
||||||
|
setEditBio(data.bio ?? "");
|
||||||
|
setEditNotes(data.notes ?? "");
|
||||||
|
setSaveError("");
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!data) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError("");
|
||||||
|
try {
|
||||||
|
await api(`/characters/${data.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
characterName: editName,
|
||||||
|
class: editClass,
|
||||||
|
race: editRace,
|
||||||
|
level: editLevel,
|
||||||
|
alignment: editAlignment || null,
|
||||||
|
pronouns: editPronouns || null,
|
||||||
|
bio: editBio || null,
|
||||||
|
notes: editNotes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setEditing(false);
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
setSaveError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: CharStats | null = (() => {
|
||||||
|
if (!data?.stats_json) return null;
|
||||||
|
try { return JSON.parse(data.stats_json) as CharStats; } catch { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="alert alert-error">{error}</div>
|
||||||
|
<Link to={`/campaigns/${campaignId}`} className="back-link">← Back to Campaign</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return (
|
||||||
|
<div className="page">
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: "60px" }}>
|
||||||
|
<span className="spinner" style={{ width: "24px", height: "24px" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<Link to={`/campaigns/${campaignId}`} className="back-link">← {data.campaign_name}</Link>
|
||||||
|
|
||||||
|
{/* Hero section */}
|
||||||
|
<div className="char-page-hero">
|
||||||
|
<div className="char-page-portrait-wrap">
|
||||||
|
{data.portrait_url ? (
|
||||||
|
<img src={data.portrait_url} className="char-page-portrait" alt={data.character_name} />
|
||||||
|
) : (
|
||||||
|
<div className="char-page-portrait char-page-portrait-fallback">
|
||||||
|
{data.character_name[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="char-page-hero-info">
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{ fontSize: "28px", fontFamily: "var(--font-heading)", marginBottom: "8px" }}
|
||||||
|
value={editName}
|
||||||
|
onChange={e => setEditName(e.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h1 className="page-title" style={{ marginBottom: "4px" }}>{data.character_name}</h1>
|
||||||
|
)}
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex items-center gap-2" style={{ flexWrap: "wrap", marginBottom: "8px" }}>
|
||||||
|
<input className="form-input" style={{ maxWidth: "160px", fontSize: "15px", padding: "6px 12px" }} placeholder="Class" value={editClass} onChange={e => setEditClass(e.target.value)} />
|
||||||
|
<input className="form-input" style={{ maxWidth: "140px", fontSize: "15px", padding: "6px 12px" }} placeholder="Race" value={editRace} onChange={e => setEditRace(e.target.value)} />
|
||||||
|
<input className="form-input" type="number" style={{ maxWidth: "80px", fontSize: "15px", padding: "6px 12px" }} placeholder="Level" value={editLevel} min={1} max={20} onChange={e => setEditLevel(Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="page-subtitle" style={{ fontSize: "18px", marginBottom: "8px" }}>
|
||||||
|
{[data.class, data.race].filter(Boolean).join(" · ")}
|
||||||
|
{data.level ? ` · Level ${data.level}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2" style={{ flexWrap: "wrap" }}>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<input className="form-input" style={{ maxWidth: "180px", fontSize: "14px", padding: "6px 12px" }} placeholder="Alignment" value={editAlignment} onChange={e => setEditAlignment(e.target.value)} />
|
||||||
|
<input className="form-input" style={{ maxWidth: "140px", fontSize: "14px", padding: "6px 12px" }} placeholder="Pronouns" value={editPronouns} onChange={e => setEditPronouns(e.target.value)} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data.alignment && <span className="cs-alignment-badge" style={{ fontSize: "12px", padding: "3px 10px" }}>{data.alignment}</span>}
|
||||||
|
{data.pronouns && <span className="cs-alignment-badge" style={{ fontSize: "12px", padding: "3px 10px", color: "var(--text-muted)", borderColor: "var(--border)", background: "var(--surface-2)" }}>{data.pronouns}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="char-page-hero-actions">
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)} disabled={saving}>Cancel</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={saveEdit} disabled={saving}>
|
||||||
|
{saving ? <span className="spinner" /> : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={startEdit}>Edit</button>
|
||||||
|
)}
|
||||||
|
{saveError && <div className="alert alert-error" style={{ marginTop: "8px", fontSize: "13px" }}>{saveError}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats section */}
|
||||||
|
<div className="char-page-layout">
|
||||||
|
<div className="char-page-left">
|
||||||
|
{stats && (
|
||||||
|
<div className="card" style={{ marginBottom: "20px" }}>
|
||||||
|
<div className="card-header"><span className="card-title">Quick Stats</span></div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="char-page-quick-stats">
|
||||||
|
{stats.maxHp != null && (
|
||||||
|
<div className="char-page-quick-stat">
|
||||||
|
<div className="char-page-stat-value">{stats.maxHp}</div>
|
||||||
|
<div className="char-page-stat-label">Max HP</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="char-page-quick-stat">
|
||||||
|
<div className="char-page-stat-value">+{stats.proficiencyBonus}</div>
|
||||||
|
<div className="char-page-stat-label">Proficiency</div>
|
||||||
|
</div>
|
||||||
|
<div className="char-page-quick-stat">
|
||||||
|
<div className="char-page-stat-value">{abilityMod(stats.dex)}</div>
|
||||||
|
<div className="char-page-stat-label">Initiative</div>
|
||||||
|
</div>
|
||||||
|
<div className="char-page-quick-stat">
|
||||||
|
<div className="char-page-stat-value">30</div>
|
||||||
|
<div className="char-page-stat-label">Speed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header"><span className="card-title">Ability Scores</span></div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="char-page-ability-grid">
|
||||||
|
{ABILITIES.map(({ key, label, fullName }) => (
|
||||||
|
<div key={key} className="char-page-ability-cell" title={fullName}>
|
||||||
|
<div className="char-page-ability-mod">{abilityMod(stats[key] as number)}</div>
|
||||||
|
<div className="char-page-ability-score">{stats[key] as number}</div>
|
||||||
|
<div className="char-page-ability-label">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!stats && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<p style={{ fontSize: "14px", color: "var(--text-dim)", fontStyle: "italic" }}>
|
||||||
|
{data.dndbeyond_id
|
||||||
|
? "Sync from D&D Beyond to populate ability scores."
|
||||||
|
: "Ability scores not yet recorded for this character."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.dndbeyond_id && (
|
||||||
|
<div className="card" style={{ marginTop: "20px" }}>
|
||||||
|
<div className="card-header"><span className="card-title">D&D Beyond</span></div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p style={{ fontSize: "13px", color: "var(--text-muted)", marginBottom: "12px" }}>
|
||||||
|
Last synced: {data.dndbeyond_last_sync ? new Date(data.dndbeyond_last_sync).toLocaleDateString() : "Never"}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`https://www.dndbeyond.com/characters/${data.dndbeyond_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
>
|
||||||
|
Open on D&D Beyond →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="char-page-right">
|
||||||
|
<div className="card" style={{ marginBottom: "20px" }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">Biography</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{editing ? (
|
||||||
|
<textarea
|
||||||
|
className="form-textarea"
|
||||||
|
rows={6}
|
||||||
|
placeholder="Character backstory, personality traits, ideals, bonds, and flaws…"
|
||||||
|
value={editBio}
|
||||||
|
onChange={e => setEditBio(e.target.value)}
|
||||||
|
/>
|
||||||
|
) : data.bio ? (
|
||||||
|
<p style={{ fontFamily: "var(--font-body)", fontSize: "16px", color: "var(--text-muted)", lineHeight: 1.7, margin: 0 }}>
|
||||||
|
{data.bio}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: "14px", color: "var(--text-dim)", fontStyle: "italic" }}>No biography yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">Private Notes</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{editing ? (
|
||||||
|
<textarea
|
||||||
|
className="form-textarea"
|
||||||
|
rows={5}
|
||||||
|
placeholder="Personal notes about this character (only visible to you)…"
|
||||||
|
value={editNotes}
|
||||||
|
onChange={e => setEditNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
) : data.notes ? (
|
||||||
|
<p style={{ fontFamily: "var(--font-body)", fontSize: "16px", color: "var(--text-muted)", lineHeight: 1.7, margin: 0 }}>
|
||||||
|
{data.notes}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: "14px", color: "var(--text-dim)", fontStyle: "italic" }}>No notes yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
apps/web/src/pages/DashboardPage.js
Normal file
216
apps/web/src/pages/DashboardPage.js
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function parseLocalDate(dateStr) {
|
||||||
|
const [y, m, d] = dateStr.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
return parseLocalDate(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function formatDateLong(dateStr) {
|
||||||
|
return parseLocalDate(dateStr).toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function getWeekday(dateStr) {
|
||||||
|
return parseLocalDate(dateStr).toLocaleDateString("en-US", { weekday: "long" });
|
||||||
|
}
|
||||||
|
function todayStr() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function toLocalDateStr(d) {
|
||||||
|
return [
|
||||||
|
d.getFullYear(),
|
||||||
|
String(d.getMonth() + 1).padStart(2, "0"),
|
||||||
|
String(d.getDate()).padStart(2, "0"),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
|
/** Returns the next unscheduled date that falls on defaultDay (0=Sun…6=Sat). */
|
||||||
|
function nextDefaultNight(defaultDay, scheduledDates) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
// Advance to the nearest upcoming occurrence of defaultDay (today counts)
|
||||||
|
const daysUntil = (defaultDay - d.getDay() + 7) % 7;
|
||||||
|
d.setDate(d.getDate() + daysUntil);
|
||||||
|
// Skip weeks that are already scheduled
|
||||||
|
while (scheduledDates.has(toLocalDateStr(d))) {
|
||||||
|
d.setDate(d.getDate() + 7);
|
||||||
|
}
|
||||||
|
return toLocalDateStr(d);
|
||||||
|
}
|
||||||
|
// ─── Tonight hero card ────────────────────────────────────────────────────────
|
||||||
|
function TonightCard({ entry }) {
|
||||||
|
if (!entry) {
|
||||||
|
return (_jsxs("div", { className: "tonight-card", style: { marginBottom: "24px" }, children: [_jsx("div", { className: "tonight-eyebrow", children: "Tonight" }), _jsx("div", { className: "tonight-empty", children: "No session scheduled for tonight." })] }));
|
||||||
|
}
|
||||||
|
return (_jsxs("div", { className: "tonight-card", style: { marginBottom: "24px" }, children: [_jsxs("div", { className: "tonight-eyebrow", children: ["Tonight \u00B7 ", formatDateLong(entry.date)] }), entry.campaignName ? (entry.campaignId ? (_jsx(Link, { to: `/campaigns/${entry.campaignId}`, className: "tonight-campaign tonight-campaign-link", children: entry.campaignName })) : (_jsx("div", { className: "tonight-campaign", children: entry.campaignName }))) : (_jsx("div", { className: "tonight-empty", children: "No campaign selected yet." })), entry.blackouts.length > 0 && (_jsx("div", { style: { marginTop: "14px", display: "flex", flexWrap: "wrap", gap: "6px" }, children: entry.blackouts.map((b) => (_jsxs("span", { className: "avail-chip", title: b.reason ?? undefined, children: [b.username, " out"] }, b.userId))) }))] }));
|
||||||
|
}
|
||||||
|
// ─── Avatar stack ─────────────────────────────────────────────────────────────
|
||||||
|
const MAX_AVATARS = 8;
|
||||||
|
function AvatarStack({ members, blackoutUserIds, campaignDmUserIds, allIn, }) {
|
||||||
|
const visible = members.slice(0, MAX_AVATARS);
|
||||||
|
const overflow = members.length - MAX_AVATARS;
|
||||||
|
return (_jsx("div", { className: "avatar-stack", children: _jsxs("div", { className: `avatar-stack-group${allIn ? " all-in" : ""}`, title: allIn ? "Everyone's available" : undefined, children: [visible.map((m) => {
|
||||||
|
const isOut = blackoutUserIds.has(m.userId);
|
||||||
|
const isDm = campaignDmUserIds.has(m.userId);
|
||||||
|
return (_jsxs("div", { className: `avatar-wrap${isOut ? " out" : ""}`, title: `${m.username}${isDm ? " (DM)" : ""}${isOut ? " — out" : ""}`, children: [isDm && _jsx("span", { className: "avatar-crown", children: "\uD83D\uDC51" }), m.avatarUrl ? (_jsx("img", { src: m.avatarUrl, className: `avatar-bubble${isOut ? " out" : " in"}`, alt: m.username })) : (_jsx("div", { className: `avatar-bubble avatar-fallback${isOut ? " out" : " in"}`, children: m.username[0].toUpperCase() }))] }, m.userId));
|
||||||
|
}), overflow > 0 && (_jsxs("div", { className: "avatar-overflow", title: `${overflow} more`, children: ["+", overflow] })), allIn && _jsx("span", { className: "avatar-all-in-check", children: "\u2713" })] }) }));
|
||||||
|
}
|
||||||
|
// ─── Tab nav icons ────────────────────────────────────────────────────────────
|
||||||
|
function IconTabBoard() {
|
||||||
|
return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "3", y: "3", width: "7", height: "7" }), _jsx("rect", { x: "14", y: "3", width: "7", height: "7" }), _jsx("rect", { x: "14", y: "14", width: "7", height: "7" }), _jsx("rect", { x: "3", y: "14", width: "7", height: "7" })] }));
|
||||||
|
}
|
||||||
|
function IconTabCharacters() {
|
||||||
|
return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" }), _jsx("circle", { cx: "12", cy: "7", r: "4" })] }));
|
||||||
|
}
|
||||||
|
function IconTabRecaps() {
|
||||||
|
return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("polygon", { points: "23 7 16 12 23 17 23 7" }), _jsx("rect", { x: "1", y: "5", width: "15", height: "14", rx: "2", ry: "2" })] }));
|
||||||
|
}
|
||||||
|
function IconTabWiki() {
|
||||||
|
return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M4 19.5A2.5 2.5 0 016.5 17H20" }), _jsx("path", { d: "M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" })] }));
|
||||||
|
}
|
||||||
|
function IconTabNotes() {
|
||||||
|
return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" }), _jsx("polyline", { points: "14 2 14 8 20 8" }), _jsx("line", { x1: "16", y1: "13", x2: "8", y2: "13" }), _jsx("line", { x1: "16", y1: "17", x2: "8", y2: "17" }), _jsx("line", { x1: "10", y1: "9", x2: "8", y2: "9" })] }));
|
||||||
|
}
|
||||||
|
function ScheduleRow({ entry, role, myUserId, members, availableCampaigns, expanded, saving, onToggle, onAssign, onUnassign, onBlackout, onRemoveBlackout, }) {
|
||||||
|
const canManage = role === "dm" || role === "admin";
|
||||||
|
const myBlackout = entry.blackouts.find((b) => b.userId === myUserId);
|
||||||
|
const blackoutUserIds = new Set(entry.blackouts.map((b) => b.userId));
|
||||||
|
const campaignDmUserIds = new Set(entry.campaignDmUserIds);
|
||||||
|
return (_jsxs("div", { className: `schedule-row${expanded ? " expanded" : ""}`, children: [_jsxs("div", { className: "schedule-row-header", onClick: onToggle, children: [_jsxs("div", { style: { flexShrink: 0 }, children: [_jsx("div", { className: "schedule-date", children: formatDate(entry.date) }), _jsx("div", { className: "schedule-weekday", children: getWeekday(entry.date) })] }), _jsx(AvatarStack, { members: members, blackoutUserIds: blackoutUserIds, campaignDmUserIds: campaignDmUserIds, allIn: entry.blackouts.length === 0 }), _jsxs("div", { className: "flex items-center gap-3", style: { flexShrink: 0 }, children: [entry.campaignId ? (_jsx(Link, { to: `/campaigns/${entry.campaignId}`, className: "schedule-campaign selected schedule-campaign-link", onClick: e => e.stopPropagation(), title: `Open ${entry.campaignName}`, children: entry.campaignName })) : (_jsx("span", { className: "schedule-campaign", children: "No campaign" })), _jsx("span", { className: "schedule-chevron", children: expanded ? "▲" : "▼" })] })] }), expanded && (_jsxs("div", { className: "schedule-expand", children: [_jsxs("div", { className: "expand-row", children: [_jsxs("div", { className: "expand-section", style: { flex: 1 }, children: [_jsx("div", { className: "expand-label", children: "Campaign" }), canManage ? (availableCampaigns.length > 0 ? (_jsxs("div", { className: "flex items-center gap-2", style: { flexWrap: "wrap" }, children: [_jsxs("select", { className: "form-input form-input-sm", value: entry.campaignId ?? "", disabled: saving, onChange: (e) => {
|
||||||
|
if (e.target.value)
|
||||||
|
onAssign(entry.date, Number(e.target.value));
|
||||||
|
}, children: [_jsx("option", { value: "", children: "\u2014 select campaign \u2014" }), availableCampaigns.map((c) => (_jsx("option", { value: c.id, children: c.name }, c.id)))] }), entry.campaignId && (_jsx("button", { className: "btn btn-danger btn-sm", onClick: () => onUnassign(entry.date), disabled: saving, children: "Remove" }))] })) : (_jsxs("span", { className: "text-muted", style: { fontSize: "13px" }, children: ["No campaigns yet \u2014", " ", _jsx("a", { href: "/campaigns", style: { color: "var(--teal)" }, children: "create one first" })] }))) : entry.campaignName ? (_jsx("span", { style: { fontSize: "14px", fontWeight: 600, color: "var(--teal)" }, children: entry.campaignName })) : (_jsx("span", { className: "text-muted", style: { fontSize: "13px" }, children: "No campaign assigned" }))] }), _jsxs("div", { className: "expand-section", children: [_jsx("div", { className: "expand-label", children: "My Availability" }), _jsxs("label", { className: `avail-toggle${saving ? " disabled" : ""}`, children: [_jsx("input", { type: "checkbox", checked: !myBlackout, disabled: saving, onChange: () => myBlackout ? onRemoveBlackout(entry.date) : onBlackout(entry.date) }), _jsx("span", { className: "avail-toggle-track" }), _jsx("span", { className: "avail-toggle-label", children: myBlackout ? "I'm Out" : "I'm In" })] })] })] }), entry.blackouts.filter((b) => b.userId !== myUserId).length > 0 && (_jsx("div", { className: "avail-row", children: entry.blackouts
|
||||||
|
.filter((b) => b.userId !== myUserId)
|
||||||
|
.map((b) => (_jsxs("span", { className: "avail-chip", title: b.reason ?? undefined, children: [b.username, " out"] }, b.userId))) })), entry.campaignId && (_jsxs("div", { className: "expand-section", children: [_jsx("div", { className: "expand-label", style: { textAlign: "center" }, children: "Quick Nav" }), _jsxs("div", { className: "schedule-tab-links", children: [_jsxs(Link, { to: `/campaigns/${entry.campaignId}?tab=board`, className: "schedule-tab-btn", title: "Board", children: [_jsx(IconTabBoard, {}), _jsx("span", { children: "Board" })] }), _jsxs(Link, { to: `/campaigns/${entry.campaignId}?tab=characters`, className: "schedule-tab-btn", title: "Characters", children: [_jsx(IconTabCharacters, {}), _jsx("span", { children: "Characters" })] }), _jsxs(Link, { to: `/campaigns/${entry.campaignId}?tab=recaps`, className: "schedule-tab-btn", title: "Recaps", children: [_jsx(IconTabRecaps, {}), _jsx("span", { children: "Recaps" })] }), _jsxs(Link, { to: `/campaigns/${entry.campaignId}?tab=wiki`, className: "schedule-tab-btn", title: "Wiki", children: [_jsx(IconTabWiki, {}), _jsx("span", { children: "Wiki" })] }), _jsxs(Link, { to: `/campaigns/${entry.campaignId}?tab=notes`, className: "schedule-tab-btn", title: "My Notes", children: [_jsx(IconTabNotes, {}), _jsx("span", { children: "My Notes" })] })] })] }))] }))] }));
|
||||||
|
}
|
||||||
|
// ─── Right panel ──────────────────────────────────────────────────────────────
|
||||||
|
const ROLE_ORDER = ["admin", "dm", "pending_dm", "player"];
|
||||||
|
function roleBadgeClass(role) {
|
||||||
|
if (role === "admin")
|
||||||
|
return "badge badge-admin";
|
||||||
|
if (role === "dm")
|
||||||
|
return "badge badge-dm";
|
||||||
|
if (role === "pending_dm")
|
||||||
|
return "badge badge-pending";
|
||||||
|
return "badge badge-archived";
|
||||||
|
}
|
||||||
|
function RightPanel({ data, saving, onRemoveBlackout, }) {
|
||||||
|
const myBlackouts = data.entries.filter((e) => e.blackouts.some((b) => b.userId === data.myUserId));
|
||||||
|
const sortedMembers = [...data.members].sort((a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role) ||
|
||||||
|
a.username.localeCompare(b.username));
|
||||||
|
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "16px" }, children: [_jsxs("div", { className: "card", children: [_jsxs("div", { className: "card-header", children: [_jsx("span", { className: "card-title", children: "Dates I'm Out" }), myBlackouts.length > 0 && (_jsx("span", { className: "badge badge-pending", children: myBlackouts.length }))] }), _jsx("div", { className: "card-body", children: myBlackouts.length === 0 ? (_jsx("p", { style: { fontSize: "13px", color: "var(--text-muted)", lineHeight: 1.6 }, children: "All clear \u2014 you're in for every session." })) : (_jsx("div", { style: { display: "flex", flexDirection: "column" }, children: myBlackouts.map((e, i) => (_jsxs("div", { className: "flex items-center justify-between", style: {
|
||||||
|
padding: "6px 0",
|
||||||
|
borderBottom: i < myBlackouts.length - 1 ? "1px solid var(--border)" : "none",
|
||||||
|
}, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: "13px", fontWeight: 600 }, children: formatDate(e.date) }), _jsx("div", { style: { fontSize: "11px", color: "var(--text-dim)" }, children: getWeekday(e.date) })] }), _jsx("button", { className: "btn btn-secondary btn-sm", onClick: () => onRemoveBlackout(e.date), disabled: saving !== null, children: "I'm back in" })] }, e.date))) })) })] }), _jsxs("div", { className: "card", children: [_jsxs("div", { className: "card-header", children: [_jsx("span", { className: "card-title", children: "Players" }), _jsx("span", { className: "badge badge-archived", children: sortedMembers.length })] }), _jsx("div", { className: "card-body", style: { padding: "4px 20px 8px" }, children: sortedMembers.map((m) => (_jsxs("div", { className: "roster-row", children: [m.avatarUrl ? (_jsx("img", { src: m.avatarUrl, className: "roster-avatar", alt: m.username })) : (_jsx("div", { className: "roster-avatar-fallback", children: m.username[0] })), _jsx("span", { className: "roster-name", children: m.username }), _jsx("span", { className: roleBadgeClass(m.role), children: m.role === "pending_dm" ? "pending" : m.role })] }, m.userId))) })] })] }));
|
||||||
|
}
|
||||||
|
// ─── Dashboard page ───────────────────────────────────────────────────────────
|
||||||
|
export function DashboardPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [expandedDate, setExpandedDate] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [addDate, setAddDate] = useState("");
|
||||||
|
const [addCampaignId, setAddCampaignId] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api("/dashboard")
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => {
|
||||||
|
toast.addToast("Failed to load dashboard. Please try again.", "error");
|
||||||
|
});
|
||||||
|
}, [toast]);
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
async function run(key, fn) {
|
||||||
|
setSaving(key);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.addToast("Operation failed. Please try again.", "error");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function assignCampaign(date, campaignId) {
|
||||||
|
run(`assign:${date}`, () => api(`/game-nights/${date}/select-campaign`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ campaignId }),
|
||||||
|
})).then(() => {
|
||||||
|
toast.addToast(`Campaign assigned to ${formatDate(date)}`, "success");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function unassignCampaign(date) {
|
||||||
|
run(`unassign:${date}`, () => api(`/game-nights/${date}/campaign`, { method: "DELETE" })).then(() => {
|
||||||
|
toast.addToast("Campaign removed", "success");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function addBlackout(date) {
|
||||||
|
run(`blackout:${date}`, () => api(`/game-nights/${date}/blackout`, { method: "POST", body: JSON.stringify({}) })).then(() => {
|
||||||
|
toast.addToast(`You're marked as out on ${formatDate(date)}`, "info");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeBlackout(date) {
|
||||||
|
run(`rmblackout:${date}`, () => api(`/game-nights/${date}/blackout`, { method: "DELETE" })).then(() => {
|
||||||
|
toast.addToast(`You're back in for ${formatDate(date)}`, "success");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function createNight() {
|
||||||
|
const dateToUse = addDate || suggestedDate;
|
||||||
|
if (!dateToUse)
|
||||||
|
return;
|
||||||
|
setCreating(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const body = { date: dateToUse };
|
||||||
|
if (addCampaignId)
|
||||||
|
body.campaignId = Number(addCampaignId);
|
||||||
|
await api("/game-nights", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
setAddDate(""); // clears any manual override; suggestedDate advances automatically on reload
|
||||||
|
setAddCampaignId("");
|
||||||
|
load();
|
||||||
|
toast.addToast(`Game night scheduled for ${formatDate(dateToUse)}`, "success");
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.addToast("Failed to schedule game night. Please try again.", "error");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auto-suggest the next unscheduled occurrence of the default game day
|
||||||
|
// Must be before any early return to satisfy Rules of Hooks
|
||||||
|
const suggestedDate = useMemo(() => {
|
||||||
|
if (!data || data.defaultGameDay == null)
|
||||||
|
return "";
|
||||||
|
const scheduled = new Set(data.entries.map((e) => e.date));
|
||||||
|
return nextDefaultNight(data.defaultGameDay, scheduled);
|
||||||
|
}, [data]);
|
||||||
|
if (!data) {
|
||||||
|
return (_jsx("div", { className: "page", children: _jsx("div", { style: { display: "flex", justifyContent: "center", padding: "60px" }, children: _jsx("span", { className: "spinner", style: { width: "24px", height: "24px" } }) }) }));
|
||||||
|
}
|
||||||
|
const today = todayStr();
|
||||||
|
const todayEntry = data.entries.find((e) => e.date === today);
|
||||||
|
const upcomingEntries = data.entries.filter((e) => e.date >= today);
|
||||||
|
const role = data.role;
|
||||||
|
const canManage = role === "dm" || role === "admin";
|
||||||
|
const availableCampaigns = role === "admin" ? data.allCampaigns : data.myCampaigns;
|
||||||
|
// The displayed value: user override takes precedence, else the suggestion
|
||||||
|
const displayDate = addDate || suggestedDate;
|
||||||
|
return (_jsxs("div", { className: "page", children: [_jsxs("div", { className: "page-header", children: [_jsx("h1", { className: "page-title", children: "Dashboard" }), _jsx("p", { className: "page-subtitle", children: "Upcoming sessions and shared availability" })] }), _jsxs("div", { className: "dashboard-layout", children: [_jsxs("div", { children: [_jsx(TonightCard, { entry: todayEntry }), _jsxs("div", { className: "card", children: [_jsxs("div", { className: "card-header", children: [_jsx("span", { className: "card-title", children: "Upcoming Schedule" }), upcomingEntries.length > 0 && (_jsx("span", { className: "badge badge-pending", children: upcomingEntries.length }))] }), _jsx("div", { className: "schedule-list", children: upcomingEntries.length === 0 ? (_jsxs("div", { className: "empty-state", children: [_jsx("div", { className: "empty-state-icon", children: "\uD83D\uDCC5" }), _jsx("div", { className: "empty-state-title", children: "Nothing scheduled yet" }), canManage && (_jsx("div", { className: "empty-state-text", children: "Add a date below to schedule your next session." }))] })) : (upcomingEntries.map((entry) => (_jsx(ScheduleRow, { entry: entry, role: role, myUserId: data.myUserId, members: data.members, availableCampaigns: availableCampaigns, expanded: expandedDate === entry.date, saving: saving !== null && saving.endsWith(`:${entry.date}`), onToggle: () => setExpandedDate(expandedDate === entry.date ? null : entry.date), onAssign: assignCampaign, onUnassign: unassignCampaign, onBlackout: addBlackout, onRemoveBlackout: removeBlackout }, entry.date)))) }), canManage && (_jsxs("div", { className: "add-night-form", children: [_jsx("div", { className: "add-night-label", children: "Add a game night" }), _jsxs("div", { className: "flex items-center gap-2", style: { flexWrap: "wrap" }, children: [_jsx("input", { type: "date", className: "form-input", style: { maxWidth: "180px" }, value: displayDate, onChange: (e) => setAddDate(e.target.value) }), availableCampaigns.length > 0 && (_jsxs("select", { className: "form-input", style: { maxWidth: "240px" }, value: addCampaignId, onChange: (e) => setAddCampaignId(e.target.value), children: [_jsx("option", { value: "", children: "campaign (optional)" }), availableCampaigns.map((c) => (_jsx("option", { value: c.id, children: c.name }, c.id)))] })), _jsx("button", { className: "btn btn-primary", onClick: createNight, disabled: !displayDate || creating, children: creating ? _jsx("span", { className: "spinner" }) : "Schedule" })] })] }))] })] }), _jsx(RightPanel, { data: data, saving: saving, onRemoveBlackout: removeBlackout })] })] }));
|
||||||
|
}
|
||||||
748
apps/web/src/pages/DashboardPage.tsx
Normal file
748
apps/web/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,748 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
|
||||||
|
interface BlackoutEntry {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleEntry {
|
||||||
|
date: string;
|
||||||
|
gameNightId: number;
|
||||||
|
campaignId: number | null;
|
||||||
|
campaignName: string | null;
|
||||||
|
blackouts: BlackoutEntry[];
|
||||||
|
campaignDmUserIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
role: string;
|
||||||
|
myUserId: number;
|
||||||
|
defaultGameDay: number | null; // 0=Sun … 6=Sat
|
||||||
|
members: Member[];
|
||||||
|
entries: ScheduleEntry[];
|
||||||
|
myCampaigns: Campaign[];
|
||||||
|
allCampaigns: Campaign[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseLocalDate(dateStr: string): Date {
|
||||||
|
const [y, m, d] = dateStr.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return parseLocalDate(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLong(dateStr: string) {
|
||||||
|
return parseLocalDate(dateStr).toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekday(dateStr: string) {
|
||||||
|
return parseLocalDate(dateStr).toLocaleDateString("en-US", { weekday: "long" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDateStr(d: Date) {
|
||||||
|
return [
|
||||||
|
d.getFullYear(),
|
||||||
|
String(d.getMonth() + 1).padStart(2, "0"),
|
||||||
|
String(d.getDate()).padStart(2, "0"),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the next unscheduled date that falls on defaultDay (0=Sun…6=Sat). */
|
||||||
|
function nextDefaultNight(defaultDay: number, scheduledDates: Set<string>): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
// Advance to the nearest upcoming occurrence of defaultDay (today counts)
|
||||||
|
const daysUntil = (defaultDay - d.getDay() + 7) % 7;
|
||||||
|
d.setDate(d.getDate() + daysUntil);
|
||||||
|
// Skip weeks that are already scheduled
|
||||||
|
while (scheduledDates.has(toLocalDateStr(d))) {
|
||||||
|
d.setDate(d.getDate() + 7);
|
||||||
|
}
|
||||||
|
return toLocalDateStr(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tonight hero card ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TonightCard({ entry }: { entry: ScheduleEntry | undefined }) {
|
||||||
|
if (!entry) {
|
||||||
|
return (
|
||||||
|
<div className="tonight-card" style={{ marginBottom: "24px" }}>
|
||||||
|
<div className="tonight-eyebrow">Tonight</div>
|
||||||
|
<div className="tonight-empty">No session scheduled for tonight.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tonight-card" style={{ marginBottom: "24px" }}>
|
||||||
|
<div className="tonight-eyebrow">Tonight · {formatDateLong(entry.date)}</div>
|
||||||
|
{entry.campaignName ? (
|
||||||
|
entry.campaignId ? (
|
||||||
|
<Link to={`/campaigns/${entry.campaignId}`} className="tonight-campaign tonight-campaign-link">
|
||||||
|
{entry.campaignName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="tonight-campaign">{entry.campaignName}</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="tonight-empty">No campaign selected yet.</div>
|
||||||
|
)}
|
||||||
|
{entry.blackouts.length > 0 && (
|
||||||
|
<div style={{ marginTop: "14px", display: "flex", flexWrap: "wrap", gap: "6px" }}>
|
||||||
|
{entry.blackouts.map((b) => (
|
||||||
|
<span key={b.userId} className="avail-chip" title={b.reason ?? undefined}>
|
||||||
|
{b.username} out
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Avatar stack ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAX_AVATARS = 8;
|
||||||
|
|
||||||
|
function AvatarStack({
|
||||||
|
members,
|
||||||
|
blackoutUserIds,
|
||||||
|
campaignDmUserIds,
|
||||||
|
allIn,
|
||||||
|
}: {
|
||||||
|
members: Member[];
|
||||||
|
blackoutUserIds: Set<number>;
|
||||||
|
campaignDmUserIds: Set<number>;
|
||||||
|
allIn: boolean;
|
||||||
|
}) {
|
||||||
|
const visible = members.slice(0, MAX_AVATARS);
|
||||||
|
const overflow = members.length - MAX_AVATARS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="avatar-stack">
|
||||||
|
<div
|
||||||
|
className={`avatar-stack-group${allIn ? " all-in" : ""}`}
|
||||||
|
title={allIn ? "Everyone's available" : undefined}
|
||||||
|
>
|
||||||
|
{visible.map((m) => {
|
||||||
|
const isOut = blackoutUserIds.has(m.userId);
|
||||||
|
const isDm = campaignDmUserIds.has(m.userId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.userId}
|
||||||
|
className={`avatar-wrap${isOut ? " out" : ""}`}
|
||||||
|
title={`${m.username}${isDm ? " (DM)" : ""}${isOut ? " — out" : ""}`}
|
||||||
|
>
|
||||||
|
{isDm && <span className="avatar-crown">👑</span>}
|
||||||
|
{m.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={m.avatarUrl}
|
||||||
|
className={`avatar-bubble${isOut ? " out" : " in"}`}
|
||||||
|
alt={m.username}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`avatar-bubble avatar-fallback${isOut ? " out" : " in"}`}>
|
||||||
|
{m.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<div className="avatar-overflow" title={`${overflow} more`}>
|
||||||
|
+{overflow}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{allIn && <span className="avatar-all-in-check">✓</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab nav icons ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function IconTabBoard() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTabCharacters() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTabRecaps() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7" />
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTabWiki() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTabNotes() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<line x1="10" y1="9" x2="8" y2="9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Schedule row ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ScheduleRowProps {
|
||||||
|
entry: ScheduleEntry;
|
||||||
|
role: string;
|
||||||
|
myUserId: number;
|
||||||
|
members: Member[];
|
||||||
|
availableCampaigns: Campaign[];
|
||||||
|
expanded: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onAssign: (date: string, campaignId: number) => void;
|
||||||
|
onUnassign: (date: string) => void;
|
||||||
|
onBlackout: (date: string) => void;
|
||||||
|
onRemoveBlackout: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleRow({
|
||||||
|
entry,
|
||||||
|
role,
|
||||||
|
myUserId,
|
||||||
|
members,
|
||||||
|
availableCampaigns,
|
||||||
|
expanded,
|
||||||
|
saving,
|
||||||
|
onToggle,
|
||||||
|
onAssign,
|
||||||
|
onUnassign,
|
||||||
|
onBlackout,
|
||||||
|
onRemoveBlackout,
|
||||||
|
}: ScheduleRowProps) {
|
||||||
|
const canManage = role === "dm" || role === "admin";
|
||||||
|
const myBlackout = entry.blackouts.find((b) => b.userId === myUserId);
|
||||||
|
const blackoutUserIds = new Set(entry.blackouts.map((b) => b.userId));
|
||||||
|
const campaignDmUserIds = new Set(entry.campaignDmUserIds);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`schedule-row${expanded ? " expanded" : ""}`}>
|
||||||
|
<div className="schedule-row-header" onClick={onToggle}>
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<div className="schedule-date">{formatDate(entry.date)}</div>
|
||||||
|
<div className="schedule-weekday">{getWeekday(entry.date)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar stack fills the middle */}
|
||||||
|
<AvatarStack
|
||||||
|
members={members}
|
||||||
|
blackoutUserIds={blackoutUserIds}
|
||||||
|
campaignDmUserIds={campaignDmUserIds}
|
||||||
|
allIn={entry.blackouts.length === 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3" style={{ flexShrink: 0 }}>
|
||||||
|
{entry.campaignId ? (
|
||||||
|
<Link
|
||||||
|
to={`/campaigns/${entry.campaignId}`}
|
||||||
|
className="schedule-campaign selected schedule-campaign-link"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
title={`Open ${entry.campaignName}`}
|
||||||
|
>
|
||||||
|
{entry.campaignName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="schedule-campaign">No campaign</span>
|
||||||
|
)}
|
||||||
|
<span className="schedule-chevron">{expanded ? "▲" : "▼"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="schedule-expand">
|
||||||
|
{/* Campaign + Availability — combined row */}
|
||||||
|
<div className="expand-row">
|
||||||
|
<div className="expand-section" style={{ flex: 1 }}>
|
||||||
|
<div className="expand-label">Campaign</div>
|
||||||
|
{canManage ? (
|
||||||
|
availableCampaigns.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-2" style={{ flexWrap: "wrap" }}>
|
||||||
|
<select
|
||||||
|
className="form-input form-input-sm"
|
||||||
|
value={entry.campaignId ?? ""}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) onAssign(entry.date, Number(e.target.value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— select campaign —</option>
|
||||||
|
{availableCampaigns.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{entry.campaignId && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => onUnassign(entry.date)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted" style={{ fontSize: "13px" }}>
|
||||||
|
No campaigns yet —{" "}
|
||||||
|
<a href="/campaigns" style={{ color: "var(--teal)" }}>
|
||||||
|
create one first
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : entry.campaignName ? (
|
||||||
|
<span style={{ fontSize: "14px", fontWeight: 600, color: "var(--teal)" }}>
|
||||||
|
{entry.campaignName}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted" style={{ fontSize: "13px" }}>No campaign assigned</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="expand-section">
|
||||||
|
<div className="expand-label">My Availability</div>
|
||||||
|
<label className={`avail-toggle${saving ? " disabled" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!myBlackout}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={() =>
|
||||||
|
myBlackout ? onRemoveBlackout(entry.date) : onBlackout(entry.date)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="avail-toggle-track" />
|
||||||
|
<span className="avail-toggle-label">
|
||||||
|
{myBlackout ? "I'm Out" : "I'm In"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Others out */}
|
||||||
|
{entry.blackouts.filter((b) => b.userId !== myUserId).length > 0 && (
|
||||||
|
<div className="avail-row">
|
||||||
|
{entry.blackouts
|
||||||
|
.filter((b) => b.userId !== myUserId)
|
||||||
|
.map((b) => (
|
||||||
|
<span key={b.userId} className="avail-chip" title={b.reason ?? undefined}>
|
||||||
|
{b.username} out
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick-nav tab buttons — only when a campaign is assigned */}
|
||||||
|
{entry.campaignId && (
|
||||||
|
<div className="expand-section">
|
||||||
|
<div className="expand-label" style={{ textAlign: "center" }}>Quick Nav</div>
|
||||||
|
<div className="schedule-tab-links">
|
||||||
|
<Link to={`/campaigns/${entry.campaignId}?tab=board`} className="schedule-tab-btn" title="Board">
|
||||||
|
<IconTabBoard /><span>Board</span>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/campaigns/${entry.campaignId}?tab=characters`} className="schedule-tab-btn" title="Characters">
|
||||||
|
<IconTabCharacters /><span>Characters</span>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/campaigns/${entry.campaignId}?tab=recaps`} className="schedule-tab-btn" title="Recaps">
|
||||||
|
<IconTabRecaps /><span>Recaps</span>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/campaigns/${entry.campaignId}?tab=wiki`} className="schedule-tab-btn" title="Wiki">
|
||||||
|
<IconTabWiki /><span>Wiki</span>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/campaigns/${entry.campaignId}?tab=notes`} className="schedule-tab-btn" title="My Notes">
|
||||||
|
<IconTabNotes /><span>My Notes</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Right panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ROLE_ORDER = ["admin", "dm", "pending_dm", "player"];
|
||||||
|
|
||||||
|
function roleBadgeClass(role: string) {
|
||||||
|
if (role === "admin") return "badge badge-admin";
|
||||||
|
if (role === "dm") return "badge badge-dm";
|
||||||
|
if (role === "pending_dm") return "badge badge-pending";
|
||||||
|
return "badge badge-archived";
|
||||||
|
}
|
||||||
|
|
||||||
|
function RightPanel({
|
||||||
|
data,
|
||||||
|
saving,
|
||||||
|
onRemoveBlackout,
|
||||||
|
}: {
|
||||||
|
data: DashboardData;
|
||||||
|
saving: string | null;
|
||||||
|
onRemoveBlackout: (date: string) => void;
|
||||||
|
}) {
|
||||||
|
const myBlackouts = data.entries.filter((e) =>
|
||||||
|
e.blackouts.some((b) => b.userId === data.myUserId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedMembers = [...data.members].sort(
|
||||||
|
(a, b) =>
|
||||||
|
ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role) ||
|
||||||
|
a.username.localeCompare(b.username)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||||
|
{/* Dates I'm Out */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">Dates I'm Out</span>
|
||||||
|
{myBlackouts.length > 0 && (
|
||||||
|
<span className="badge badge-pending">{myBlackouts.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{myBlackouts.length === 0 ? (
|
||||||
|
<p style={{ fontSize: "13px", color: "var(--text-muted)", lineHeight: 1.6 }}>
|
||||||
|
All clear — you're in for every session.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
{myBlackouts.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={e.date}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
padding: "6px 0",
|
||||||
|
borderBottom:
|
||||||
|
i < myBlackouts.length - 1 ? "1px solid var(--border)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "13px", fontWeight: 600 }}>
|
||||||
|
{formatDate(e.date)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "11px", color: "var(--text-dim)" }}>
|
||||||
|
{getWeekday(e.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => onRemoveBlackout(e.date)}
|
||||||
|
disabled={saving !== null}
|
||||||
|
>
|
||||||
|
I'm back in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guild roster */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">Players</span>
|
||||||
|
<span className="badge badge-archived">{sortedMembers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ padding: "4px 20px 8px" }}>
|
||||||
|
{sortedMembers.map((m) => (
|
||||||
|
<div key={m.userId} className="roster-row">
|
||||||
|
{m.avatarUrl ? (
|
||||||
|
<img src={m.avatarUrl} className="roster-avatar" alt={m.username} />
|
||||||
|
) : (
|
||||||
|
<div className="roster-avatar-fallback">{m.username[0]}</div>
|
||||||
|
)}
|
||||||
|
<span className="roster-name">{m.username}</span>
|
||||||
|
<span className={roleBadgeClass(m.role)}>
|
||||||
|
{m.role === "pending_dm" ? "pending" : m.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dashboard page ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [expandedDate, setExpandedDate] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [addDate, setAddDate] = useState("");
|
||||||
|
const [addCampaignId, setAddCampaignId] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api<DashboardData>("/dashboard")
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => {
|
||||||
|
toast.addToast("Failed to load dashboard. Please try again.", "error");
|
||||||
|
});
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function run(key: string, fn: () => Promise<unknown>) {
|
||||||
|
setSaving(key);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
toast.addToast("Operation failed. Please try again.", "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignCampaign(date: string, campaignId: number) {
|
||||||
|
run(`assign:${date}`, () =>
|
||||||
|
api(`/game-nights/${date}/select-campaign`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ campaignId }),
|
||||||
|
})
|
||||||
|
).then(() => {
|
||||||
|
toast.addToast(`Campaign assigned to ${formatDate(date)}`, "success");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unassignCampaign(date: string) {
|
||||||
|
run(`unassign:${date}`, () =>
|
||||||
|
api(`/game-nights/${date}/campaign`, { method: "DELETE" })
|
||||||
|
).then(() => {
|
||||||
|
toast.addToast("Campaign removed", "success");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBlackout(date: string) {
|
||||||
|
run(`blackout:${date}`, () =>
|
||||||
|
api(`/game-nights/${date}/blackout`, { method: "POST", body: JSON.stringify({}) })
|
||||||
|
).then(() => {
|
||||||
|
toast.addToast(`You're marked as out on ${formatDate(date)}`, "info");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBlackout(date: string) {
|
||||||
|
run(`rmblackout:${date}`, () =>
|
||||||
|
api(`/game-nights/${date}/blackout`, { method: "DELETE" })
|
||||||
|
).then(() => {
|
||||||
|
toast.addToast(`You're back in for ${formatDate(date)}`, "success");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNight() {
|
||||||
|
const dateToUse = addDate || suggestedDate;
|
||||||
|
if (!dateToUse) return;
|
||||||
|
setCreating(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = { date: dateToUse };
|
||||||
|
if (addCampaignId) body.campaignId = Number(addCampaignId);
|
||||||
|
await api("/game-nights", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
setAddDate(""); // clears any manual override; suggestedDate advances automatically on reload
|
||||||
|
setAddCampaignId("");
|
||||||
|
load();
|
||||||
|
toast.addToast(`Game night scheduled for ${formatDate(dateToUse)}`, "success");
|
||||||
|
} catch (e) {
|
||||||
|
toast.addToast("Failed to schedule game night. Please try again.", "error");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-suggest the next unscheduled occurrence of the default game day
|
||||||
|
// Must be before any early return to satisfy Rules of Hooks
|
||||||
|
const suggestedDate = useMemo(() => {
|
||||||
|
if (!data || data.defaultGameDay == null) return "";
|
||||||
|
const scheduled = new Set(data.entries.map((e) => e.date));
|
||||||
|
return nextDefaultNight(data.defaultGameDay, scheduled);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: "60px" }}>
|
||||||
|
<span className="spinner" style={{ width: "24px", height: "24px" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = todayStr();
|
||||||
|
const todayEntry = data.entries.find((e) => e.date === today);
|
||||||
|
const upcomingEntries = data.entries.filter((e) => e.date >= today);
|
||||||
|
const role = data.role;
|
||||||
|
const canManage = role === "dm" || role === "admin";
|
||||||
|
const availableCampaigns = role === "admin" ? data.allCampaigns : data.myCampaigns;
|
||||||
|
|
||||||
|
// The displayed value: user override takes precedence, else the suggestion
|
||||||
|
const displayDate = addDate || suggestedDate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Dashboard</h1>
|
||||||
|
<p className="page-subtitle">Upcoming sessions and shared availability</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-layout">
|
||||||
|
{/* ── Left column: tonight + schedule ── */}
|
||||||
|
<div>
|
||||||
|
<TonightCard entry={todayEntry} />
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">Upcoming Schedule</span>
|
||||||
|
{upcomingEntries.length > 0 && (
|
||||||
|
<span className="badge badge-pending">{upcomingEntries.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="schedule-list">
|
||||||
|
{upcomingEntries.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">📅</div>
|
||||||
|
<div className="empty-state-title">Nothing scheduled yet</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="empty-state-text">
|
||||||
|
Add a date below to schedule your next session.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
upcomingEntries.map((entry) => (
|
||||||
|
<ScheduleRow
|
||||||
|
key={entry.date}
|
||||||
|
entry={entry}
|
||||||
|
role={role}
|
||||||
|
myUserId={data.myUserId}
|
||||||
|
members={data.members}
|
||||||
|
availableCampaigns={availableCampaigns}
|
||||||
|
expanded={expandedDate === entry.date}
|
||||||
|
saving={saving !== null && saving.endsWith(`:${entry.date}`)}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedDate(expandedDate === entry.date ? null : entry.date)
|
||||||
|
}
|
||||||
|
onAssign={assignCampaign}
|
||||||
|
onUnassign={unassignCampaign}
|
||||||
|
onBlackout={addBlackout}
|
||||||
|
onRemoveBlackout={removeBlackout}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add game night — DM and admin only */}
|
||||||
|
{canManage && (
|
||||||
|
<div className="add-night-form">
|
||||||
|
<div className="add-night-label">Add a game night</div>
|
||||||
|
<div className="flex items-center gap-2" style={{ flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: "180px" }}
|
||||||
|
value={displayDate}
|
||||||
|
onChange={(e) => setAddDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{availableCampaigns.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: "240px" }}
|
||||||
|
value={addCampaignId}
|
||||||
|
onChange={(e) => setAddCampaignId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">campaign (optional)</option>
|
||||||
|
{availableCampaigns.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={createNight}
|
||||||
|
disabled={!displayDate || creating}
|
||||||
|
>
|
||||||
|
{creating ? <span className="spinner" /> : "Schedule"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right column: availability + roster ── */}
|
||||||
|
<RightPanel data={data} saving={saving} onRemoveBlackout={removeBlackout} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/web/src/pages/LoginPage.js
Normal file
46
apps/web/src/pages/LoginPage.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
const INVITE_KEY = "dnd_invite_code";
|
||||||
|
export function LoginPage() {
|
||||||
|
const [inviteCode, setInviteCode] = useState(sessionStorage.getItem(INVITE_KEY) ?? "");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [devCode, setDevCode] = useState("");
|
||||||
|
const [showDev, setShowDev] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const toast = useToast();
|
||||||
|
async function handleDiscordLogin() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = inviteCode.trim() ? `?inviteCode=${encodeURIComponent(inviteCode.trim())}` : "";
|
||||||
|
const { authorizeUrl } = await api(`/auth/discord${params}`);
|
||||||
|
// Persist so CallbackPage can send it on the token exchange
|
||||||
|
sessionStorage.setItem(INVITE_KEY, inviteCode.trim());
|
||||||
|
window.location.href = authorizeUrl;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const msg = String(e);
|
||||||
|
toast.addToast(msg.includes("403") ? "Invalid invite code." : "Login failed: " + msg, "error");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function handleDevExchange() {
|
||||||
|
if (!devCode.trim())
|
||||||
|
return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api("/auth/discord/callback", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ code: devCode.trim(), inviteCode: inviteCode.trim() || undefined })
|
||||||
|
});
|
||||||
|
localStorage.setItem("token", result.token);
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.addToast("Code exchange failed: " + String(e), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (_jsx("div", { className: "login-page", children: _jsxs("div", { className: "login-card", children: [_jsx("div", { className: "login-icon", children: "\u2694\uFE0F" }), _jsx("h1", { className: "login-title", children: "Campaign Hub" }), _jsx("p", { className: "login-subtitle", children: "Manage your D&D campaigns, sessions, and characters \u2014 all in one place." }), error && _jsx("div", { className: "alert alert-error", children: error }), _jsxs("div", { className: "form-group", style: { textAlign: "left", marginBottom: "16px" }, children: [_jsx("label", { className: "form-label", children: "Invite Code" }), _jsx("input", { className: "form-input", type: "password", value: inviteCode, onChange: e => setInviteCode(e.target.value), placeholder: "Enter invite code", onKeyDown: e => e.key === "Enter" && handleDiscordLogin() })] }), _jsxs("button", { className: "discord-btn", onClick: handleDiscordLogin, disabled: loading, children: [loading ? (_jsx("span", { className: "spinner" })) : (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 71 55", fill: "white", children: _jsx("path", { d: "M60.1 4.9A58.5 58.5 0 0045.8 0c-.6 1.2-1.4 2.8-1.9 4a54.2 54.2 0 00-16.3 0C27 2.8 26.2 1.2 25.5 0A58.3 58.3 0 0011.2 4.9C1.6 19.6-1 34 .3 48.2A58.9 58.9 0 0017.9 55c1.4-2 2.7-4.1 3.8-6.3a38.3 38.3 0 01-6-2.9l1.5-1.2a42 42 0 0036.3 0l1.5 1.2a38 38 0 01-6 2.9c1.1 2.2 2.4 4.3 3.8 6.3A58.7 58.7 0 0070.7 48.2C72.2 31.8 67.8 17.5 60.1 4.9zM23.7 39.5c-3.5 0-6.4-3.3-6.4-7.3s2.8-7.3 6.4-7.3 6.5 3.3 6.4 7.3c0 4-2.8 7.3-6.4 7.3zm23.6 0c-3.5 0-6.4-3.3-6.4-7.3s2.8-7.3 6.4-7.3 6.5 3.3 6.4 7.3c0 4-2.8 7.3-6.4 7.3z" }) })), loading ? "Redirecting…" : "Sign in with Discord"] }), _jsx("div", { className: "divider", children: "or" }), _jsx("button", { className: "btn btn-secondary w-full", onClick: () => setShowDev(!showDev), style: { fontSize: "12px", color: "var(--text-dim)" }, children: showDev ? "Hide" : "Manual code exchange (dev)" }), showDev && (_jsxs("div", { style: { marginTop: "12px", display: "flex", flexDirection: "column", gap: "8px" }, children: [_jsx("input", { className: "form-input", value: devCode, onChange: e => setDevCode(e.target.value), placeholder: "Paste OAuth code here" }), _jsx("button", { className: "btn btn-primary w-full", onClick: handleDevExchange, children: "Exchange Code" })] }))] }) }));
|
||||||
|
}
|
||||||
106
apps/web/src/pages/LoginPage.tsx
Normal file
106
apps/web/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useToast } from "../contexts/ToastContext";
|
||||||
|
|
||||||
|
const INVITE_KEY = "dnd_invite_code";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const [inviteCode, setInviteCode] = useState(sessionStorage.getItem(INVITE_KEY) ?? "");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [devCode, setDevCode] = useState("");
|
||||||
|
const [showDev, setShowDev] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
async function handleDiscordLogin() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = inviteCode.trim() ? `?inviteCode=${encodeURIComponent(inviteCode.trim())}` : "";
|
||||||
|
const { authorizeUrl } = await api<{ authorizeUrl: string }>(`/auth/discord${params}`);
|
||||||
|
// Persist so CallbackPage can send it on the token exchange
|
||||||
|
sessionStorage.setItem(INVITE_KEY, inviteCode.trim());
|
||||||
|
window.location.href = authorizeUrl;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e);
|
||||||
|
toast.addToast(msg.includes("403") ? "Invalid invite code." : "Login failed: " + msg, "error");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDevExchange() {
|
||||||
|
if (!devCode.trim()) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api<{ token: string }>("/auth/discord/callback", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ code: devCode.trim(), inviteCode: inviteCode.trim() || undefined })
|
||||||
|
});
|
||||||
|
localStorage.setItem("token", result.token);
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
} catch (e) {
|
||||||
|
toast.addToast("Code exchange failed: " + String(e), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-icon">⚔️</div>
|
||||||
|
<h1 className="login-title">Campaign Hub</h1>
|
||||||
|
<p className="login-subtitle">
|
||||||
|
Manage your D&D campaigns, sessions, and characters — all in one place.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-group" style={{ textAlign: "left", marginBottom: "16px" }}>
|
||||||
|
<label className="form-label">Invite Code</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={e => setInviteCode(e.target.value)}
|
||||||
|
placeholder="Enter invite code"
|
||||||
|
onKeyDown={e => e.key === "Enter" && handleDiscordLogin()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="discord-btn" onClick={handleDiscordLogin} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<span className="spinner" />
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 71 55" fill="white">
|
||||||
|
<path d="M60.1 4.9A58.5 58.5 0 0045.8 0c-.6 1.2-1.4 2.8-1.9 4a54.2 54.2 0 00-16.3 0C27 2.8 26.2 1.2 25.5 0A58.3 58.3 0 0011.2 4.9C1.6 19.6-1 34 .3 48.2A58.9 58.9 0 0017.9 55c1.4-2 2.7-4.1 3.8-6.3a38.3 38.3 0 01-6-2.9l1.5-1.2a42 42 0 0036.3 0l1.5 1.2a38 38 0 01-6 2.9c1.1 2.2 2.4 4.3 3.8 6.3A58.7 58.7 0 0070.7 48.2C72.2 31.8 67.8 17.5 60.1 4.9zM23.7 39.5c-3.5 0-6.4-3.3-6.4-7.3s2.8-7.3 6.4-7.3 6.5 3.3 6.4 7.3c0 4-2.8 7.3-6.4 7.3zm23.6 0c-3.5 0-6.4-3.3-6.4-7.3s2.8-7.3 6.4-7.3 6.5 3.3 6.4 7.3c0 4-2.8 7.3-6.4 7.3z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{loading ? "Redirecting…" : "Sign in with Discord"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="divider">or</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary w-full"
|
||||||
|
onClick={() => setShowDev(!showDev)}
|
||||||
|
style={{ fontSize: "12px", color: "var(--text-dim)" }}
|
||||||
|
>
|
||||||
|
{showDev ? "Hide" : "Manual code exchange (dev)"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDev && (
|
||||||
|
<div style={{ marginTop: "12px", display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={devCode}
|
||||||
|
onChange={e => setDevCode(e.target.value)}
|
||||||
|
placeholder="Paste OAuth code here"
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary w-full" onClick={handleDevExchange}>
|
||||||
|
Exchange Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/web/src/smoke.test.js
Normal file
6
apps/web/src/smoke.test.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
describe("web smoke", () => {
|
||||||
|
it("runs tests", () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/web/src/smoke.test.ts
Normal file
8
apps/web/src/smoke.test.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("web smoke", () => {
|
||||||
|
it("runs tests", () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
2918
apps/web/src/styles.css
Normal file
2918
apps/web/src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
13
apps/web/tsconfig.json
Normal file
13
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
||||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/smoke.test.ts","./src/api/client.ts","./src/components/charactersheetdrawer.tsx","./src/components/srddrawer.tsx","./src/components/toast.tsx","./src/contexts/charactersheetcontext.tsx","./src/contexts/debugcontext.tsx","./src/contexts/toastcontext.tsx","./src/pages/adminpage.tsx","./src/pages/callbackpage.tsx","./src/pages/campaigndetailpage.tsx","./src/pages/campaignspage.tsx","./src/pages/characterpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx"],"version":"5.9.3"}
|
||||||
10
apps/web/vite.config.ts
Normal file
10
apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: infra/server.Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
volumes:
|
||||||
|
- ./apps/server/data:/app/apps/server/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: infra/frontend.Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
14
infra/frontend.Dockerfile
Normal file
14
infra/frontend.Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
COPY apps/web/package.json apps/web/package.json
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build -w @dnd-hub/web
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY infra/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
|
|
||||||
10
infra/nginx.conf
Normal file
10
infra/nginx.conf
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
12
infra/server.Dockerfile
Normal file
12
infra/server.Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
COPY apps/server/package.json apps/server/package.json
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build -w @dnd-hub/server
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npm run migrate -w @dnd-hub/server && npm run start -w @dnd-hub/server"]
|
||||||
|
|
||||||
7897
package-lock.json
generated
Normal file
7897
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
package.json
Normal file
21
package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "dnd-hub",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/server",
|
||||||
|
"apps/web"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,web -c cyan,magenta \"npm run dev -w @dnd-hub/server\" \"npm run dev -w @dnd-hub/web\"",
|
||||||
|
"build": "npm run build -w @dnd-hub/server && npm run build -w @dnd-hub/web",
|
||||||
|
"test": "npm run test -w @dnd-hub/server && npm run test -w @dnd-hub/web"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/voice": "^0.19.1",
|
||||||
|
"libsodium-wrappers": "^0.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue