1412 lines
36 KiB
Markdown
1412 lines
36 KiB
Markdown
# View/Edit Mode Redesign — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Redesign CharacterDetail as a view/edit mode split with a clean "Modern Card Panels" layout in view mode and full editing controls in edit mode.
|
||
|
||
**Architecture:** Split CharacterDetail.tsx into CharacterDetail (modal shell + mode state), CharacterSheet (layout), and three panel components (StatsPanel, InfoPanel, GearPanel). Existing sub-components (StatBlock, TalentList, GearList, etc.) gain a `mode` prop to conditionally show/hide interactive controls. No backend changes.
|
||
|
||
**Tech Stack:** React 18, TypeScript, CSS Modules (same as existing)
|
||
|
||
---
|
||
|
||
## File Structure — New and Modified Files
|
||
|
||
```
|
||
client/src/components/
|
||
├── CharacterDetail.tsx # REWRITE: modal shell only, mode state
|
||
├── CharacterDetail.module.css # REWRITE: just overlay/modal styles
|
||
├── CharacterSheet.tsx # CREATE: header banner + 3-panel layout
|
||
├── CharacterSheet.module.css # CREATE: banner + panel grid styles
|
||
├── StatsPanel.tsx # CREATE: ability scores + attacks panel
|
||
├── StatsPanel.module.css # CREATE: compact stat list styles
|
||
├── InfoPanel.tsx # CREATE: talents + character info panel
|
||
├── InfoPanel.module.css # CREATE: info display/edit styles
|
||
├── GearPanel.tsx # CREATE: gear list + currency panel
|
||
├── GearPanel.module.css # CREATE: gear table + icon styles
|
||
├── StatBlock.tsx # MODIFY: add mode prop to hide +/- buttons
|
||
├── StatBlock.module.css # (unchanged)
|
||
├── TalentList.tsx # MODIFY: add mode prop to hide add/remove
|
||
├── TalentList.module.css # (unchanged)
|
||
├── GearList.tsx # MODIFY: add mode prop to hide add/remove
|
||
├── GearList.module.css # MODIFY: gear type icons
|
||
├── CurrencyRow.tsx # MODIFY: add mode prop for +/- vs direct input
|
||
├── CurrencyRow.module.css # MODIFY: +/- button styles
|
||
├── AcDisplay.tsx # MODIFY: add mode prop to disable override
|
||
├── HpBar.tsx # (unchanged — always shows +/-)
|
||
├── AttackBlock.tsx # (unchanged — always read-only)
|
||
└── ItemPicker.tsx # (unchanged — only rendered in edit mode)
|
||
|
||
client/src/pages/
|
||
└── CampaignView.tsx # MODIFY: pass new props to CharacterDetail
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Add `mode` Prop to Existing Sub-Components
|
||
|
||
**Files:**
|
||
|
||
- Modify: `client/src/components/StatBlock.tsx`
|
||
- Modify: `client/src/components/TalentList.tsx`
|
||
- Modify: `client/src/components/GearList.tsx`
|
||
- Modify: `client/src/components/GearList.module.css`
|
||
- Modify: `client/src/components/CurrencyRow.tsx`
|
||
- Modify: `client/src/components/CurrencyRow.module.css`
|
||
- Modify: `client/src/components/AcDisplay.tsx`
|
||
|
||
- [ ] **Step 1: Update StatBlock.tsx — add `mode` prop**
|
||
|
||
Read the file first. Add `mode` to the props interface and conditionally render +/- buttons:
|
||
|
||
```tsx
|
||
interface StatBlockProps {
|
||
stats: Stat[];
|
||
onStatChange: (statName: string, newValue: number) => void;
|
||
mode?: "view" | "edit";
|
||
}
|
||
```
|
||
|
||
In the JSX, wrap each button pair with a mode check. Replace the button rendering inside the statRow div:
|
||
|
||
```tsx
|
||
<div className={styles.statRow}>
|
||
{mode === "edit" && (
|
||
<button
|
||
className={styles.btn}
|
||
onClick={() => onStatChange(name, value - 1)}
|
||
>
|
||
−
|
||
</button>
|
||
)}
|
||
<span className={styles.modifier}>{formatModifier(mod)}</span>
|
||
{mode === "edit" && (
|
||
<button
|
||
className={styles.btn}
|
||
onClick={() => onStatChange(name, value + 1)}
|
||
>
|
||
+
|
||
</button>
|
||
)}
|
||
</div>
|
||
```
|
||
|
||
Default `mode` to `'view'` in the destructuring: `{ stats, onStatChange, mode = 'view' }`
|
||
|
||
Add a `rollSpace` div after the modifier in view mode for future dice buttons:
|
||
|
||
```tsx
|
||
{
|
||
mode === "view" && <span className={styles.rollSpace}></span>;
|
||
}
|
||
```
|
||
|
||
Add to StatBlock.module.css:
|
||
|
||
```css
|
||
.rollSpace {
|
||
width: 2.5rem;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update TalentList.tsx — add `mode` prop**
|
||
|
||
Read the file first. Add `mode` to props:
|
||
|
||
```tsx
|
||
interface TalentListProps {
|
||
talents: Talent[];
|
||
onAdd: (data: { name: string; description: string }) => void;
|
||
onRemove: (talentId: number) => void;
|
||
mode?: "view" | "edit";
|
||
}
|
||
```
|
||
|
||
Default to `'view'`. Conditionally render the remove buttons and add form:
|
||
|
||
- Wrap each remove button: `{mode === 'edit' && (<button ...>✕</button>)}`
|
||
- Wrap the entire add form: `{mode === 'edit' && (<form ...>...</form>)}`
|
||
|
||
- [ ] **Step 3: Update GearList.tsx — add `mode` prop and gear type icons**
|
||
|
||
Read the file first. Add `mode` to props:
|
||
|
||
```tsx
|
||
interface GearListProps {
|
||
gear: Gear[];
|
||
gp: number;
|
||
sp: number;
|
||
cp: number;
|
||
slotsUsed: number;
|
||
slotsMax: number;
|
||
onAddFromItem: (item: GameItem) => void;
|
||
onAddCustom: (data: {
|
||
name: string;
|
||
type: string;
|
||
slot_count: number;
|
||
}) => void;
|
||
onRemove: (gearId: number) => void;
|
||
onCurrencyChange: (field: "gp" | "sp" | "cp", value: number) => void;
|
||
mode?: "view" | "edit";
|
||
}
|
||
```
|
||
|
||
Default to `'view'`.
|
||
|
||
Replace the type badge column with a unicode icon. Add a helper function:
|
||
|
||
```tsx
|
||
function gearIcon(type: string): string {
|
||
switch (type) {
|
||
case "weapon":
|
||
return "⚔";
|
||
case "armor":
|
||
return "🛡";
|
||
case "spell":
|
||
return "✨";
|
||
default:
|
||
return "⚙";
|
||
}
|
||
}
|
||
```
|
||
|
||
In the table, replace the type badge `<span>` with:
|
||
|
||
```tsx
|
||
<td className={`${styles.cell} ${styles.center}`}>
|
||
<span title={item.type}>{gearIcon(item.type)}</span>
|
||
</td>
|
||
```
|
||
|
||
Conditionally render remove buttons: `{mode === 'edit' && (<button ...>✕</button>)}`
|
||
|
||
Conditionally render the add area: `{mode === 'edit' && (<div className={styles.addArea}>...</div>)}`
|
||
|
||
In GearList.module.css, remove the `.typeBadge`, `.weapon`, `.armor`, `.gear`, `.spell` classes (no longer needed).
|
||
|
||
- [ ] **Step 4: Update CurrencyRow.tsx — add `mode` prop for +/- vs direct input**
|
||
|
||
Read the file first. Add `mode` to props:
|
||
|
||
```tsx
|
||
interface CurrencyRowProps {
|
||
gp: number;
|
||
sp: number;
|
||
cp: number;
|
||
onChange: (field: "gp" | "sp" | "cp", value: number) => void;
|
||
mode?: "view" | "edit";
|
||
}
|
||
```
|
||
|
||
Default to `'view'`. In view mode, show +/- buttons around the value. In edit mode, show direct input:
|
||
|
||
```tsx
|
||
function CoinDisplay({
|
||
label,
|
||
value,
|
||
field,
|
||
className,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
field: "gp" | "sp" | "cp";
|
||
className: string;
|
||
}) {
|
||
if (mode === "edit") {
|
||
return (
|
||
<div className={styles.coin}>
|
||
<span className={`${styles.coinLabel} ${className}`}>{label}</span>
|
||
<input
|
||
className={styles.coinInput}
|
||
type="number"
|
||
min={0}
|
||
value={value}
|
||
onChange={(e) => onChange(field, Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className={styles.coin}>
|
||
<span className={`${styles.coinLabel} ${className}`}>{label}</span>
|
||
<button
|
||
className={styles.coinBtn}
|
||
onClick={() => onChange(field, value - 1)}
|
||
>
|
||
−
|
||
</button>
|
||
<span className={styles.coinValue}>{value}</span>
|
||
<button
|
||
className={styles.coinBtn}
|
||
onClick={() => onChange(field, value + 1)}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Add to CurrencyRow.module.css:
|
||
|
||
```css
|
||
.coinBtn {
|
||
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;
|
||
}
|
||
|
||
.coinBtn:hover {
|
||
border-color: #c9a84c;
|
||
color: #c9a84c;
|
||
}
|
||
|
||
.coinValue {
|
||
min-width: 1.5rem;
|
||
text-align: center;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Update AcDisplay.tsx — add `mode` prop**
|
||
|
||
Read the file first. Add `mode` to props:
|
||
|
||
```tsx
|
||
interface AcDisplayProps {
|
||
breakdown: AcBreakdown;
|
||
onOverride: (value: number | null) => void;
|
||
mode?: "view" | "edit";
|
||
}
|
||
```
|
||
|
||
Default to `'view'`. In view mode, the AC value is just a display — no click to edit. Only show the edit/override behavior when `mode === 'edit'`:
|
||
|
||
```tsx
|
||
{mode === 'edit' ? (
|
||
// existing click-to-edit behavior
|
||
editing ? (<input .../>) : (<span onClick={startEdit} ...>{breakdown.effective}</span>)
|
||
) : (
|
||
// view mode: just display the number
|
||
<span className={styles.value}>{breakdown.effective}</span>
|
||
)}
|
||
```
|
||
|
||
Still show the source label and override indicator (if overridden) in both modes — just don't allow editing in view mode. Show the revert button only in edit mode.
|
||
|
||
- [ ] **Step 6: Verify TypeScript compiles**
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
|
||
```
|
||
|
||
Expected: No errors. All existing usages pass no `mode` prop, which defaults to `'view'`, so the app should look read-only now. This is expected — we'll wire up edit mode in later tasks.
|
||
|
||
---
|
||
|
||
### Task 2: Create CharacterSheet + Panel Components (View Mode)
|
||
|
||
**Files:**
|
||
|
||
- Create: `client/src/components/StatsPanel.tsx`
|
||
- Create: `client/src/components/StatsPanel.module.css`
|
||
- Create: `client/src/components/InfoPanel.tsx`
|
||
- Create: `client/src/components/InfoPanel.module.css`
|
||
- Create: `client/src/components/GearPanel.tsx`
|
||
- Create: `client/src/components/GearPanel.module.css`
|
||
- Create: `client/src/components/CharacterSheet.tsx`
|
||
- Create: `client/src/components/CharacterSheet.module.css`
|
||
|
||
- [ ] **Step 1: Create StatsPanel.module.css**
|
||
|
||
```css
|
||
.panel {
|
||
background: #16213e;
|
||
border: 1px solid #333;
|
||
border-radius: 8px;
|
||
padding: 0.75rem;
|
||
}
|
||
|
||
.sectionTitle {
|
||
font-size: 0.8rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.separator {
|
||
border: none;
|
||
border-top: 1px solid #2a2a4a;
|
||
margin: 0.75rem 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create StatsPanel.tsx**
|
||
|
||
```tsx
|
||
import type { Character } from "../types";
|
||
import { generateAttacks } from "../utils/derived-attacks";
|
||
import StatBlock from "./StatBlock";
|
||
import AttackBlock from "./AttackBlock";
|
||
import styles from "./StatsPanel.module.css";
|
||
|
||
interface StatsPanelProps {
|
||
character: Character;
|
||
mode: "view" | "edit";
|
||
onStatChange: (characterId: number, statName: string, value: number) => void;
|
||
}
|
||
|
||
export default function StatsPanel({
|
||
character,
|
||
mode,
|
||
onStatChange,
|
||
}: StatsPanelProps) {
|
||
const attacks = generateAttacks(character);
|
||
|
||
return (
|
||
<div className={styles.panel}>
|
||
<div className={styles.sectionTitle}>Ability Scores</div>
|
||
<StatBlock
|
||
stats={character.stats}
|
||
onStatChange={(statName, value) =>
|
||
onStatChange(character.id, statName, value)
|
||
}
|
||
mode={mode}
|
||
/>
|
||
<hr className={styles.separator} />
|
||
<AttackBlock attacks={attacks} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create InfoPanel.module.css**
|
||
|
||
```css
|
||
.panel {
|
||
background: #16213e;
|
||
border: 1px solid #333;
|
||
border-radius: 8px;
|
||
padding: 0.75rem;
|
||
}
|
||
|
||
.sectionTitle {
|
||
font-size: 0.8rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.infoGrid {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
.infoRow {
|
||
display: flex;
|
||
gap: 0.3rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.infoLabel {
|
||
color: #666;
|
||
font-size: 0.7rem;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
min-width: 5rem;
|
||
}
|
||
|
||
.infoValue {
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
.notes {
|
||
font-size: 0.85rem;
|
||
color: #aaa;
|
||
white-space: pre-wrap;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.editField {
|
||
padding: 0.3rem 0.5rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 5px;
|
||
color: #e0e0e0;
|
||
font-size: 0.85rem;
|
||
width: 100%;
|
||
}
|
||
|
||
.editField:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.editSelect {
|
||
padding: 0.3rem 0.5rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 5px;
|
||
color: #e0e0e0;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.editRow {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.editRow > * {
|
||
flex: 1;
|
||
}
|
||
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.15rem;
|
||
}
|
||
|
||
.fieldLabel {
|
||
font-size: 0.65rem;
|
||
color: #666;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.notesEdit {
|
||
width: 100%;
|
||
min-height: 50px;
|
||
padding: 0.4rem;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 5px;
|
||
color: #e0e0e0;
|
||
font-size: 0.85rem;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
}
|
||
|
||
.notesEdit:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create InfoPanel.tsx**
|
||
|
||
```tsx
|
||
import { useRef, useEffect } from "react";
|
||
import type { Character } from "../types";
|
||
import TalentList from "./TalentList";
|
||
import styles from "./InfoPanel.module.css";
|
||
|
||
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
|
||
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
|
||
const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"];
|
||
|
||
interface InfoPanelProps {
|
||
character: Character;
|
||
mode: "view" | "edit";
|
||
onUpdate: (id: number, data: Partial<Character>) => void;
|
||
onAddTalent: (
|
||
characterId: number,
|
||
data: { name: string; description: string },
|
||
) => void;
|
||
onRemoveTalent: (characterId: number, talentId: number) => void;
|
||
}
|
||
|
||
export default function InfoPanel({
|
||
character,
|
||
mode,
|
||
onUpdate,
|
||
onAddTalent,
|
||
onRemoveTalent,
|
||
}: InfoPanelProps) {
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
};
|
||
}, []);
|
||
|
||
function handleField(field: string, value: string | number) {
|
||
if (typeof value === "string") {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(() => {
|
||
onUpdate(character.id, { [field]: value });
|
||
}, 400);
|
||
} else {
|
||
onUpdate(character.id, { [field]: value });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={styles.panel}>
|
||
<div className={styles.sectionTitle}>Talents</div>
|
||
<TalentList
|
||
talents={character.talents}
|
||
onAdd={(data) => onAddTalent(character.id, data)}
|
||
onRemove={(id) => onRemoveTalent(character.id, id)}
|
||
mode={mode}
|
||
/>
|
||
|
||
<div className={styles.sectionTitle} style={{ marginTop: "0.75rem" }}>
|
||
Info
|
||
</div>
|
||
|
||
{mode === "view" ? (
|
||
<div className={styles.infoGrid}>
|
||
{character.background && (
|
||
<div className={styles.infoRow}>
|
||
<span className={styles.infoLabel}>Background</span>
|
||
<span className={styles.infoValue}>{character.background}</span>
|
||
</div>
|
||
)}
|
||
{character.deity && (
|
||
<div className={styles.infoRow}>
|
||
<span className={styles.infoLabel}>Deity</span>
|
||
<span className={styles.infoValue}>{character.deity}</span>
|
||
</div>
|
||
)}
|
||
{character.languages && (
|
||
<div className={styles.infoRow}>
|
||
<span className={styles.infoLabel}>Languages</span>
|
||
<span className={styles.infoValue}>{character.languages}</span>
|
||
</div>
|
||
)}
|
||
<div className={styles.infoRow}>
|
||
<span className={styles.infoLabel}>Alignment</span>
|
||
<span className={styles.infoValue}>{character.alignment}</span>
|
||
</div>
|
||
{character.notes && (
|
||
<>
|
||
<div className={styles.infoRow}>
|
||
<span className={styles.infoLabel}>Notes</span>
|
||
</div>
|
||
<div className={styles.notes}>{character.notes}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className={styles.infoGrid}>
|
||
<div className={styles.editRow}>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Class</label>
|
||
<select
|
||
className={styles.editSelect}
|
||
value={character.class}
|
||
onChange={(e) => handleField("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.editSelect}
|
||
value={character.ancestry}
|
||
onChange={(e) => handleField("ancestry", e.target.value)}
|
||
>
|
||
{ANCESTRIES.map((a) => (
|
||
<option key={a} value={a}>
|
||
{a}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className={styles.editRow}>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Level</label>
|
||
<input
|
||
className={styles.editField}
|
||
type="number"
|
||
min={0}
|
||
value={character.level}
|
||
onChange={(e) => handleField("level", Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Alignment</label>
|
||
<select
|
||
className={styles.editSelect}
|
||
value={character.alignment}
|
||
onChange={(e) => handleField("alignment", e.target.value)}
|
||
>
|
||
{ALIGNMENTS.map((a) => (
|
||
<option key={a} value={a}>
|
||
{a}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Background</label>
|
||
<input
|
||
className={styles.editField}
|
||
value={character.background}
|
||
placeholder="Urchin..."
|
||
onChange={(e) => handleField("background", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Deity</label>
|
||
<input
|
||
className={styles.editField}
|
||
value={character.deity}
|
||
placeholder="None..."
|
||
onChange={(e) => handleField("deity", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Languages</label>
|
||
<input
|
||
className={styles.editField}
|
||
value={character.languages}
|
||
placeholder="Common, Elvish..."
|
||
onChange={(e) => handleField("languages", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Notes</label>
|
||
<textarea
|
||
className={styles.notesEdit}
|
||
value={character.notes}
|
||
onChange={(e) => handleField("notes", e.target.value)}
|
||
placeholder="Freeform notes..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create GearPanel.module.css**
|
||
|
||
```css
|
||
.panel {
|
||
background: #16213e;
|
||
border: 1px solid #333;
|
||
border-radius: 8px;
|
||
padding: 0.75rem;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Create GearPanel.tsx**
|
||
|
||
```tsx
|
||
import type { Character, GameItem } from "../types";
|
||
import GearList from "./GearList";
|
||
import styles from "./GearPanel.module.css";
|
||
|
||
interface GearPanelProps {
|
||
character: Character;
|
||
mode: "view" | "edit";
|
||
onAddGearFromItem: (characterId: number, item: GameItem) => void;
|
||
onAddGearCustom: (
|
||
characterId: number,
|
||
data: { name: string; type: string; slot_count: number },
|
||
) => void;
|
||
onRemoveGear: (characterId: number, gearId: number) => void;
|
||
onCurrencyChange: (
|
||
characterId: number,
|
||
field: "gp" | "sp" | "cp",
|
||
value: number,
|
||
) => void;
|
||
}
|
||
|
||
export default function GearPanel({
|
||
character,
|
||
mode,
|
||
onAddGearFromItem,
|
||
onAddGearCustom,
|
||
onRemoveGear,
|
||
onCurrencyChange,
|
||
}: GearPanelProps) {
|
||
const slotsUsed = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
|
||
|
||
return (
|
||
<div className={styles.panel}>
|
||
<GearList
|
||
gear={character.gear}
|
||
gp={character.gp}
|
||
sp={character.sp}
|
||
cp={character.cp}
|
||
slotsUsed={slotsUsed}
|
||
slotsMax={character.gear_slots_max}
|
||
onAddFromItem={(item) => onAddGearFromItem(character.id, item)}
|
||
onAddCustom={(data) => onAddGearCustom(character.id, data)}
|
||
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
|
||
onCurrencyChange={(field, value) =>
|
||
onCurrencyChange(character.id, field, value)
|
||
}
|
||
mode={mode}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Create CharacterSheet.module.css**
|
||
|
||
```css
|
||
.banner {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: linear-gradient(135deg, #16213e, #0f1a30);
|
||
border: 1px solid #333;
|
||
border-radius: 8px;
|
||
padding: 0.75rem 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.identity {
|
||
flex: 1;
|
||
}
|
||
|
||
.name {
|
||
font-size: 1.4rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
}
|
||
|
||
.title {
|
||
color: #888;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #666;
|
||
font-size: 0.8rem;
|
||
margin-top: 0.15rem;
|
||
}
|
||
|
||
.vitals {
|
||
display: flex;
|
||
gap: 1.25rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.vital {
|
||
text-align: center;
|
||
}
|
||
|
||
.vitalValue {
|
||
font-size: 1.3rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.vitalValue.hp {
|
||
color: #4caf50;
|
||
}
|
||
|
||
.vitalValue.ac {
|
||
color: #5dade2;
|
||
}
|
||
|
||
.vitalLabel {
|
||
font-size: 0.65rem;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.hpControls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.vitalBtn {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 50%;
|
||
border: 1px solid #444;
|
||
background: #16213e;
|
||
color: #e0e0e0;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.vitalBtn:hover {
|
||
border-color: #c9a84c;
|
||
color: #c9a84c;
|
||
}
|
||
|
||
.hpSlash {
|
||
color: #666;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.xpThreshold {
|
||
font-size: 0.75rem;
|
||
color: #666;
|
||
}
|
||
|
||
.xpCurrent {
|
||
color: #c9a84c;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.nameInput {
|
||
font-size: 1.3rem;
|
||
font-weight: 700;
|
||
color: #c9a84c;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 5px;
|
||
padding: 0.2rem 0.4rem;
|
||
width: 10rem;
|
||
}
|
||
|
||
.nameInput:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.titleInput {
|
||
font-size: 0.85rem;
|
||
color: #888;
|
||
background: #0f1a30;
|
||
border: 1px solid #333;
|
||
border-radius: 5px;
|
||
padding: 0.15rem 0.4rem;
|
||
width: 8rem;
|
||
margin-left: 0.3rem;
|
||
}
|
||
|
||
.titleInput:focus {
|
||
outline: none;
|
||
border-color: #c9a84c;
|
||
}
|
||
|
||
.panels {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.panels {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.panels {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.deleteSection {
|
||
margin-top: 1rem;
|
||
padding-top: 0.75rem;
|
||
border-top: 1px solid #333;
|
||
}
|
||
|
||
.deleteBtn {
|
||
padding: 0.4rem 0.75rem;
|
||
background: transparent;
|
||
border: 1px solid #e74c3c;
|
||
border-radius: 5px;
|
||
color: #e74c3c;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.deleteBtn:hover {
|
||
background: rgba(231, 76, 60, 0.1);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Create CharacterSheet.tsx**
|
||
|
||
```tsx
|
||
import { useState, useRef, useEffect } from "react";
|
||
import type { Character, GameItem } from "../types";
|
||
import { calculateAC } from "../utils/derived-ac";
|
||
import AcDisplay from "./AcDisplay";
|
||
import StatsPanel from "./StatsPanel";
|
||
import InfoPanel from "./InfoPanel";
|
||
import GearPanel from "./GearPanel";
|
||
import styles from "./CharacterSheet.module.css";
|
||
|
||
interface CharacterSheetProps {
|
||
character: Character;
|
||
mode: "view" | "edit";
|
||
onUpdate: (id: number, data: Partial<Character>) => void;
|
||
onStatChange: (characterId: number, statName: string, value: number) => void;
|
||
onAddGearFromItem: (characterId: number, item: GameItem) => void;
|
||
onAddGearCustom: (
|
||
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;
|
||
}
|
||
|
||
export default function CharacterSheet({
|
||
character,
|
||
mode,
|
||
onUpdate,
|
||
onStatChange,
|
||
onAddGearFromItem,
|
||
onAddGearCustom,
|
||
onRemoveGear,
|
||
onAddTalent,
|
||
onRemoveTalent,
|
||
onDelete,
|
||
}: CharacterSheetProps) {
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||
const acBreakdown = calculateAC(character);
|
||
const xpThreshold = character.level * 10;
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
};
|
||
}, []);
|
||
|
||
function handleNameField(field: string, value: string) {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(() => {
|
||
onUpdate(character.id, { [field]: value });
|
||
}, 400);
|
||
}
|
||
|
||
function handleAcOverride(value: number | null) {
|
||
const overrides = { ...(character.overrides || {}) };
|
||
if (value === null) {
|
||
delete overrides.ac;
|
||
} else {
|
||
overrides.ac = value;
|
||
}
|
||
onUpdate(character.id, { overrides } as Partial<Character>);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* HEADER BANNER */}
|
||
<div className={styles.banner}>
|
||
<div className={styles.identity}>
|
||
{mode === "edit" ? (
|
||
<div>
|
||
<input
|
||
className={styles.nameInput}
|
||
defaultValue={character.name}
|
||
onChange={(e) => handleNameField("name", e.target.value)}
|
||
/>
|
||
<input
|
||
className={styles.titleInput}
|
||
defaultValue={character.title}
|
||
placeholder="title..."
|
||
onChange={(e) => handleNameField("title", e.target.value)}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className={styles.name}>
|
||
{character.name}
|
||
{character.title && (
|
||
<span className={styles.title}> {character.title}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className={styles.subtitle}>
|
||
Level {character.level} {character.ancestry} {character.class}
|
||
</div>
|
||
</div>
|
||
<div className={styles.vitals}>
|
||
{/* HP with +/- always */}
|
||
<div className={styles.vital}>
|
||
<div className={styles.hpControls}>
|
||
<button
|
||
className={styles.vitalBtn}
|
||
onClick={() =>
|
||
onUpdate(character.id, {
|
||
hp_current: character.hp_current - 1,
|
||
})
|
||
}
|
||
>
|
||
−
|
||
</button>
|
||
<span className={`${styles.vitalValue} ${styles.hp}`}>
|
||
{character.hp_current}
|
||
</span>
|
||
<span className={styles.hpSlash}>/</span>
|
||
<span style={{ color: "#888", fontWeight: 600 }}>
|
||
{character.hp_max}
|
||
</span>
|
||
<button
|
||
className={styles.vitalBtn}
|
||
onClick={() =>
|
||
onUpdate(character.id, {
|
||
hp_current: character.hp_current + 1,
|
||
})
|
||
}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<div className={styles.vitalLabel}>HP</div>
|
||
</div>
|
||
|
||
{/* AC — display only in view, override in edit */}
|
||
<div className={styles.vital}>
|
||
<AcDisplay
|
||
breakdown={acBreakdown}
|
||
onOverride={handleAcOverride}
|
||
mode={mode}
|
||
/>
|
||
</div>
|
||
|
||
{/* XP with +/- always */}
|
||
<div className={styles.vital}>
|
||
<div className={styles.hpControls}>
|
||
<button
|
||
className={styles.vitalBtn}
|
||
onClick={() =>
|
||
onUpdate(character.id, { xp: Math.max(0, character.xp - 1) })
|
||
}
|
||
>
|
||
−
|
||
</button>
|
||
<span className={styles.vitalValue}>
|
||
<span className={styles.xpCurrent}>{character.xp}</span>
|
||
<span className={styles.xpThreshold}> / {xpThreshold}</span>
|
||
</span>
|
||
<button
|
||
className={styles.vitalBtn}
|
||
onClick={() => onUpdate(character.id, { xp: character.xp + 1 })}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<div className={styles.vitalLabel}>XP</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* THREE PANELS */}
|
||
<div className={styles.panels}>
|
||
<StatsPanel
|
||
character={character}
|
||
mode={mode}
|
||
onStatChange={onStatChange}
|
||
/>
|
||
<InfoPanel
|
||
character={character}
|
||
mode={mode}
|
||
onUpdate={onUpdate}
|
||
onAddTalent={onAddTalent}
|
||
onRemoveTalent={onRemoveTalent}
|
||
/>
|
||
<GearPanel
|
||
character={character}
|
||
mode={mode}
|
||
onAddGearFromItem={onAddGearFromItem}
|
||
onAddGearCustom={onAddGearCustom}
|
||
onRemoveGear={onRemoveGear}
|
||
onCurrencyChange={(charId, field, value) =>
|
||
onUpdate(charId, { [field]: value })
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
{/* DELETE — edit mode only */}
|
||
{mode === "edit" && (
|
||
<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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Rewrite CharacterDetail as Modal Shell with Mode Toggle
|
||
|
||
**Files:**
|
||
|
||
- Rewrite: `client/src/components/CharacterDetail.tsx`
|
||
- Rewrite: `client/src/components/CharacterDetail.module.css`
|
||
|
||
- [ ] **Step 1: Rewrite 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: 1100px;
|
||
max-height: 95vh;
|
||
overflow-y: auto;
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.topBar {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.editBtn {
|
||
padding: 0.35rem 0.75rem;
|
||
background: transparent;
|
||
border: 1px solid #c9a84c;
|
||
border-radius: 5px;
|
||
color: #c9a84c;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.editBtn:hover {
|
||
background: rgba(201, 168, 76, 0.15);
|
||
}
|
||
|
||
.editBtn.active {
|
||
background: #c9a84c;
|
||
color: #1a1a2e;
|
||
}
|
||
|
||
.closeBtn {
|
||
background: none;
|
||
border: none;
|
||
color: #888;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
padding: 0.25rem 0.5rem;
|
||
}
|
||
|
||
.closeBtn:hover {
|
||
color: #e0e0e0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite CharacterDetail.tsx**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import type { Character, GameItem } from "../types";
|
||
import CharacterSheet from "./CharacterSheet";
|
||
import styles from "./CharacterDetail.module.css";
|
||
|
||
interface CharacterDetailProps {
|
||
character: Character;
|
||
onUpdate: (id: number, data: Partial<Character>) => void;
|
||
onStatChange: (characterId: number, statName: string, value: number) => void;
|
||
onAddGearFromItem: (characterId: number, item: GameItem) => void;
|
||
onAddGearCustom: (
|
||
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,
|
||
onAddGearFromItem,
|
||
onAddGearCustom,
|
||
onRemoveGear,
|
||
onAddTalent,
|
||
onRemoveTalent,
|
||
onDelete,
|
||
onClose,
|
||
}: CharacterDetailProps) {
|
||
const [mode, setMode] = useState<"view" | "edit">("view");
|
||
|
||
return (
|
||
<div className={styles.overlay} onClick={onClose}>
|
||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||
<div className={styles.topBar}>
|
||
<button
|
||
className={`${styles.editBtn} ${mode === "edit" ? styles.active : ""}`}
|
||
onClick={() => setMode(mode === "view" ? "edit" : "view")}
|
||
>
|
||
{mode === "view" ? "Edit" : "Done"}
|
||
</button>
|
||
<button className={styles.closeBtn} onClick={onClose}>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<CharacterSheet
|
||
character={character}
|
||
mode={mode}
|
||
onUpdate={onUpdate}
|
||
onStatChange={onStatChange}
|
||
onAddGearFromItem={onAddGearFromItem}
|
||
onAddGearCustom={onAddGearCustom}
|
||
onRemoveGear={onRemoveGear}
|
||
onAddTalent={onAddTalent}
|
||
onRemoveTalent={onRemoveTalent}
|
||
onDelete={onDelete}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Update CampaignView Props
|
||
|
||
**Files:**
|
||
|
||
- Modify: `client/src/pages/CampaignView.tsx`
|
||
|
||
- [ ] **Step 1: Update CampaignView.tsx**
|
||
|
||
The CharacterDetail props interface hasn't changed — it still expects the same handlers. No changes needed to CampaignView unless the prop names changed.
|
||
|
||
Read CampaignView.tsx and verify the CharacterDetail usage still matches. The props are: `character, onUpdate, onStatChange, onAddGearFromItem, onAddGearCustom, onRemoveGear, onAddTalent, onRemoveTalent, onDelete, onClose` — same as before.
|
||
|
||
If there are any mismatches, fix them.
|
||
|
||
- [ ] **Step 2: Verify full TypeScript compilation**
|
||
|
||
```bash
|
||
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
|
||
```
|
||
|
||
Expected: No errors.
|
||
|
||
---
|
||
|
||
### Task 5: End-to-End Smoke Test
|
||
|
||
**Files:** None (testing only)
|
||
|
||
- [ ] **Step 1: Restart the full stack**
|
||
|
||
```bash
|
||
# Kill any running servers
|
||
lsof -ti:3000 | xargs kill 2>/dev/null
|
||
lsof -ti:5173 | xargs kill 2>/dev/null
|
||
cd /Users/aaron.wood/workspace/shadowdark && npm run dev
|
||
```
|
||
|
||
- [ ] **Step 2: Test view mode (default)**
|
||
|
||
Open http://localhost:5173, go into a campaign, click a character:
|
||
|
||
1. Modal opens in VIEW mode — clean card panel layout
|
||
2. Header shows name, class/ancestry/level, HP with +/-, AC display, XP with +/-
|
||
3. Left panel: ability scores as compact 2-column list (no +/- buttons), attacks below with separator
|
||
4. Center panel: talents as read-only list, info fields as plain text
|
||
5. Right panel: gear list with unicode type icons (no delete buttons), currency with +/- buttons
|
||
6. No "Delete Character" button visible
|
||
7. Click HP +/- — works in view mode
|
||
8. Click currency +/- — works in view mode
|
||
9. Click XP +/- — works in view mode
|
||
|
||
- [ ] **Step 3: Test edit mode**
|
||
|
||
1. Click "Edit" button — toggles to edit mode, button text changes to "Done"
|
||
2. Header: name and title become input fields
|
||
3. Left panel: ability scores get +/- buttons
|
||
4. Center panel: talents get add form and remove buttons, info fields become editable inputs/dropdowns
|
||
5. Right panel: gear gets "+ Add Gear" and delete buttons, currency switches to direct input
|
||
6. "Delete Character" button appears at bottom
|
||
7. AC becomes click-to-override
|
||
8. Click "Done" — returns to view mode, all changes persisted
|
||
|
||
- [ ] **Step 4: Test real-time sync**
|
||
|
||
Open second tab, same campaign:
|
||
|
||
1. In tab 1 (view mode), click HP −
|
||
2. Tab 2 shows updated HP on the card
|
||
3. In tab 2, open same character — both tabs viewing same character
|
||
4. Tab 1 changes XP — tab 2 updates
|
||
|
||
- [ ] **Step 5: Test responsive layout**
|
||
|
||
Resize browser:
|
||
|
||
- 1100px+: 3 panels side by side
|
||
- 768-1100px: 2 panels
|
||
- <768px: single column stacked
|