3214 lines
75 KiB
Markdown
3214 lines
75 KiB
Markdown
# Shadowdark Character Sheet Manager — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a real-time web app for managing Shadowdark RPG character sheets across a group of players.
|
||
|
||
**Architecture:** React SPA frontend communicating with a Node/Express + Socket.IO backend. SQLite for persistence. All mutations go through REST endpoints which save to DB then broadcast via Socket.IO to all clients in the same campaign room.
|
||
|
||
**Tech Stack:** React 18, Vite, TypeScript, Node.js, Express, Socket.IO, better-sqlite3, CSS Modules
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
shadowdark/
|
||
├── client/
|
||
│ ├── index.html
|
||
│ ├── package.json
|
||
│ ├── tsconfig.json
|
||
│ ├── vite.config.ts
|
||
│ └── src/
|
||
│ ├── main.tsx
|
||
│ ├── App.tsx
|
||
│ ├── App.module.css
|
||
│ ├── socket.ts # Socket.IO client singleton
|
||
│ ├── api.ts # REST API helper functions
|
||
│ ├── types.ts # Shared TypeScript types
|
||
│ ├── utils/
|
||
│ │ └── modifiers.ts # Ability score -> modifier lookup
|
||
│ ├── pages/
|
||
│ │ ├── CampaignList.tsx # Home page — list/create campaigns
|
||
│ │ ├── CampaignList.module.css
|
||
│ │ ├── CampaignView.tsx # Main campaign screen with character grid
|
||
│ │ └── CampaignView.module.css
|
||
│ └── components/
|
||
│ ├── CharacterCard.tsx # Compact card in the grid
|
||
│ ├── CharacterCard.module.css
|
||
│ ├── CharacterDetail.tsx # Expanded modal for full editing
|
||
│ ├── CharacterDetail.module.css
|
||
│ ├── StatBlock.tsx # 3x2 stat display with +/- buttons
|
||
│ ├── StatBlock.module.css
|
||
│ ├── GearList.tsx # Gear/inventory management
|
||
│ ├── GearList.module.css
|
||
│ ├── TalentList.tsx # Talents management
|
||
│ ├── TalentList.module.css
|
||
│ ├── HpBar.tsx # HP current/max with +/- buttons
|
||
│ └── HpBar.module.css
|
||
├── server/
|
||
│ ├── package.json
|
||
│ ├── tsconfig.json
|
||
│ ├── data/ # SQLite DB lives here (created at runtime)
|
||
│ └── src/
|
||
│ ├── index.ts # Express + Socket.IO server entry
|
||
│ ├── db.ts # SQLite setup, schema init, query helpers
|
||
│ ├── routes/
|
||
│ │ ├── campaigns.ts # CRUD routes for campaigns
|
||
│ │ └── characters.ts # CRUD routes for characters, stats, gear, talents
|
||
│ └── socket.ts # Socket.IO room management and broadcast logic
|
||
└── package.json # Root package.json with dev scripts
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Project Scaffolding
|
||
|
||
**Files:**
|
||
|
||
- Create: `package.json` (root)
|
||
- Create: `server/package.json`
|
||
- Create: `server/tsconfig.json`
|
||
- Create: `client/package.json`
|
||
- Create: `client/tsconfig.json`
|
||
- Create: `client/vite.config.ts`
|
||
- Create: `client/index.html`
|
||
|
||
- [ ] **Step 1: Create root package.json**
|
||
|
||
```json
|
||
{
|
||
"name": "shadowdark",
|
||
"private": true,
|
||
"scripts": {
|
||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||
"dev:server": "cd server && npm run dev",
|
||
"dev:client": "cd client && npm run dev"
|
||
},
|
||
"devDependencies": {
|
||
"concurrently": "^9.1.2"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create server/package.json**
|
||
|
||
```json
|
||
{
|
||
"name": "shadowdark-server",
|
||
"private": true,
|
||
"scripts": {
|
||
"dev": "tsx watch src/index.ts",
|
||
"build": "tsc",
|
||
"start": "node dist/index.js"
|
||
},
|
||
"dependencies": {
|
||
"better-sqlite3": "^11.7.0",
|
||
"cors": "^2.8.5",
|
||
"express": "^4.21.2",
|
||
"socket.io": "^4.8.1"
|
||
},
|
||
"devDependencies": {
|
||
"@types/better-sqlite3": "^7.6.12",
|
||
"@types/cors": "^2.8.17",
|
||
"@types/express": "^5.0.0",
|
||
"tsx": "^4.19.0",
|
||
"typescript": "^5.7.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create server/tsconfig.json**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"module": "ES2022",
|
||
"moduleResolution": "bundler",
|
||
"outDir": "dist",
|
||
"rootDir": "src",
|
||
"strict": true,
|
||
"esModuleInterop": true,
|
||
"skipLibCheck": true,
|
||
"resolveJsonModule": true
|
||
},
|
||
"include": ["src"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create client/package.json**
|
||
|
||
```json
|
||
{
|
||
"name": "shadowdark-client",
|
||
"private": true,
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc && vite build",
|
||
"preview": "vite preview"
|
||
},
|
||
"dependencies": {
|
||
"react": "^18.3.1",
|
||
"react-dom": "^18.3.1",
|
||
"react-router-dom": "^7.1.1",
|
||
"socket.io-client": "^4.8.1"
|
||
},
|
||
"devDependencies": {
|
||
"@types/react": "^18.3.18",
|
||
"@types/react-dom": "^18.3.5",
|
||
"@vitejs/plugin-react": "^4.3.4",
|
||
"typescript": "^5.7.0",
|
||
"vite": "^6.0.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create client/tsconfig.json**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2020",
|
||
"module": "ESNext",
|
||
"moduleResolution": "bundler",
|
||
"jsx": "react-jsx",
|
||
"strict": true,
|
||
"esModuleInterop": true,
|
||
"skipLibCheck": true,
|
||
"resolveJsonModule": true
|
||
},
|
||
"include": ["src"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Create client/vite.config.ts**
|
||
|
||
```ts
|
||
import { defineConfig } from "vite";
|
||
import react from "@vitejs/plugin-react";
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
server: {
|
||
port: 5173,
|
||
proxy: {
|
||
"/api": "http://localhost:3000",
|
||
"/socket.io": {
|
||
target: "http://localhost:3000",
|
||
ws: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Create client/index.html**
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Shadowdark Character Manager</title>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 8: Install dependencies**
|
||
|
||
Run from project root:
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark && npm install
|
||
cd /Users/aaron.wood/workspace/shadowdark/server && npm install
|
||
cd /Users/aaron.wood/workspace/shadowdark/client && npm install
|
||
```
|
||
|
||
Expected: All three install successfully with no errors.
|
||
|
||
---
|
||
|
||
### Task 2: Database Layer
|
||
|
||
**Files:**
|
||
|
||
- Create: `server/src/db.ts`
|
||
|
||
- [ ] **Step 1: Create server/src/db.ts with schema initialization**
|
||
|
||
```ts
|
||
import Database from "better-sqlite3";
|
||
import path from "path";
|
||
import fs from "fs";
|
||
|
||
const DATA_DIR = path.join(import.meta.dirname, "..", "data");
|
||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
|
||
const db = new Database(path.join(DATA_DIR, "shadowdark.db"));
|
||
|
||
db.pragma("journal_mode = WAL");
|
||
db.pragma("foreign_keys = ON");
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS campaigns (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
created_by TEXT DEFAULT '',
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS characters (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
||
created_by TEXT DEFAULT '',
|
||
name TEXT NOT NULL,
|
||
class TEXT NOT NULL DEFAULT 'Fighter',
|
||
ancestry TEXT NOT NULL DEFAULT 'Human',
|
||
level INTEGER NOT NULL DEFAULT 1,
|
||
xp INTEGER NOT NULL DEFAULT 0,
|
||
hp_current INTEGER NOT NULL DEFAULT 0,
|
||
hp_max INTEGER NOT NULL DEFAULT 0,
|
||
ac INTEGER NOT NULL DEFAULT 10,
|
||
alignment TEXT NOT NULL DEFAULT 'Neutral',
|
||
title TEXT DEFAULT '',
|
||
notes TEXT DEFAULT ''
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS character_stats (
|
||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||
stat_name TEXT NOT NULL,
|
||
value INTEGER NOT NULL DEFAULT 10,
|
||
PRIMARY KEY (character_id, stat_name)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS character_gear (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||
name TEXT NOT NULL,
|
||
type TEXT NOT NULL DEFAULT 'gear',
|
||
slot_count INTEGER NOT NULL DEFAULT 1,
|
||
properties TEXT DEFAULT '{}'
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS character_talents (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||
name TEXT NOT NULL,
|
||
description TEXT DEFAULT '',
|
||
effect TEXT DEFAULT '{}'
|
||
);
|
||
`);
|
||
|
||
export default db;
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the database initializes**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/db.ts
|
||
```
|
||
|
||
Expected: No errors. File `server/data/shadowdark.db` is created.
|
||
|
||
---
|
||
|
||
### Task 3: Server Entry + Socket.IO Setup
|
||
|
||
**Files:**
|
||
|
||
- Create: `server/src/index.ts`
|
||
- Create: `server/src/socket.ts`
|
||
|
||
- [ ] **Step 1: Create server/src/socket.ts**
|
||
|
||
```ts
|
||
import { Server } from "socket.io";
|
||
|
||
export function setupSocket(io: Server) {
|
||
io.on("connection", (socket) => {
|
||
socket.on("join-campaign", (campaignId: string) => {
|
||
socket.join(`campaign:${campaignId}`);
|
||
});
|
||
|
||
socket.on("leave-campaign", (campaignId: string) => {
|
||
socket.leave(`campaign:${campaignId}`);
|
||
});
|
||
|
||
socket.on("disconnect", () => {
|
||
// Rooms are cleaned up automatically by Socket.IO
|
||
});
|
||
});
|
||
}
|
||
|
||
export function broadcastToCampaign(
|
||
io: Server,
|
||
campaignId: number,
|
||
event: string,
|
||
data: unknown
|
||
) {
|
||
io.to(`campaign:${campaignId}`).emit(event, data);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create server/src/index.ts**
|
||
|
||
```ts
|
||
import express from "express";
|
||
import cors from "cors";
|
||
import { createServer } from "http";
|
||
import { Server } from "socket.io";
|
||
import { setupSocket } from "./socket.js";
|
||
|
||
const app = express();
|
||
const httpServer = createServer(app);
|
||
const io = new Server(httpServer, {
|
||
cors: { origin: "*" },
|
||
});
|
||
|
||
app.use(cors());
|
||
app.use(express.json());
|
||
|
||
// Make io accessible to route handlers
|
||
app.set("io", io);
|
||
|
||
setupSocket(io);
|
||
|
||
const PORT = process.env.PORT || 3000;
|
||
httpServer.listen(PORT, () => {
|
||
console.log(`Shadowdark server running on http://localhost:${PORT}`);
|
||
});
|
||
|
||
export { io };
|
||
```
|
||
|
||
- [ ] **Step 3: Verify server starts**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts
|
||
```
|
||
|
||
Expected: Console prints `Shadowdark server running on http://localhost:3000`. Kill with Ctrl+C.
|
||
|
||
---
|
||
|
||
### Task 4: Campaign API Routes
|
||
|
||
**Files:**
|
||
|
||
- Create: `server/src/routes/campaigns.ts`
|
||
- Modify: `server/src/index.ts` (register routes)
|
||
|
||
- [ ] **Step 1: Create server/src/routes/campaigns.ts**
|
||
|
||
```ts
|
||
import { Router } from "express";
|
||
import db from "../db.js";
|
||
|
||
const router = Router();
|
||
|
||
// GET /api/campaigns — list all campaigns
|
||
router.get("/", (_req, res) => {
|
||
const campaigns = db
|
||
.prepare("SELECT * FROM campaigns ORDER BY created_at DESC")
|
||
.all();
|
||
res.json(campaigns);
|
||
});
|
||
|
||
// POST /api/campaigns — create a campaign
|
||
router.post("/", (req, res) => {
|
||
const { name } = req.body;
|
||
if (!name || !name.trim()) {
|
||
res.status(400).json({ error: "Campaign name is required" });
|
||
return;
|
||
}
|
||
const result = db
|
||
.prepare("INSERT INTO campaigns (name) VALUES (?)")
|
||
.run(name.trim());
|
||
const campaign = db
|
||
.prepare("SELECT * FROM campaigns WHERE id = ?")
|
||
.get(result.lastInsertRowid);
|
||
res.status(201).json(campaign);
|
||
});
|
||
|
||
// GET /api/campaigns/:id — get a single campaign
|
||
router.get("/:id", (req, res) => {
|
||
const campaign = db
|
||
.prepare("SELECT * FROM campaigns WHERE id = ?")
|
||
.get(req.params.id);
|
||
if (!campaign) {
|
||
res.status(404).json({ error: "Campaign not found" });
|
||
return;
|
||
}
|
||
res.json(campaign);
|
||
});
|
||
|
||
// DELETE /api/campaigns/:id — delete a campaign (cascades to characters)
|
||
router.delete("/:id", (req, res) => {
|
||
const result = db
|
||
.prepare("DELETE FROM campaigns WHERE id = ?")
|
||
.run(req.params.id);
|
||
if (result.changes === 0) {
|
||
res.status(404).json({ error: "Campaign not found" });
|
||
return;
|
||
}
|
||
res.status(204).end();
|
||
});
|
||
|
||
export default router;
|
||
```
|
||
|
||
- [ ] **Step 2: Register campaign routes in server/src/index.ts**
|
||
|
||
Add these lines before the `httpServer.listen()` call:
|
||
|
||
```ts
|
||
import campaignRoutes from "./routes/campaigns.js";
|
||
|
||
app.use("/api/campaigns", campaignRoutes);
|
||
```
|
||
|
||
- [ ] **Step 3: Test campaign endpoints manually**
|
||
|
||
Run server:
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts &
|
||
```
|
||
|
||
Test:
|
||
|
||
```bash
|
||
# Create
|
||
curl -s -X POST http://localhost:3000/api/campaigns -H 'Content-Type: application/json' -d '{"name":"Test Campaign"}' | jq .
|
||
|
||
# List
|
||
curl -s http://localhost:3000/api/campaigns | jq .
|
||
|
||
# Get
|
||
curl -s http://localhost:3000/api/campaigns/1 | jq .
|
||
|
||
# Delete
|
||
curl -s -X DELETE http://localhost:3000/api/campaigns/1 -w "%{http_code}"
|
||
```
|
||
|
||
Expected: 201 with campaign object, 200 with array, 200 with object, 204.
|
||
|
||
Kill server after testing.
|
||
|
||
---
|
||
|
||
### Task 5: Character API Routes
|
||
|
||
**Files:**
|
||
|
||
- Create: `server/src/routes/characters.ts`
|
||
- Modify: `server/src/index.ts` (register routes)
|
||
|
||
- [ ] **Step 1: Create server/src/routes/characters.ts**
|
||
|
||
```ts
|
||
import { Router } from "express";
|
||
import type { Server } from "socket.io";
|
||
import db from "../db.js";
|
||
import { broadcastToCampaign } from "../socket.js";
|
||
|
||
const router = Router();
|
||
|
||
const DEFAULT_STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
|
||
|
||
// GET /api/campaigns/:campaignId/characters — list characters in a campaign
|
||
router.get("/", (req, res) => {
|
||
const { campaignId } = req.params;
|
||
const characters = db
|
||
.prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name")
|
||
.all(campaignId) as Array<Record<string, unknown>>;
|
||
|
||
// Attach stats, gear, and talents to each character
|
||
const stmtStats = db.prepare(
|
||
"SELECT stat_name, value FROM character_stats WHERE character_id = ?"
|
||
);
|
||
const stmtGear = db.prepare(
|
||
"SELECT * FROM character_gear WHERE character_id = ?"
|
||
);
|
||
const stmtTalents = db.prepare(
|
||
"SELECT * FROM character_talents WHERE character_id = ?"
|
||
);
|
||
|
||
const enriched = characters.map((char) => ({
|
||
...char,
|
||
stats: stmtStats.all(char.id),
|
||
gear: stmtGear.all(char.id),
|
||
talents: stmtTalents.all(char.id),
|
||
}));
|
||
|
||
res.json(enriched);
|
||
});
|
||
|
||
// POST /api/campaigns/:campaignId/characters — create a character
|
||
router.post("/", (req, res) => {
|
||
const { campaignId } = req.params;
|
||
const { name, class: charClass, ancestry, hp_max } = req.body;
|
||
|
||
if (!name || !name.trim()) {
|
||
res.status(400).json({ error: "Character name is required" });
|
||
return;
|
||
}
|
||
|
||
const insertChar = db.prepare(`
|
||
INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
`);
|
||
|
||
const insertStat = db.prepare(`
|
||
INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)
|
||
`);
|
||
|
||
const result = insertChar.run(
|
||
campaignId,
|
||
name.trim(),
|
||
charClass || "Fighter",
|
||
ancestry || "Human",
|
||
hp_max || 0,
|
||
hp_max || 0
|
||
);
|
||
const characterId = result.lastInsertRowid;
|
||
|
||
for (const stat of DEFAULT_STATS) {
|
||
insertStat.run(characterId, stat);
|
||
}
|
||
|
||
const character = db
|
||
.prepare("SELECT * FROM characters WHERE id = ?")
|
||
.get(characterId);
|
||
const stats = db
|
||
.prepare(
|
||
"SELECT stat_name, value FROM character_stats WHERE character_id = ?"
|
||
)
|
||
.all(characterId);
|
||
|
||
const enriched = {
|
||
...(character as Record<string, unknown>),
|
||
stats,
|
||
gear: [],
|
||
talents: [],
|
||
};
|
||
|
||
const io: Server = req.app.get("io");
|
||
broadcastToCampaign(io, Number(campaignId), "character:created", enriched);
|
||
|
||
res.status(201).json(enriched);
|
||
});
|
||
|
||
// PATCH /api/characters/:id — update character fields
|
||
router.patch("/:id", (req, res) => {
|
||
const { id } = req.params;
|
||
const allowedFields = [
|
||
"name",
|
||
"class",
|
||
"ancestry",
|
||
"level",
|
||
"xp",
|
||
"hp_current",
|
||
"hp_max",
|
||
"ac",
|
||
"alignment",
|
||
"title",
|
||
"notes",
|
||
];
|
||
|
||
const updates: string[] = [];
|
||
const values: unknown[] = [];
|
||
|
||
for (const field of allowedFields) {
|
||
if (req.body[field] !== undefined) {
|
||
updates.push(`${field} = ?`);
|
||
values.push(req.body[field]);
|
||
}
|
||
}
|
||
|
||
if (updates.length === 0) {
|
||
res.status(400).json({ error: "No valid fields to update" });
|
||
return;
|
||
}
|
||
|
||
values.push(id);
|
||
db.prepare(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`).run(
|
||
...values
|
||
);
|
||
|
||
const character = db
|
||
.prepare("SELECT * FROM characters WHERE id = ?")
|
||
.get(id) as Record<string, unknown>;
|
||
if (!character) {
|
||
res.status(404).json({ error: "Character not found" });
|
||
return;
|
||
}
|
||
|
||
const io: Server = req.app.get("io");
|
||
broadcastToCampaign(io, Number(character.campaign_id), "character:updated", {
|
||
id: Number(id),
|
||
...req.body,
|
||
});
|
||
|
||
res.json(character);
|
||
});
|
||
|
||
// DELETE /api/characters/:id — delete a character
|
||
router.delete("/:id", (req, res) => {
|
||
const character = db
|
||
.prepare("SELECT * FROM characters WHERE id = ?")
|
||
.get(req.params.id) as Record<string, unknown> | undefined;
|
||
if (!character) {
|
||
res.status(404).json({ error: "Character not found" });
|
||
return;
|
||
}
|
||
|
||
db.prepare("DELETE FROM characters WHERE id = ?").run(req.params.id);
|
||
|
||
const io: Server = req.app.get("io");
|
||
broadcastToCampaign(io, Number(character.campaign_id), "character:deleted", {
|
||
id: Number(req.params.id),
|
||
});
|
||
|
||
res.status(204).end();
|
||
});
|
||
|
||
// PATCH /api/characters/:id/stats/:statName — update a single stat
|
||
router.patch("/:id/stats/:statName", (req, res) => {
|
||
const { id, statName } = req.params;
|
||
const { value } = req.body;
|
||
const upper = statName.toUpperCase();
|
||
|
||
if (!DEFAULT_STATS.includes(upper)) {
|
||
res.status(400).json({ error: "Invalid stat name" });
|
||
return;
|
||
}
|
||
|
||
db.prepare(
|
||
"UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?"
|
||
).run(value, id, upper);
|
||
|
||
const character = db
|
||
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
|
||
.get(id) as Record<string, unknown>;
|
||
if (!character) {
|
||
res.status(404).json({ error: "Character not found" });
|
||
return;
|
||
}
|
||
|
||
const io: Server = req.app.get("io");
|
||
broadcastToCampaign(io, Number(character.campaign_id), "stat:updated", {
|
||
characterId: Number(id),
|
||
statName: upper,
|
||
value,
|
||
});
|
||
|
||
res.json({ characterId: Number(id), statName: upper, value });
|
||
});
|
||
|
||
// POST /api/characters/:id/gear — add gear
|
||
router.post("/:id/gear", (req, res) => {
|
||
const { id } = req.params;
|
||
const { name, type, slot_count, properties } = req.body;
|
||
|
||
if (!name || !name.trim()) {
|
||
res.status(400).json({ error: "Gear name is required" });
|
||
return;
|
||
}
|
||
|
||
const result = db
|
||
.prepare(
|
||
"INSERT INTO character_gear (character_id, name, type, slot_count, properties) VALUES (?, ?, ?, ?, ?)"
|
||
)
|
||
.run(
|
||
id,
|
||
name.trim(),
|
||
type || "gear",
|
||
slot_count ?? 1,
|
||
JSON.stringify(properties || {})
|
||
);
|
||
|
||
const gear = db
|
||
.prepare("SELECT * FROM character_gear WHERE id = ?")
|
||
.get(result.lastInsertRowid);
|
||
|
||
const character = db
|
||
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
|
||
.get(id) as Record<string, unknown>;
|
||
|
||
const io: Server = req.app.get("io");
|
||
broadcastToCampaign(io, Number(character.campaign_id), "gear:added", {
|
||
characterId: Number(id),
|
||
gear,
|
||
});
|
||
|
||
res.status(201).json(gear);
|
||
});
|
||
|
||
// DELETE /api/characters/:id/gear/:gearId — remove gear
|
||
router.delete("/:id/gear/:gearId", (req, res) => {
|
||
const { id, gearId } = req.params;
|
||
const character = db
|
||
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
|
||
.get(id) as Record<string, unknown>;
|
||
if (!character) {
|
||
res.status(404).json({ error: "Character not found" });
|
||
return;
|
||
}
|
||
|
||
const result = db
|
||
.prepare("DELETE FROM character_gear WHERE id = ? AND character_id = ?")
|
||
.run(gearId, id);
|
||
if (result.changes === 0) {
|
||
res.status(404).json({ error: "Gear not found" });
|
||
return;
|
||
}
|
||
|
||
const io: Server = req.app.get("io");
|
||
broadcastToCampaign(io, Number(character.campaign_id), "gear:removed", {
|
||
characterId: Number(id),
|
||
gearId: Number(gearId),
|
||
});
|
||
|
||
res.status(204).end();
|
||
});
|
||
|
||
// POST /api/characters/:id/talents — add talent
|
||
router.post("/:id/talents", (req, res) => {
|
||
const { id } = req.params;
|
||
const { name, description, effect } = req.body;
|
||
|
||
if (!name || !name.trim()) {
|
||
res.status(400).json({ error: "Talent name is required" });
|
||
return;
|
||
}
|
||
|
||
const result = db
|
||
.prepare(
|
||
"INSERT INTO character_talents (character_id, name, description, effect) VALUES (?, ?, ?, ?)"
|
||
)
|
||
.run(id, name.trim(), description || "", JSON.stringify(effect || {}));
|
||
|
||
const talent = db
|
||
.prepare("SELECT * FROM character_talents WHERE id = ?")
|
||
.get(result.lastInsertRowid);
|
||
|
||
const character = db
|
||
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
|
||
.get(id) as Record<string, unknown>;
|
||
|
||
const io: Server = req.app.get("io");
|
||
broadcastToCampaign(io, Number(character.campaign_id), "talent:added", {
|
||
characterId: Number(id),
|
||
talent,
|
||
});
|
||
|
||
res.status(201).json(talent);
|
||
});
|
||
|
||
// DELETE /api/characters/:id/talents/:talentId — remove talent
|
||
router.delete("/:id/talents/:talentId", (req, res) => {
|
||
const { id, talentId } = req.params;
|
||
const character = db
|
||
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
|
||
.get(id) as Record<string, unknown>;
|
||
if (!character) {
|
||
res.status(404).json({ error: "Character not found" });
|
||
return;
|
||
}
|
||
|
||
const result = db
|
||
.prepare("DELETE FROM character_talents WHERE id = ? AND character_id = ?")
|
||
.run(talentId, id);
|
||
if (result.changes === 0) {
|
||
res.status(404).json({ error: "Talent not found" });
|
||
return;
|
||
}
|
||
|
||
const io: Server = req.app.get("io");
|
||
broadcastToCampaign(io, Number(character.campaign_id), "talent:removed", {
|
||
characterId: Number(id),
|
||
talentId: Number(talentId),
|
||
});
|
||
|
||
res.status(204).end();
|
||
});
|
||
|
||
export default router;
|
||
```
|
||
|
||
- [ ] **Step 2: Register character routes in server/src/index.ts**
|
||
|
||
Add these lines after the campaign routes registration:
|
||
|
||
```ts
|
||
import characterRoutes from "./routes/characters.js";
|
||
|
||
app.use("/api/campaigns/:campaignId/characters", characterRoutes);
|
||
app.use("/api/characters", characterRoutes);
|
||
```
|
||
|
||
The dual mount gives us:
|
||
|
||
- `/api/campaigns/:campaignId/characters` — list/create characters in a campaign
|
||
- `/api/characters/:id` — update/delete a specific character, manage stats/gear/talents
|
||
|
||
- [ ] **Step 3: Test character endpoints manually**
|
||
|
||
Run server and test:
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts &
|
||
|
||
# Create campaign first
|
||
curl -s -X POST http://localhost:3000/api/campaigns -H 'Content-Type: application/json' -d '{"name":"Test"}' | jq .
|
||
|
||
# Create character
|
||
curl -s -X POST http://localhost:3000/api/campaigns/1/characters -H 'Content-Type: application/json' -d '{"name":"Kira","class":"Thief","ancestry":"Elf","hp_max":4}' | jq .
|
||
|
||
# Update stat
|
||
curl -s -X PATCH http://localhost:3000/api/characters/1/stats/STR -H 'Content-Type: application/json' -d '{"value":16}' | jq .
|
||
|
||
# Add gear
|
||
curl -s -X POST http://localhost:3000/api/characters/1/gear -H 'Content-Type: application/json' -d '{"name":"Longsword +1","type":"weapon","slot_count":1,"properties":{"damage":"1d8","melee":true,"bonus":1}}' | jq .
|
||
|
||
# Add talent
|
||
curl -s -X POST http://localhost:3000/api/characters/1/talents -H 'Content-Type: application/json' -d '{"name":"Backstab","description":"Advantage on attacks from behind","effect":{"advantage":"melee_stealth"}}' | jq .
|
||
|
||
# List characters
|
||
curl -s http://localhost:3000/api/campaigns/1/characters | jq .
|
||
```
|
||
|
||
Expected: All return appropriate status codes and JSON responses. Characters include stats, gear, and talents arrays.
|
||
|
||
Kill server after testing.
|
||
|
||
---
|
||
|
||
### Task 6: Client Foundation — Types, API, Socket, Modifier Utils
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/types.ts`
|
||
- Create: `client/src/api.ts`
|
||
- Create: `client/src/socket.ts`
|
||
- Create: `client/src/utils/modifiers.ts`
|
||
|
||
- [ ] **Step 1: Create client/src/types.ts**
|
||
|
||
```ts
|
||
export interface Campaign {
|
||
id: number;
|
||
name: string;
|
||
created_by: string;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface Stat {
|
||
stat_name: string;
|
||
value: number;
|
||
}
|
||
|
||
export interface Gear {
|
||
id: number;
|
||
character_id: number;
|
||
name: string;
|
||
type: "weapon" | "armor" | "gear" | "spell";
|
||
slot_count: number;
|
||
properties: Record<string, unknown>;
|
||
}
|
||
|
||
export interface Talent {
|
||
id: number;
|
||
character_id: number;
|
||
name: string;
|
||
description: string;
|
||
effect: Record<string, unknown>;
|
||
}
|
||
|
||
export interface Character {
|
||
id: number;
|
||
campaign_id: number;
|
||
created_by: string;
|
||
name: string;
|
||
class: string;
|
||
ancestry: string;
|
||
level: number;
|
||
xp: number;
|
||
hp_current: number;
|
||
hp_max: number;
|
||
ac: number;
|
||
alignment: string;
|
||
title: string;
|
||
notes: string;
|
||
stats: Stat[];
|
||
gear: Gear[];
|
||
talents: Talent[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create client/src/utils/modifiers.ts**
|
||
|
||
```ts
|
||
const MODIFIER_TABLE: [number, number][] = [
|
||
[3, -4],
|
||
[5, -3],
|
||
[7, -2],
|
||
[9, -1],
|
||
[11, 0],
|
||
[13, 1],
|
||
[15, 2],
|
||
[17, 3],
|
||
[18, 4],
|
||
];
|
||
|
||
export function getModifier(score: number): number {
|
||
for (const [max, mod] of MODIFIER_TABLE) {
|
||
if (score <= max) return mod;
|
||
}
|
||
return 4;
|
||
}
|
||
|
||
export function formatModifier(mod: number): string {
|
||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create client/src/socket.ts**
|
||
|
||
```ts
|
||
import { io } from "socket.io-client";
|
||
|
||
const socket = io("/", {
|
||
autoConnect: true,
|
||
reconnection: true,
|
||
});
|
||
|
||
export default socket;
|
||
```
|
||
|
||
- [ ] **Step 4: Create client/src/api.ts**
|
||
|
||
```ts
|
||
const BASE = "/api";
|
||
|
||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||
const res = await fetch(`${BASE}${path}`, {
|
||
headers: { "Content-Type": "application/json" },
|
||
...options,
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||
throw new Error(err.error || res.statusText);
|
||
}
|
||
if (res.status === 204) return undefined as T;
|
||
return res.json();
|
||
}
|
||
|
||
// Campaigns
|
||
export const getCampaigns = () => request<Campaign[]>("/campaigns");
|
||
export const createCampaign = (name: string) =>
|
||
request<Campaign>("/campaigns", {
|
||
method: "POST",
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
export const deleteCampaign = (id: number) =>
|
||
request<void>(`/campaigns/${id}`, { method: "DELETE" });
|
||
|
||
// Characters
|
||
export const getCharacters = (campaignId: number) =>
|
||
request<Character[]>(`/campaigns/${campaignId}/characters`);
|
||
export const createCharacter = (
|
||
campaignId: number,
|
||
data: { name: string; class?: string; ancestry?: string; hp_max?: number }
|
||
) =>
|
||
request<Character>(`/campaigns/${campaignId}/characters`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
export const updateCharacter = (id: number, data: Partial<Character>) =>
|
||
request<Character>(`/characters/${id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
export const deleteCharacter = (id: number) =>
|
||
request<void>(`/characters/${id}`, { method: "DELETE" });
|
||
|
||
// Stats
|
||
export const updateStat = (
|
||
characterId: number,
|
||
statName: string,
|
||
value: number
|
||
) =>
|
||
request<{ characterId: number; statName: string; value: number }>(
|
||
`/characters/${characterId}/stats/${statName}`,
|
||
{ method: "PATCH", body: JSON.stringify({ value }) }
|
||
);
|
||
|
||
// Gear
|
||
export const addGear = (
|
||
characterId: number,
|
||
data: {
|
||
name: string;
|
||
type?: string;
|
||
slot_count?: number;
|
||
properties?: Record<string, unknown>;
|
||
}
|
||
) =>
|
||
request<Gear>(`/characters/${characterId}/gear`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
export const removeGear = (characterId: number, gearId: number) =>
|
||
request<void>(`/characters/${characterId}/gear/${gearId}`, {
|
||
method: "DELETE",
|
||
});
|
||
|
||
// Talents
|
||
export const addTalent = (
|
||
characterId: number,
|
||
data: { name: string; description?: string; effect?: Record<string, unknown> }
|
||
) =>
|
||
request<Talent>(`/characters/${characterId}/talents`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
export const removeTalent = (characterId: number, talentId: number) =>
|
||
request<void>(`/characters/${characterId}/talents/${talentId}`, {
|
||
method: "DELETE",
|
||
});
|
||
|
||
import type { Campaign, Character, Gear, Talent } from "./types";
|
||
```
|
||
|
||
- [ ] **Step 5: Verify client builds**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
|
||
```
|
||
|
||
Expected: No type errors (may warn about missing main.tsx, that's fine — created next task).
|
||
|
||
---
|
||
|
||
### Task 7: App Shell — Routing and Layout
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/main.tsx`
|
||
- Create: `client/src/App.tsx`
|
||
- Create: `client/src/App.module.css`
|
||
|
||
- [ ] **Step 1: Create client/src/main.tsx**
|
||
|
||
```tsx
|
||
import { StrictMode } from "react";
|
||
import { createRoot } from "react-dom/client";
|
||
import App from "./App";
|
||
|
||
createRoot(document.getElementById("root")!).render(
|
||
<StrictMode>
|
||
<App />
|
||
</StrictMode>
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: Create client/src/App.module.css**
|
||
|
||
```css
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||
background: #1a1a2e;
|
||
color: #e0e0e0;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.app {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.header {
|
||
text-align: center;
|
||
padding: 1rem 0;
|
||
border-bottom: 1px solid #333;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 1.8rem;
|
||
color: #c9a84c;
|
||
font-variant: small-caps;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create client/src/App.tsx**
|
||
|
||
```tsx
|
||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||
import CampaignList from "./pages/CampaignList";
|
||
import CampaignView from "./pages/CampaignView";
|
||
import styles from "./App.module.css";
|
||
|
||
export default function App() {
|
||
return (
|
||
<BrowserRouter>
|
||
<div className={styles.app}>
|
||
<header className={styles.header}>
|
||
<h1>Shadowdark</h1>
|
||
</header>
|
||
<Routes>
|
||
<Route path="/" element={<CampaignList />} />
|
||
<Route path="/campaign/:id" element={<CampaignView />} />
|
||
</Routes>
|
||
</div>
|
||
</BrowserRouter>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify the app compiles**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark/client && npx vite build
|
||
```
|
||
|
||
Expected: Build succeeds (may warn about missing page components — they're in the next tasks).
|
||
|
||
---
|
||
|
||
### Task 8: Campaign List Page
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/pages/CampaignList.tsx`
|
||
- Create: `client/src/pages/CampaignList.module.css`
|
||
|
||
- [ ] **Step 1: Create client/src/pages/CampaignList.module.css**
|
||
|
||
```css
|
||
.container {
|
||
max-width: 600px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.campaignGrid {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.campaignCard {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: #16213e;
|
||
border: 1px solid #333;
|
||
border-radius: 8px;
|
||
padding: 1rem 1.25rem;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.campaignCard:hover {
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.campaignName {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.campaignDate {
|
||
font-size: 0.8rem;
|
||
color: #888;
|
||
}
|
||
|
||
.deleteBtn {
|
||
background: none;
|
||
border: none;
|
||
color: #666;
|
||
cursor: pointer;
|
||
font-size: 1.2rem;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.deleteBtn:hover {
|
||
color: #e74c3c;
|
||
background: rgba(231, 76, 60, 0.1);
|
||
}
|
||
|
||
.createForm {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.createInput {
|
||
flex: 1;
|
||
padding: 0.6rem 1rem;
|
||
background: #16213e;
|
||
border: 1px solid #333;
|
||
border-radius: 8px;
|
||
color: #e0e0e0;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.createInput:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.createBtn {
|
||
padding: 0.6rem 1.25rem;
|
||
background: #c9a84c;
|
||
color: #1a1a2e;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.createBtn:hover {
|
||
background: #d4b65a;
|
||
}
|
||
|
||
.empty {
|
||
text-align: center;
|
||
color: #666;
|
||
padding: 3rem 0;
|
||
font-style: italic;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create client/src/pages/CampaignList.tsx**
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { getCampaigns, createCampaign, deleteCampaign } from "../api";
|
||
import type { Campaign } from "../types";
|
||
import styles from "./CampaignList.module.css";
|
||
|
||
export default function CampaignList() {
|
||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||
const [newName, setNewName] = useState("");
|
||
const navigate = useNavigate();
|
||
|
||
useEffect(() => {
|
||
getCampaigns().then(setCampaigns);
|
||
}, []);
|
||
|
||
async function handleCreate(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!newName.trim()) return;
|
||
try {
|
||
const campaign = await createCampaign(newName.trim());
|
||
setCampaigns((prev) => [campaign, ...prev]);
|
||
setNewName("");
|
||
} catch (err) {
|
||
console.error("Failed to create campaign:", err);
|
||
}
|
||
}
|
||
|
||
async function handleDelete(e: React.MouseEvent, id: number) {
|
||
e.stopPropagation();
|
||
try {
|
||
await deleteCampaign(id);
|
||
setCampaigns((prev) => prev.filter((c) => c.id !== id));
|
||
} catch (err) {
|
||
console.error("Failed to delete campaign:", err);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<form className={styles.createForm} onSubmit={handleCreate}>
|
||
<input
|
||
className={styles.createInput}
|
||
type="text"
|
||
placeholder="New campaign name..."
|
||
value={newName}
|
||
onChange={(e) => setNewName(e.target.value)}
|
||
/>
|
||
<button className={styles.createBtn} type="submit">
|
||
+ Create
|
||
</button>
|
||
</form>
|
||
|
||
<div className={styles.campaignGrid}>
|
||
{campaigns.length === 0 && (
|
||
<p className={styles.empty}>No campaigns yet. Create one above!</p>
|
||
)}
|
||
{campaigns.map((c) => (
|
||
<div
|
||
key={c.id}
|
||
className={styles.campaignCard}
|
||
onClick={() => navigate(`/campaign/${c.id}`)}
|
||
>
|
||
<div>
|
||
<div className={styles.campaignName}>{c.name}</div>
|
||
<div className={styles.campaignDate}>
|
||
{new Date(c.created_at).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className={styles.deleteBtn}
|
||
onClick={(e) => handleDelete(e, c.id)}
|
||
title="Delete campaign"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: HpBar and StatBlock Components
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/components/HpBar.tsx`
|
||
- Create: `client/src/components/HpBar.module.css`
|
||
- Create: `client/src/components/StatBlock.tsx`
|
||
- Create: `client/src/components/StatBlock.module.css`
|
||
|
||
- [ ] **Step 1: Create client/src/components/HpBar.module.css**
|
||
|
||
```css
|
||
.hpBar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.label {
|
||
font-size: 0.75rem;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.values {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.current {
|
||
color: #4caf50;
|
||
}
|
||
|
||
.current.hurt {
|
||
color: #ff9800;
|
||
}
|
||
|
||
.current.critical {
|
||
color: #e74c3c;
|
||
}
|
||
|
||
.slash {
|
||
color: #666;
|
||
}
|
||
|
||
.max {
|
||
color: #888;
|
||
}
|
||
|
||
.btn {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
border: 1px solid #444;
|
||
background: #16213e;
|
||
color: #e0e0e0;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: 1;
|
||
}
|
||
|
||
.btn:hover {
|
||
border-color: #c9a84c;
|
||
color: #c9a84c;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create client/src/components/HpBar.tsx**
|
||
|
||
```tsx
|
||
import styles from "./HpBar.module.css";
|
||
|
||
interface HpBarProps {
|
||
current: number;
|
||
max: number;
|
||
onChange: (current: number) => void;
|
||
}
|
||
|
||
export default function HpBar({ current, max, onChange }: HpBarProps) {
|
||
const ratio = max > 0 ? current / max : 1;
|
||
const colorClass =
|
||
ratio <= 0.25 ? styles.critical : ratio <= 0.5 ? styles.hurt : "";
|
||
|
||
return (
|
||
<div className={styles.hpBar}>
|
||
<span className={styles.label}>HP</span>
|
||
<button className={styles.btn} onClick={() => onChange(current - 1)}>
|
||
−
|
||
</button>
|
||
<span className={styles.values}>
|
||
<span className={`${styles.current} ${colorClass}`}>{current}</span>
|
||
<span className={styles.slash}>/</span>
|
||
<span className={styles.max}>{max}</span>
|
||
</span>
|
||
<button className={styles.btn} onClick={() => onChange(current + 1)}>
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create client/src/components/StatBlock.module.css**
|
||
|
||
```css
|
||
.statGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.stat {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
background: #0f1a30;
|
||
border-radius: 6px;
|
||
padding: 0.3rem;
|
||
}
|
||
|
||
.statName {
|
||
font-size: 0.65rem;
|
||
color: #888;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.statRow {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.modifier {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
min-width: 2rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.score {
|
||
font-size: 0.7rem;
|
||
color: #666;
|
||
}
|
||
|
||
.btn {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
border: 1px solid #444;
|
||
background: #16213e;
|
||
color: #e0e0e0;
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: 1;
|
||
}
|
||
|
||
.btn:hover {
|
||
border-color: #c9a84c;
|
||
color: #c9a84c;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create client/src/components/StatBlock.tsx**
|
||
|
||
```tsx
|
||
import type { Stat } from "../types";
|
||
import { getModifier, formatModifier } from "../utils/modifiers";
|
||
import styles from "./StatBlock.module.css";
|
||
|
||
const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
|
||
|
||
interface StatBlockProps {
|
||
stats: Stat[];
|
||
onStatChange: (statName: string, newValue: number) => void;
|
||
}
|
||
|
||
export default function StatBlock({ stats, onStatChange }: StatBlockProps) {
|
||
const statMap = new Map(stats.map((s) => [s.stat_name, s.value]));
|
||
|
||
return (
|
||
<div className={styles.statGrid}>
|
||
{STAT_ORDER.map((name) => {
|
||
const value = statMap.get(name) ?? 10;
|
||
const mod = getModifier(value);
|
||
return (
|
||
<div key={name} className={styles.stat}>
|
||
<span className={styles.statName}>{name}</span>
|
||
<div className={styles.statRow}>
|
||
<button
|
||
className={styles.btn}
|
||
onClick={() => onStatChange(name, value - 1)}
|
||
>
|
||
−
|
||
</button>
|
||
<span className={styles.modifier}>{formatModifier(mod)}</span>
|
||
<button
|
||
className={styles.btn}
|
||
onClick={() => onStatChange(name, value + 1)}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<span className={styles.score}>{value}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: CharacterCard Component
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/components/CharacterCard.tsx`
|
||
- Create: `client/src/components/CharacterCard.module.css`
|
||
|
||
- [ ] **Step 1: Create client/src/components/CharacterCard.module.css**
|
||
|
||
```css
|
||
.card {
|
||
background: #16213e;
|
||
border: 1px solid #333;
|
||
border-radius: 10px;
|
||
padding: 1rem;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, transform 0.1s;
|
||
}
|
||
|
||
.card:hover {
|
||
border-color: #c9a84c;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.cardHeader {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.name {
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
.level {
|
||
font-size: 0.8rem;
|
||
color: #888;
|
||
}
|
||
|
||
.meta {
|
||
font-size: 0.8rem;
|
||
color: #666;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.hpAcRow {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.ac {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.acLabel {
|
||
font-size: 0.75rem;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.acValue {
|
||
font-size: 1.1rem;
|
||
color: #5dade2;
|
||
}
|
||
|
||
.gearSummary {
|
||
font-size: 0.75rem;
|
||
color: #666;
|
||
text-align: right;
|
||
margin-top: 0.5rem;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create client/src/components/CharacterCard.tsx**
|
||
|
||
```tsx
|
||
import type { Character } from "../types";
|
||
import HpBar from "./HpBar";
|
||
import StatBlock from "./StatBlock";
|
||
import styles from "./CharacterCard.module.css";
|
||
|
||
interface CharacterCardProps {
|
||
character: Character;
|
||
onHpChange: (characterId: number, hp: number) => void;
|
||
onStatChange: (characterId: number, statName: string, value: number) => void;
|
||
onClick: (characterId: number) => void;
|
||
}
|
||
|
||
export default function CharacterCard({
|
||
character,
|
||
onHpChange,
|
||
onStatChange,
|
||
onClick,
|
||
}: CharacterCardProps) {
|
||
const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
|
||
|
||
return (
|
||
<div className={styles.card} onClick={() => onClick(character.id)}>
|
||
<div className={styles.cardHeader}>
|
||
<span className={styles.name}>
|
||
{character.name}
|
||
{character.title ? ` ${character.title}` : ""}
|
||
</span>
|
||
<span className={styles.level}>Lvl {character.level}</span>
|
||
</div>
|
||
<div className={styles.meta}>
|
||
{character.ancestry} {character.class}
|
||
</div>
|
||
<div className={styles.hpAcRow} onClick={(e) => e.stopPropagation()}>
|
||
<HpBar
|
||
current={character.hp_current}
|
||
max={character.hp_max}
|
||
onChange={(hp) => onHpChange(character.id, hp)}
|
||
/>
|
||
<div className={styles.ac}>
|
||
<span className={styles.acLabel}>AC</span>
|
||
<span className={styles.acValue}>{character.ac}</span>
|
||
</div>
|
||
</div>
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<StatBlock
|
||
stats={character.stats}
|
||
onStatChange={(statName, value) =>
|
||
onStatChange(character.id, statName, value)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className={styles.gearSummary}>
|
||
{totalSlots} gear slot{totalSlots !== 1 ? "s" : ""} used
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: GearList and TalentList Components
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/components/GearList.tsx`
|
||
- Create: `client/src/components/GearList.module.css`
|
||
- Create: `client/src/components/TalentList.tsx`
|
||
- Create: `client/src/components/TalentList.module.css`
|
||
|
||
- [ ] **Step 1: Create client/src/components/GearList.module.css**
|
||
|
||
```css
|
||
.section {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.sectionHeader {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.sectionTitle {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #0f1a30;
|
||
border-radius: 6px;
|
||
padding: 0.5rem 0.75rem;
|
||
}
|
||
|
||
.itemInfo {
|
||
flex: 1;
|
||
}
|
||
|
||
.itemName {
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.itemMeta {
|
||
font-size: 0.75rem;
|
||
color: #888;
|
||
}
|
||
|
||
.removeBtn {
|
||
background: none;
|
||
border: none;
|
||
color: #666;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
padding: 0.25rem;
|
||
}
|
||
|
||
.removeBtn:hover {
|
||
color: #e74c3c;
|
||
}
|
||
|
||
.addForm {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.addInput {
|
||
flex: 1;
|
||
padding: 0.4rem 0.6rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.addInput:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.addSelect {
|
||
padding: 0.4rem 0.6rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.addBtn {
|
||
padding: 0.4rem 0.75rem;
|
||
background: #c9a84c;
|
||
color: #1a1a2e;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.addBtn:hover {
|
||
background: #d4b65a;
|
||
}
|
||
|
||
.empty {
|
||
font-size: 0.8rem;
|
||
color: #555;
|
||
font-style: italic;
|
||
padding: 0.5rem 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create client/src/components/GearList.tsx**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import type { Gear } from "../types";
|
||
import styles from "./GearList.module.css";
|
||
|
||
interface GearListProps {
|
||
gear: Gear[];
|
||
onAdd: (data: { name: string; type: string; slot_count: number }) => void;
|
||
onRemove: (gearId: number) => void;
|
||
}
|
||
|
||
export default function GearList({ gear, onAdd, onRemove }: GearListProps) {
|
||
const [name, setName] = useState("");
|
||
const [type, setType] = useState("gear");
|
||
|
||
function handleAdd(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!name.trim()) return;
|
||
onAdd({ name: name.trim(), type, slot_count: 1 });
|
||
setName("");
|
||
}
|
||
|
||
function formatGearMeta(item: Gear): string {
|
||
const parts = [item.type];
|
||
if (item.slot_count !== 1) parts.push(`${item.slot_count} slots`);
|
||
const props = item.properties as Record<string, unknown>;
|
||
if (props.damage) parts.push(String(props.damage));
|
||
if (props.bonus) parts.push(`+${props.bonus}`);
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
return (
|
||
<div className={styles.section}>
|
||
<div className={styles.sectionHeader}>
|
||
<span className={styles.sectionTitle}>Gear & Inventory</span>
|
||
</div>
|
||
<div className={styles.list}>
|
||
{gear.length === 0 && <p className={styles.empty}>No gear yet</p>}
|
||
{gear.map((item) => (
|
||
<div key={item.id} className={styles.item}>
|
||
<div className={styles.itemInfo}>
|
||
<div className={styles.itemName}>{item.name}</div>
|
||
<div className={styles.itemMeta}>{formatGearMeta(item)}</div>
|
||
</div>
|
||
<button
|
||
className={styles.removeBtn}
|
||
onClick={() => onRemove(item.id)}
|
||
title="Remove"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<form className={styles.addForm} onSubmit={handleAdd}>
|
||
<input
|
||
className={styles.addInput}
|
||
type="text"
|
||
placeholder="Item name..."
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
<select
|
||
className={styles.addSelect}
|
||
value={type}
|
||
onChange={(e) => setType(e.target.value)}
|
||
>
|
||
<option value="weapon">Weapon</option>
|
||
<option value="armor">Armor</option>
|
||
<option value="gear">Gear</option>
|
||
<option value="spell">Spell</option>
|
||
</select>
|
||
<button className={styles.addBtn} type="submit">
|
||
+ Add
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create client/src/components/TalentList.module.css**
|
||
|
||
```css
|
||
.section {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.sectionHeader {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.sectionTitle {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
background: #0f1a30;
|
||
border-radius: 6px;
|
||
padding: 0.5rem 0.75rem;
|
||
}
|
||
|
||
.itemInfo {
|
||
flex: 1;
|
||
}
|
||
|
||
.itemName {
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.itemDesc {
|
||
font-size: 0.75rem;
|
||
color: #888;
|
||
}
|
||
|
||
.removeBtn {
|
||
background: none;
|
||
border: none;
|
||
color: #666;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
padding: 0.25rem;
|
||
}
|
||
|
||
.removeBtn:hover {
|
||
color: #e74c3c;
|
||
}
|
||
|
||
.addForm {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.addInput {
|
||
flex: 1;
|
||
padding: 0.4rem 0.6rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.addInput:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.addBtn {
|
||
padding: 0.4rem 0.75rem;
|
||
background: #c9a84c;
|
||
color: #1a1a2e;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.addBtn:hover {
|
||
background: #d4b65a;
|
||
}
|
||
|
||
.empty {
|
||
font-size: 0.8rem;
|
||
color: #555;
|
||
font-style: italic;
|
||
padding: 0.5rem 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create client/src/components/TalentList.tsx**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import type { Talent } from "../types";
|
||
import styles from "./TalentList.module.css";
|
||
|
||
interface TalentListProps {
|
||
talents: Talent[];
|
||
onAdd: (data: { name: string; description: string }) => void;
|
||
onRemove: (talentId: number) => void;
|
||
}
|
||
|
||
export default function TalentList({
|
||
talents,
|
||
onAdd,
|
||
onRemove,
|
||
}: TalentListProps) {
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
|
||
function handleAdd(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!name.trim()) return;
|
||
onAdd({ name: name.trim(), description: description.trim() });
|
||
setName("");
|
||
setDescription("");
|
||
}
|
||
|
||
return (
|
||
<div className={styles.section}>
|
||
<div className={styles.sectionHeader}>
|
||
<span className={styles.sectionTitle}>Talents</span>
|
||
</div>
|
||
<div className={styles.list}>
|
||
{talents.length === 0 && <p className={styles.empty}>No talents yet</p>}
|
||
{talents.map((t) => (
|
||
<div key={t.id} className={styles.item}>
|
||
<div className={styles.itemInfo}>
|
||
<div className={styles.itemName}>{t.name}</div>
|
||
{t.description && (
|
||
<div className={styles.itemDesc}>{t.description}</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
className={styles.removeBtn}
|
||
onClick={() => onRemove(t.id)}
|
||
title="Remove"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<form className={styles.addForm} onSubmit={handleAdd}>
|
||
<input
|
||
className={styles.addInput}
|
||
type="text"
|
||
placeholder="Talent name..."
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
<input
|
||
className={styles.addInput}
|
||
type="text"
|
||
placeholder="Description (optional)..."
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
/>
|
||
<button className={styles.addBtn} type="submit">
|
||
+ Add
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: CharacterDetail Modal
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/components/CharacterDetail.tsx`
|
||
- Create: `client/src/components/CharacterDetail.module.css`
|
||
|
||
- [ ] **Step 1: Create client/src/components/CharacterDetail.module.css**
|
||
|
||
```css
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.modal {
|
||
background: #1a1a2e;
|
||
border: 1px solid #333;
|
||
border-radius: 12px;
|
||
width: 100%;
|
||
max-width: 700px;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.nameSection {
|
||
flex: 1;
|
||
}
|
||
|
||
.name {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.meta {
|
||
color: #888;
|
||
font-size: 0.9rem;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
.closeBtn {
|
||
background: none;
|
||
border: none;
|
||
color: #888;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
padding: 0.25rem 0.5rem;
|
||
}
|
||
|
||
.closeBtn:hover {
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
.fieldGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||
gap: 0.75rem;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.fieldLabel {
|
||
font-size: 0.75rem;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.fieldInput {
|
||
padding: 0.4rem 0.6rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.fieldInput:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.fieldSelect {
|
||
padding: 0.4rem 0.6rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.statsSection {
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.statsSectionTitle {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.notesField {
|
||
width: 100%;
|
||
min-height: 80px;
|
||
padding: 0.6rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 0.9rem;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
}
|
||
|
||
.notesField:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.deleteSection {
|
||
margin-top: 1.5rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #333;
|
||
}
|
||
|
||
.deleteBtn {
|
||
padding: 0.5rem 1rem;
|
||
background: transparent;
|
||
border: 1px solid #e74c3c;
|
||
border-radius: 6px;
|
||
color: #e74c3c;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.deleteBtn:hover {
|
||
background: rgba(231, 76, 60, 0.1);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create client/src/components/CharacterDetail.tsx**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import type { Character } from "../types";
|
||
import StatBlock from "./StatBlock";
|
||
import GearList from "./GearList";
|
||
import TalentList from "./TalentList";
|
||
import styles from "./CharacterDetail.module.css";
|
||
|
||
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
|
||
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
|
||
const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"];
|
||
|
||
interface CharacterDetailProps {
|
||
character: Character;
|
||
onUpdate: (id: number, data: Partial<Character>) => void;
|
||
onStatChange: (characterId: number, statName: string, value: number) => void;
|
||
onAddGear: (
|
||
characterId: number,
|
||
data: { name: string; type: string; slot_count: number }
|
||
) => void;
|
||
onRemoveGear: (characterId: number, gearId: number) => void;
|
||
onAddTalent: (
|
||
characterId: number,
|
||
data: { name: string; description: string }
|
||
) => void;
|
||
onRemoveTalent: (characterId: number, talentId: number) => void;
|
||
onDelete: (id: number) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export default function CharacterDetail({
|
||
character,
|
||
onUpdate,
|
||
onStatChange,
|
||
onAddGear,
|
||
onRemoveGear,
|
||
onAddTalent,
|
||
onRemoveTalent,
|
||
onDelete,
|
||
onClose,
|
||
}: CharacterDetailProps) {
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
|
||
function handleFieldChange(field: string, value: string | number) {
|
||
onUpdate(character.id, { [field]: value });
|
||
}
|
||
|
||
return (
|
||
<div className={styles.overlay} onClick={onClose}>
|
||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||
<div className={styles.header}>
|
||
<div className={styles.nameSection}>
|
||
<div className={styles.name}>{character.name}</div>
|
||
<div className={styles.meta}>
|
||
Level {character.level} {character.ancestry} {character.class}
|
||
</div>
|
||
</div>
|
||
<button className={styles.closeBtn} onClick={onClose}>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Name</label>
|
||
<input
|
||
className={styles.fieldInput}
|
||
value={character.name}
|
||
onChange={(e) => handleFieldChange("name", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Title</label>
|
||
<input
|
||
className={styles.fieldInput}
|
||
value={character.title}
|
||
placeholder="the Brave..."
|
||
onChange={(e) => handleFieldChange("title", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Class</label>
|
||
<select
|
||
className={styles.fieldSelect}
|
||
value={character.class}
|
||
onChange={(e) => handleFieldChange("class", e.target.value)}
|
||
>
|
||
{CLASSES.map((c) => (
|
||
<option key={c} value={c}>
|
||
{c}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Ancestry</label>
|
||
<select
|
||
className={styles.fieldSelect}
|
||
value={character.ancestry}
|
||
onChange={(e) => handleFieldChange("ancestry", e.target.value)}
|
||
>
|
||
{ANCESTRIES.map((a) => (
|
||
<option key={a} value={a}>
|
||
{a}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Level</label>
|
||
<input
|
||
className={styles.fieldInput}
|
||
type="number"
|
||
min={0}
|
||
value={character.level}
|
||
onChange={(e) =>
|
||
handleFieldChange("level", Number(e.target.value))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>XP</label>
|
||
<input
|
||
className={styles.fieldInput}
|
||
type="number"
|
||
min={0}
|
||
value={character.xp}
|
||
onChange={(e) => handleFieldChange("xp", Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>HP Max</label>
|
||
<input
|
||
className={styles.fieldInput}
|
||
type="number"
|
||
min={0}
|
||
value={character.hp_max}
|
||
onChange={(e) =>
|
||
handleFieldChange("hp_max", Number(e.target.value))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>AC</label>
|
||
<input
|
||
className={styles.fieldInput}
|
||
type="number"
|
||
value={character.ac}
|
||
onChange={(e) => handleFieldChange("ac", Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Alignment</label>
|
||
<select
|
||
className={styles.fieldSelect}
|
||
value={character.alignment}
|
||
onChange={(e) => handleFieldChange("alignment", e.target.value)}
|
||
>
|
||
{ALIGNMENTS.map((a) => (
|
||
<option key={a} value={a}>
|
||
{a}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.statsSection}>
|
||
<div className={styles.statsSectionTitle}>Ability Scores</div>
|
||
<StatBlock
|
||
stats={character.stats}
|
||
onStatChange={(statName, value) =>
|
||
onStatChange(character.id, statName, value)
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<GearList
|
||
gear={character.gear}
|
||
onAdd={(data) => onAddGear(character.id, data)}
|
||
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
|
||
/>
|
||
|
||
<TalentList
|
||
talents={character.talents}
|
||
onAdd={(data) => onAddTalent(character.id, data)}
|
||
onRemove={(talentId) => onRemoveTalent(character.id, talentId)}
|
||
/>
|
||
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Notes</label>
|
||
<textarea
|
||
className={styles.notesField}
|
||
value={character.notes}
|
||
onChange={(e) => handleFieldChange("notes", e.target.value)}
|
||
placeholder="Freeform notes..."
|
||
/>
|
||
</div>
|
||
|
||
<div className={styles.deleteSection}>
|
||
{confirmDelete ? (
|
||
<div>
|
||
<span>Delete {character.name}? </span>
|
||
<button
|
||
className={styles.deleteBtn}
|
||
onClick={() => onDelete(character.id)}
|
||
>
|
||
Yes, delete
|
||
</button> <button
|
||
className={styles.deleteBtn}
|
||
onClick={() => setConfirmDelete(false)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className={styles.deleteBtn}
|
||
onClick={() => setConfirmDelete(true)}
|
||
>
|
||
Delete Character
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: CampaignView Page with Real-time Sync
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/pages/CampaignView.tsx`
|
||
- Create: `client/src/pages/CampaignView.module.css`
|
||
|
||
- [ ] **Step 1: Create client/src/pages/CampaignView.module.css**
|
||
|
||
```css
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.backLink {
|
||
color: #888;
|
||
text-decoration: none;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.backLink:hover {
|
||
color: #c9a84c;
|
||
}
|
||
|
||
.campaignName {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
}
|
||
|
||
.addBtn {
|
||
padding: 0.5rem 1rem;
|
||
background: #c9a84c;
|
||
color: #1a1a2e;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.addBtn:hover {
|
||
background: #d4b65a;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 1rem;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.empty {
|
||
text-align: center;
|
||
color: #666;
|
||
padding: 3rem 0;
|
||
font-style: italic;
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.createModal {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.createForm {
|
||
background: #1a1a2e;
|
||
border: 1px solid #333;
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
width: 100%;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.createTitle {
|
||
font-size: 1.2rem;
|
||
font-weight: 700;
|
||
margin-bottom: 1rem;
|
||
color: #c9a84c;
|
||
}
|
||
|
||
.formField {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.formLabel {
|
||
font-size: 0.75rem;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.formInput {
|
||
padding: 0.5rem 0.75rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.formInput:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.formSelect {
|
||
padding: 0.5rem 0.75rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.formActions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
justify-content: flex-end;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.formBtn {
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.formBtnPrimary {
|
||
background: #c9a84c;
|
||
color: #1a1a2e;
|
||
border: none;
|
||
}
|
||
|
||
.formBtnPrimary:hover {
|
||
background: #d4b65a;
|
||
}
|
||
|
||
.formBtnSecondary {
|
||
background: transparent;
|
||
color: #888;
|
||
border: 1px solid #333;
|
||
}
|
||
|
||
.formBtnSecondary:hover {
|
||
border-color: #888;
|
||
color: #e0e0e0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create client/src/pages/CampaignView.tsx**
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { useParams, Link } from "react-router-dom";
|
||
import socket from "../socket";
|
||
import {
|
||
getCharacters,
|
||
createCharacter,
|
||
updateCharacter,
|
||
deleteCharacter,
|
||
updateStat,
|
||
addGear,
|
||
removeGear,
|
||
addTalent,
|
||
removeTalent,
|
||
} from "../api";
|
||
import type { Character, Gear, Talent } from "../types";
|
||
import CharacterCard from "../components/CharacterCard";
|
||
import CharacterDetail from "../components/CharacterDetail";
|
||
import styles from "./CampaignView.module.css";
|
||
|
||
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
|
||
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
|
||
|
||
export default function CampaignView() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const campaignId = Number(id);
|
||
const [characters, setCharacters] = useState<Character[]>([]);
|
||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const [newChar, setNewChar] = useState({
|
||
name: "",
|
||
class: "Fighter",
|
||
ancestry: "Human",
|
||
hp_max: 1,
|
||
});
|
||
|
||
// Fetch characters and join socket room
|
||
useEffect(() => {
|
||
getCharacters(campaignId).then(setCharacters);
|
||
socket.emit("join-campaign", String(campaignId));
|
||
return () => {
|
||
socket.emit("leave-campaign", String(campaignId));
|
||
};
|
||
}, [campaignId]);
|
||
|
||
// Socket event listeners
|
||
useEffect(() => {
|
||
function onCharacterCreated(char: Character) {
|
||
setCharacters((prev) => {
|
||
if (prev.some((c) => c.id === char.id)) return prev;
|
||
return [...prev, char];
|
||
});
|
||
}
|
||
|
||
function onCharacterUpdated(data: Partial<Character> & { id: number }) {
|
||
setCharacters((prev) =>
|
||
prev.map((c) => (c.id === data.id ? { ...c, ...data } : c))
|
||
);
|
||
}
|
||
|
||
function onCharacterDeleted({ id }: { id: number }) {
|
||
setCharacters((prev) => prev.filter((c) => c.id !== id));
|
||
setSelectedId((prev) => (prev === id ? null : prev));
|
||
}
|
||
|
||
function onStatUpdated({
|
||
characterId,
|
||
statName,
|
||
value,
|
||
}: {
|
||
characterId: number;
|
||
statName: string;
|
||
value: number;
|
||
}) {
|
||
setCharacters((prev) =>
|
||
prev.map((c) =>
|
||
c.id === characterId
|
||
? {
|
||
...c,
|
||
stats: c.stats.map((s) =>
|
||
s.stat_name === statName ? { ...s, value } : s
|
||
),
|
||
}
|
||
: c
|
||
)
|
||
);
|
||
}
|
||
|
||
function onGearAdded({
|
||
characterId,
|
||
gear,
|
||
}: {
|
||
characterId: number;
|
||
gear: Gear;
|
||
}) {
|
||
setCharacters((prev) =>
|
||
prev.map((c) =>
|
||
c.id === characterId
|
||
? { ...c, gear: [...c.gear.filter((g) => g.id !== gear.id), gear] }
|
||
: c
|
||
)
|
||
);
|
||
}
|
||
|
||
function onGearRemoved({
|
||
characterId,
|
||
gearId,
|
||
}: {
|
||
characterId: number;
|
||
gearId: number;
|
||
}) {
|
||
setCharacters((prev) =>
|
||
prev.map((c) =>
|
||
c.id === characterId
|
||
? { ...c, gear: c.gear.filter((g) => g.id !== gearId) }
|
||
: c
|
||
)
|
||
);
|
||
}
|
||
|
||
function onTalentAdded({
|
||
characterId,
|
||
talent,
|
||
}: {
|
||
characterId: number;
|
||
talent: Talent;
|
||
}) {
|
||
setCharacters((prev) =>
|
||
prev.map((c) =>
|
||
c.id === characterId
|
||
? {
|
||
...c,
|
||
talents: [
|
||
...c.talents.filter((t) => t.id !== talent.id),
|
||
talent,
|
||
],
|
||
}
|
||
: c
|
||
)
|
||
);
|
||
}
|
||
|
||
function onTalentRemoved({
|
||
characterId,
|
||
talentId,
|
||
}: {
|
||
characterId: number;
|
||
talentId: number;
|
||
}) {
|
||
setCharacters((prev) =>
|
||
prev.map((c) =>
|
||
c.id === characterId
|
||
? { ...c, talents: c.talents.filter((t) => t.id !== talentId) }
|
||
: c
|
||
)
|
||
);
|
||
}
|
||
|
||
socket.on("character:created", onCharacterCreated);
|
||
socket.on("character:updated", onCharacterUpdated);
|
||
socket.on("character:deleted", onCharacterDeleted);
|
||
socket.on("stat:updated", onStatUpdated);
|
||
socket.on("gear:added", onGearAdded);
|
||
socket.on("gear:removed", onGearRemoved);
|
||
socket.on("talent:added", onTalentAdded);
|
||
socket.on("talent:removed", onTalentRemoved);
|
||
|
||
return () => {
|
||
socket.off("character:created", onCharacterCreated);
|
||
socket.off("character:updated", onCharacterUpdated);
|
||
socket.off("character:deleted", onCharacterDeleted);
|
||
socket.off("stat:updated", onStatUpdated);
|
||
socket.off("gear:added", onGearAdded);
|
||
socket.off("gear:removed", onGearRemoved);
|
||
socket.off("talent:added", onTalentAdded);
|
||
socket.off("talent:removed", onTalentRemoved);
|
||
};
|
||
}, []);
|
||
|
||
async function handleCreate(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!newChar.name.trim()) return;
|
||
try {
|
||
await createCharacter(campaignId, newChar);
|
||
setNewChar({ name: "", class: "Fighter", ancestry: "Human", hp_max: 1 });
|
||
setShowCreate(false);
|
||
} catch (err) {
|
||
console.error("Failed to create character:", err);
|
||
}
|
||
}
|
||
|
||
async function handleHpChange(characterId: number, hp: number) {
|
||
await updateCharacter(characterId, { hp_current: hp });
|
||
}
|
||
|
||
async function handleStatChange(
|
||
characterId: number,
|
||
statName: string,
|
||
value: number
|
||
) {
|
||
await updateStat(characterId, statName, value);
|
||
}
|
||
|
||
async function handleUpdate(characterId: number, data: Partial<Character>) {
|
||
await updateCharacter(characterId, data);
|
||
}
|
||
|
||
async function handleDelete(characterId: number) {
|
||
await deleteCharacter(characterId);
|
||
setSelectedId(null);
|
||
}
|
||
|
||
async function handleAddGear(
|
||
characterId: number,
|
||
data: { name: string; type: string; slot_count: number }
|
||
) {
|
||
await addGear(characterId, data);
|
||
}
|
||
|
||
async function handleRemoveGear(characterId: number, gearId: number) {
|
||
await removeGear(characterId, gearId);
|
||
}
|
||
|
||
async function handleAddTalent(
|
||
characterId: number,
|
||
data: { name: string; description: string }
|
||
) {
|
||
await addTalent(characterId, data);
|
||
}
|
||
|
||
async function handleRemoveTalent(characterId: number, talentId: number) {
|
||
await removeTalent(characterId, talentId);
|
||
}
|
||
|
||
const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null;
|
||
|
||
return (
|
||
<div>
|
||
<div className={styles.header}>
|
||
<Link to="/" className={styles.backLink}>
|
||
← Campaigns
|
||
</Link>
|
||
<span className={styles.campaignName}>Campaign</span>
|
||
<button className={styles.addBtn} onClick={() => setShowCreate(true)}>
|
||
+ Add Character
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.grid}>
|
||
{characters.length === 0 && (
|
||
<p className={styles.empty}>
|
||
No characters yet. Add one to get started!
|
||
</p>
|
||
)}
|
||
{characters.map((char) => (
|
||
<CharacterCard
|
||
key={char.id}
|
||
character={char}
|
||
onHpChange={handleHpChange}
|
||
onStatChange={handleStatChange}
|
||
onClick={setSelectedId}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{selectedCharacter && (
|
||
<CharacterDetail
|
||
character={selectedCharacter}
|
||
onUpdate={handleUpdate}
|
||
onStatChange={handleStatChange}
|
||
onAddGear={handleAddGear}
|
||
onRemoveGear={handleRemoveGear}
|
||
onAddTalent={handleAddTalent}
|
||
onRemoveTalent={handleRemoveTalent}
|
||
onDelete={handleDelete}
|
||
onClose={() => setSelectedId(null)}
|
||
/>
|
||
)}
|
||
|
||
{showCreate && (
|
||
<div
|
||
className={styles.createModal}
|
||
onClick={() => setShowCreate(false)}
|
||
>
|
||
<form
|
||
className={styles.createForm}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onSubmit={handleCreate}
|
||
>
|
||
<div className={styles.createTitle}>New Character</div>
|
||
<div className={styles.formField}>
|
||
<label className={styles.formLabel}>Name</label>
|
||
<input
|
||
className={styles.formInput}
|
||
type="text"
|
||
value={newChar.name}
|
||
onChange={(e) =>
|
||
setNewChar({ ...newChar, name: e.target.value })
|
||
}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<div className={styles.formField}>
|
||
<label className={styles.formLabel}>Class</label>
|
||
<select
|
||
className={styles.formSelect}
|
||
value={newChar.class}
|
||
onChange={(e) =>
|
||
setNewChar({ ...newChar, class: e.target.value })
|
||
}
|
||
>
|
||
{CLASSES.map((c) => (
|
||
<option key={c} value={c}>
|
||
{c}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className={styles.formField}>
|
||
<label className={styles.formLabel}>Ancestry</label>
|
||
<select
|
||
className={styles.formSelect}
|
||
value={newChar.ancestry}
|
||
onChange={(e) =>
|
||
setNewChar({ ...newChar, ancestry: e.target.value })
|
||
}
|
||
>
|
||
{ANCESTRIES.map((a) => (
|
||
<option key={a} value={a}>
|
||
{a}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className={styles.formField}>
|
||
<label className={styles.formLabel}>Max HP</label>
|
||
<input
|
||
className={styles.formInput}
|
||
type="number"
|
||
min={1}
|
||
value={newChar.hp_max}
|
||
onChange={(e) =>
|
||
setNewChar({ ...newChar, hp_max: Number(e.target.value) })
|
||
}
|
||
/>
|
||
</div>
|
||
<div className={styles.formActions}>
|
||
<button
|
||
type="button"
|
||
className={`${styles.formBtn} ${styles.formBtnSecondary}`}
|
||
onClick={() => setShowCreate(false)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className={`${styles.formBtn} ${styles.formBtnPrimary}`}
|
||
>
|
||
Create
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: End-to-End Smoke Test
|
||
|
||
**Files:** None (testing only)
|
||
|
||
- [ ] **Step 1: Start the full stack**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark && npm run dev
|
||
```
|
||
|
||
Expected: Both server (port 3000) and client (port 5173) start. Console shows both running.
|
||
|
||
- [ ] **Step 2: Test the full flow in browser**
|
||
|
||
Open `http://localhost:5173` in a browser.
|
||
|
||
1. Create a campaign — should appear in the list
|
||
2. Click into it — should see empty character grid
|
||
3. Click "+ Add Character" — fill in name, class, ancestry, HP
|
||
4. Character card appears in the grid with stats, HP bar, AC
|
||
5. Click +/- on HP — updates immediately
|
||
6. Click +/- on stats — modifier updates
|
||
7. Click card to open detail modal — edit name, add gear, add talent
|
||
8. Close modal — changes reflected on card
|
||
|
||
- [ ] **Step 3: Test real-time sync**
|
||
|
||
Open a second browser tab to the same campaign URL.
|
||
|
||
1. Change a stat in tab 1 — should update in tab 2 within a second
|
||
2. Add a character in tab 2 — should appear in tab 1
|
||
3. Delete a character in tab 1 — should disappear from tab 2
|
||
|
||
- [ ] **Step 4: Test responsive layout**
|
||
|
||
Resize the browser window:
|
||
|
||
- Wide (>1100px): 4 cards across
|
||
- Medium (600-1100px): 2 cards across
|
||
- Narrow (<600px): 1 card, stacked
|
||
|
||
---
|
||
|
||
### Task 15: Debounce Character Detail Field Updates
|
||
|
||
**Files:**
|
||
|
||
- Modify: `client/src/components/CharacterDetail.tsx`
|
||
|
||
Text fields in the detail modal (name, title, notes) currently fire an API call on every keystroke. Add a simple debounce so they only save after the user stops typing.
|
||
|
||
- [ ] **Step 1: Add a useDebounce helper inline in CharacterDetail.tsx**
|
||
|
||
Add this above the component function:
|
||
|
||
```tsx
|
||
function useDebouncedCallback<T extends (...args: unknown[]) => void>(
|
||
callback: T,
|
||
delay: number
|
||
): T {
|
||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||
};
|
||
}, []);
|
||
return ((...args: unknown[]) => {
|
||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||
timeoutRef.current = setTimeout(() => callback(...args), delay);
|
||
}) as T;
|
||
}
|
||
```
|
||
|
||
Add `useRef, useEffect` to the react import.
|
||
|
||
- [ ] **Step 2: Use debounced callback for text fields**
|
||
|
||
Inside the `CharacterDetail` component, add:
|
||
|
||
```tsx
|
||
const debouncedUpdate = useDebouncedCallback(
|
||
(field: string, value: string | number) => {
|
||
onUpdate(character.id, { [field]: value });
|
||
},
|
||
400
|
||
);
|
||
```
|
||
|
||
Update `handleFieldChange` to use debounce for string fields and immediate for numbers:
|
||
|
||
```tsx
|
||
function handleFieldChange(field: string, value: string | number) {
|
||
// Optimistic local update handled by parent via socket
|
||
if (typeof value === "string") {
|
||
debouncedUpdate(field, value);
|
||
} else {
|
||
onUpdate(character.id, { [field]: value });
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify debounce works**
|
||
|
||
Open a character detail, type in the name field. Network tab should show a single PATCH request ~400ms after you stop typing, not one per keystroke.
|