logic commit

This commit is contained in:
Lukas Parsons 2026-03-16 22:15:15 -04:00
parent dd6495874f
commit 81a9d3c7b3
98 changed files with 20848 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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 ?? ""
};

View 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");

View 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");

View 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)
);

View 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)
);

View 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)
);

View 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

View 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);

View 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;

View 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;

View 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;

View file

@ -0,0 +1 @@
ALTER TABLE characters ADD COLUMN stats_json TEXT;

View 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
View 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);
});

View 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();
};
}

View 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
}
});
});

View 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) });
});

View 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 });
});

View 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 });
}
);

View 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" });
}
});

View 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 });
}
);

View 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) });
}
}
);

View 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 });
});

View 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 });
});

View 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);
}
};

View 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);
}
}
};

View 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);
}
};

View 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 });
}
};

View 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);
}
};

View file

@ -0,0 +1,3 @@
export { recapService } from "./recapService.js";
export { selectProvider } from "./providerRegistry.js";
export type { SummaryProvider, RecapContext, RecapParticipant, RecapSegment } from "./types.js";

View 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 35 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:`;
}

View 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;
}

View 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;
}
};

View 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;
}
};

View 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");
}
};

View 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 };
}
};

View 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>;
}

View 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 };
}
};

View 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);
}
};

View 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());
}
};

View 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
View file

@ -0,0 +1,8 @@
export type Role = "player" | "dm" | "admin" | "pending_dm";
export interface AuthContext {
userId: number;
discordUserId: string;
guildId: number;
}

View 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");
});
});

View 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");
});
});

View 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");
});
});

View 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
View 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
View 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
View 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
View 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
View 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&amp;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>
);
}

View 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());
}

View 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;
}

View 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" })] })] }));
}

View 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>
);
}

View 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)))] })] })] }));
}

View 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>
);
}

View 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 "";
}
}

View 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>
);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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 }));
}

View 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
View 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
View 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>
);

View 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)))) })] }))] })] }));
}

View 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>
);
}

View 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" })] }) }));
}

View 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>
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View 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))) }))] }));
}

View 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>
);
}

File diff suppressed because one or more lines are too long

View 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>
);
}

View 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 })] })] }));
}

View 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>
);
}

View 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" })] }))] }) }));
}

View 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&amp;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>
);
}

View file

@ -0,0 +1,6 @@
import { describe, expect, it } from "vitest";
describe("web smoke", () => {
it("runs tests", () => {
expect(true).toBe(true);
});
});

View 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

File diff suppressed because it is too large Load diff

13
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

21
package.json Normal file
View 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"
}
}