V1.0.1: Aldenfell campaign seed, content library, party overview

- Seed now resets the database and seeds the Aldenfell campaign from the
  campaign book: admin DM user, default weapons, locations, bestiary,
  pre-built encounters, and DM/shared notes
- Default weapon/spell/monster library, character profile fields,
  locations and dice mode (new migration)
- Party overview page, pagination, info tips, build preview calc
- Remove stale demo-login hint; notes preserve line breaks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:24:48 +02:00
co-authored by Claude Opus 5.5
parent 9b29dc8253
commit 94067f1268
34 changed files with 3158 additions and 391 deletions
+2
View File
@@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { CampaignListPage } from './pages/CampaignListPage';
import { CampaignHomePage } from './pages/CampaignHomePage';
import { PartyOverviewPage } from './pages/PartyOverviewPage';
import { CharacterSheetPage } from './pages/CharacterSheetPage';
import { CharacterBuilderPage } from './pages/CharacterBuilderPage';
import { EncounterPage } from './pages/EncounterPage';
@@ -19,6 +20,7 @@ function App() {
<Route path="/" element={<Navigate to="/campaigns" replace />} />
<Route path="/campaigns" element={<CampaignListPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignHomePage />} />
<Route path="/campaigns/:campaignId/party" element={<PartyOverviewPage />} />
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
+88 -2
View File
@@ -2,12 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from './client';
import type {
Campaign,
CampaignLibrary,
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
DiceMode,
Encounter,
EncounterParticipant,
InventoryItem,
Location,
Monster,
Note,
QuickStats,
@@ -154,12 +157,23 @@ function invalidateCharacter(qc: ReturnType<typeof useQueryClient>, characterId:
qc.invalidateQueries({ queryKey: ['character', characterId] });
}
export function usePatchDiceMode(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (diceMode: DiceMode) =>
(await apiClient.patch<{ diceMode: DiceMode }>(`/characters/${characterId}/build/dice-mode`, { diceMode })).data
.diceMode,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useBuildRoll(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () =>
mutationFn: async (body?: { value?: number; abilitySets?: number[][] }) =>
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(
`/characters/${characterId}/build/roll`
`/characters/${characterId}/build/roll`,
body
)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
@@ -320,6 +334,78 @@ export function useDeleteMonster(campaignId: string) {
});
}
// --------------------------------------------------------------------------
// Default content library — search/browse, "add to campaign"
// --------------------------------------------------------------------------
export function useCampaignLibrary(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'library'],
enabled: Boolean(campaignId),
staleTime: Infinity, // static reference data — never changes under a campaign
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
});
}
export function useAddWeaponFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) =>
(await apiClient.post<{ weapon: Weapon }>(`/campaigns/${campaignId}/weapons/from-library`, { key })).data
.weapon,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'weapons'] }),
});
}
export function useAddSpellFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) =>
(await apiClient.post<{ spell: Spell }>(`/campaigns/${campaignId}/spells/from-library`, { key })).data.spell,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'spells'] }),
});
}
export function useAddMonsterFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) =>
(await apiClient.post<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/from-library`, { key })).data
.monster,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
});
}
// --------------------------------------------------------------------------
// Locations
// --------------------------------------------------------------------------
export function useCampaignLocations(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'locations'],
enabled: Boolean(campaignId),
queryFn: async () =>
(await apiClient.get<{ locations: Location[] }>(`/campaigns/${campaignId}/locations`)).data.locations,
});
}
export function useCreateLocation(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; description?: string | null }) =>
(await apiClient.post<{ location: Location }>(`/campaigns/${campaignId}/locations`, data)).data.location,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] }),
});
}
export function useDeleteLocation(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (locationId: string) => apiClient.delete(`/campaigns/${campaignId}/locations/${locationId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] }),
});
}
// --------------------------------------------------------------------------
// Encounters
// --------------------------------------------------------------------------
+95
View File
@@ -36,6 +36,8 @@ export type BuildStage =
| 'CHOOSING_EQUIPMENT'
| 'COMPLETE';
export type DiceMode = 'DIGITAL' | 'PHYSICAL';
export interface User {
id: string;
email: string;
@@ -77,6 +79,16 @@ export interface CharacterSummary {
tempHp: number;
maxHp: number | null;
armorClass: number | null;
currentLocationId: string | null;
currentLocationName: string | null;
}
export interface Location {
id: string;
campaignId: string;
name: string;
description: string | null;
createdAt: string;
}
export interface AbilityDetail {
@@ -178,6 +190,14 @@ export interface CharacterDetail {
tempHp: number;
armorBonus: number;
specialAbilities: string | null;
background: string | null;
appearance: string | null;
// Absent entirely (not null) in the API response for anyone but the
// owning player — a real server-enforced privacy boundary, not a UI hide.
privateNotes?: string | null;
diceMode: DiceMode;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number;
buildStage: BuildStage;
buildState: Record<string, unknown> | null;
@@ -211,6 +231,31 @@ export interface Monster {
export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
export interface CombatWeapon {
characterWeaponId: string;
name: string;
equipped: boolean;
quantity: number;
attackBonus: number;
damageBonus: number;
damageDice: string;
damageType: string;
}
export interface CombatSpell {
characterSpellId: string;
name: string;
level: number;
prepared: boolean;
}
export interface CharacterCombatInfo {
armorClass: number;
weapons: CombatWeapon[];
spells: CombatSpell[];
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
}
export interface EncounterParticipant {
id: string;
encounterId: string;
@@ -224,6 +269,7 @@ export interface EncounterParticipant {
isMonster: boolean;
character?: { id: string; name: string } | null;
monster?: { id: string; name: string } | null;
characterCombatInfo?: CharacterCombatInfo;
}
export interface Encounter {
@@ -247,6 +293,55 @@ export interface Note {
author?: { displayName: string };
}
export interface LibraryWeapon {
key: string;
name: string;
category: string;
damageDice: string;
damageType: string;
attackAbility: 'STR' | 'DEX' | 'FINESSE';
properties: string[];
rangeNormal: number | null;
rangeLong: number | null;
}
export interface LibrarySpell {
key: string;
name: string;
level: number;
school: string | null;
castingTime: string | null;
range: string | null;
duration: string | null;
description: string | null;
classes: CharacterClass[];
}
export interface LibraryMonster {
key: string;
name: string;
challengeRating: string | null;
ac: number;
maxHp: number;
speed: number;
strScore: number;
dexScore: number;
conScore: number;
intScore: number;
wisScore: number;
chaScore: number;
attackBonus: number;
damageDice: string | null;
specialTrait: string | null;
notes: string | null;
}
export interface CampaignLibrary {
weapons: LibraryWeapon[];
spells: LibrarySpell[];
monsters: LibraryMonster[];
}
export interface QuickStatsWeapon {
characterWeaponId: string;
weaponId: string;
+54
View File
@@ -0,0 +1,54 @@
import { useId, useState } from 'react';
// Short explanations for the stat labels sprinkled across the sheet, quick
// stats, party table, and builder — "what is this, and how is it worked
// out" for someone who doesn't already know D&D 5e by heart.
export const STAT_HELP: Record<string, string> = {
AC: 'Armor Class — how hard you are to hit. 10 + your Dexterity modifier + any armor bonus.',
HP: 'Hit Points — damage you can take before falling unconscious. Max HP is set by your class hit die, Constitution modifier, and level.',
TEMP_HP: 'Temporary Hit Points — an extra buffer that absorbs damage first and disappears when it runs out (it does not stack with more temp HP).',
INITIATIVE: 'Initiative — added to a d20 roll at the start of a fight to decide turn order. Equal to your Dexterity modifier.',
PROFICIENCY_BONUS: 'Proficiency Bonus — added to attacks, saves, and skills you are trained in. Grows with level (+2 at level 1, up to +6 at level 20).',
PASSIVE_PERCEPTION: 'Passive Perception — your always-on alertness score, used instead of rolling when you are not actively searching. 10 + your Perception skill bonus.',
SAVING_THROW: 'Saving Throw — a roll to resist an effect (like a spell or trap). Your ability modifier, plus your proficiency bonus if your class is trained in that save.',
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
ARMOR_BONUS: 'Armor Bonus — a flat bonus to AC from equipped armor or a shield, added on top of 10 + Dex modifier.',
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
ATTACK_BONUS: 'Attack Bonus — added to the d20 when you attack with this weapon. Ability modifier (Str, Dex, or the better of the two for finesse weapons), plus proficiency bonus.',
DAMAGE_BONUS: 'Damage Bonus — added to the weapon’s damage dice on a hit. Same ability modifier as the attack bonus.',
CHALLENGE_RATING: 'Challenge Rating — a rough measure of how dangerous a monster is on its own, used to gauge encounter difficulty.',
HIT_DICE: 'Hit Dice — the die your class rolls (or uses the fixed average of) when your HP increases at each level.',
};
export function InfoTip({ term, label = 'What is this?' }: { term: keyof typeof STAT_HELP; label?: string }) {
const [open, setOpen] = useState(false);
const id = useId();
const text = STAT_HELP[term];
if (!text) return null;
return (
<span className="info-tip" onMouseLeave={() => setOpen(false)}>
<button
type="button"
className="info-tip-trigger"
aria-describedby={id}
aria-label={label}
onMouseEnter={() => setOpen(true)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onClick={() => setOpen((o) => !o)}
>
ⓘ
</button>
{open && (
<span role="tooltip" id={id} className="info-tip-bubble">
{text}
</span>
)}
</span>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useMemo, useState } from 'react';
// Small generic pager shared by every list-turned-table in the app, so page
// state isn't hand-rolled per screen. Resets to page 1 whenever the
// underlying item count shrinks below the current page (e.g. after a delete
// or a filter change).
export function usePagination<T>(items: T[] | undefined, pageSize = 10) {
const [page, setPage] = useState(1);
const all = items || [];
const pageCount = Math.max(1, Math.ceil(all.length / pageSize));
const safePage = Math.min(page, pageCount);
const pageItems = useMemo(() => all.slice((safePage - 1) * pageSize, safePage * pageSize), [all, safePage, pageSize]);
return {
page: safePage,
pageCount,
pageItems,
total: all.length,
setPage,
nextPage: () => setPage((p) => Math.min(pageCount, p + 1)),
prevPage: () => setPage((p) => Math.max(1, p - 1)),
};
}
export function Pagination({
page,
pageCount,
onPrev,
onNext,
}: {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
}) {
if (pageCount <= 1) return null;
return (
<div className="pagination">
<button type="button" className="btn btn-ghost" onClick={onPrev} disabled={page <= 1}>
← Prev
</button>
<span className="pagination-status">
Page {page} of {pageCount}
</span>
<button type="button" className="btn btn-ghost" onClick={onNext} disabled={page >= pageCount}>
Next →
</button>
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod } from '../lib/format';
import { fmtMod, hpBarClass } from '../lib/format';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
@@ -51,7 +51,7 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className="hp-bar-fill" style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
<div className={hpBarClass(data.currentHp, data.maxHp)} style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
</div>
{data.spellcasting && (
+152 -2
View File
@@ -367,6 +367,29 @@ textarea:focus {
box-shadow: 0 0 0 3px var(--accent-soft);
}
select {
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem 0.75rem;
color: var(--text);
}
select option {
background: var(--bg-inset);
color: var(--text);
}
/* Native checkboxes/radios otherwise render as unstyled white boxes in most
browsers, breaking the dark theme — this keeps them small and on-brand
without a full custom-control rebuild. */
input[type='checkbox'],
input[type='radio'] {
accent-color: var(--accent);
width: 16px;
height: 16px;
cursor: pointer;
}
.checkbox-row {
display: flex;
align-items: center;
@@ -436,8 +459,16 @@ textarea:focus {
}
.hp-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--danger) 0%, var(--success) 100%);
transition: width 0.3s ease;
background: var(--success-strong);
transition: width 0.3s ease, background-color 0.3s ease;
}
/* Color reflects the actual HP percentage instead of a fixed gradient, so a
combat tracker reads "healthy / bloodied / critical" at a glance. */
.hp-bar-fill.hp-bloodied {
background: var(--accent-strong);
}
.hp-bar-fill.hp-critical {
background: var(--danger-strong);
}
.divider {
@@ -572,6 +603,125 @@ textarea:focus {
margin-top: 2rem;
}
/* ---------------------------------------------------------------------- */
/* Info tooltips */
/* ---------------------------------------------------------------------- */
.info-tip {
position: relative;
display: inline-flex;
margin-left: 0.3rem;
}
.info-tip-trigger {
background: transparent;
border: none;
color: var(--text-dim);
cursor: help;
font-size: 0.85em;
padding: 0;
line-height: 1;
}
.info-tip-trigger:hover,
.info-tip-trigger:focus {
color: var(--accent-strong);
outline: none;
}
.info-tip-bubble {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 260px;
background: var(--bg-inset);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 0.6rem 0.75rem;
font-size: 0.8rem;
font-family: var(--font-body);
text-transform: none;
letter-spacing: normal;
color: var(--text);
box-shadow: var(--shadow-deep);
z-index: 100;
pointer-events: none;
}
/* ---------------------------------------------------------------------- */
/* Tables + pagination */
/* ---------------------------------------------------------------------- */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
}
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
table.data-table th,
table.data-table td {
padding: 0.55rem 0.75rem;
text-align: left;
white-space: nowrap;
}
table.data-table thead th {
background: var(--bg-inset);
color: var(--text-muted);
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
}
table.data-table tbody tr {
border-bottom: 1px solid var(--border-soft);
}
table.data-table tbody tr:last-child {
border-bottom: none;
}
table.data-table tbody tr:hover {
background: var(--bg-card-hover);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.75rem 0;
}
.pagination-status {
color: var(--text-muted);
font-size: 0.85rem;
}
/* ---------------------------------------------------------------------- */
/* Character builder live stats preview */
/* ---------------------------------------------------------------------- */
.build-layout {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 900px) {
.build-layout {
grid-template-columns: 1fr 320px;
align-items: start;
}
.build-preview {
position: sticky;
top: 80px;
}
}
.preview-highlight {
color: var(--accent-strong);
font-weight: 700;
}
@media (max-width: 640px) {
.page {
padding: 1rem 0.85rem 5rem;
+33 -2
View File
@@ -1,7 +1,8 @@
import type { Ability, CharacterClass } from '../api/types';
// Mirrors backend/src/game/data/classes.js spellcastingAbility column — used
// only to decide whether the builder's equipment step offers spell picks.
// Mirrors backend/src/game/data/classes.js — used only to preview the
// running math live during the builder. The server recomputes authoritatively
// at finalize, so this duplication is display-only and never gameplay-deciding.
export const CLASS_SPELLCASTING_ABILITY: Record<CharacterClass, Ability | null> = {
FIGHTER: null,
BARBARIAN: null,
@@ -16,3 +17,33 @@ export const CLASS_SPELLCASTING_ABILITY: Record<CharacterClass, Ability | null>
DRUID: 'WIS',
MONK: null,
};
export const CLASS_HIT_DIE: Record<CharacterClass, number> = {
FIGHTER: 10,
BARBARIAN: 12,
ROGUE: 8,
RANGER: 10,
WIZARD: 6,
SORCERER: 6,
CLERIC: 8,
PALADIN: 10,
BARD: 8,
WARLOCK: 8,
DRUID: 8,
MONK: 8,
};
export const CLASS_SAVE_PROFICIENCIES: Record<CharacterClass, Ability[]> = {
FIGHTER: ['STR', 'CON'],
BARBARIAN: ['STR', 'CON'],
ROGUE: ['DEX', 'INT'],
RANGER: ['STR', 'DEX'],
WIZARD: ['INT', 'WIS'],
SORCERER: ['CON', 'CHA'],
CLERIC: ['WIS', 'CHA'],
PALADIN: ['WIS', 'CHA'],
BARD: ['DEX', 'CHA'],
WARLOCK: ['WIS', 'CHA'],
DRUID: ['INT', 'WIS'],
MONK: ['STR', 'DEX'],
};
+10
View File
@@ -60,6 +60,16 @@ export function fmtMod(mod: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}
// Healthy / bloodied (<=50%) / critical (<=25%) — matches .hp-bar-fill's
// modifier classes in index.css.
export function hpBarClass(current: number, max: number): string {
if (max <= 0) return 'hp-bar-fill';
const pct = current / max;
if (pct <= 0.25) return 'hp-bar-fill hp-critical';
if (pct <= 0.5) return 'hp-bar-fill hp-bloodied';
return 'hp-bar-fill';
}
export function extractErrorMessage(err: unknown): string {
const anyErr = err as { response?: { data?: { error?: unknown } } };
const apiError = anyErr?.response?.data?.error;
+64
View File
@@ -0,0 +1,64 @@
import type { Ability } from '../api/types';
// Mirrors the pure formulas in backend/src/game/calculations.js — used only
// to render a live "what does this change" preview while building a
// character. Display-only: the server recomputes authoritatively at
// finalize, so this duplication is never gameplay-deciding. Keep in sync by
// hand if the backend formulas change.
export function abilityModifier(score: number): number {
return Math.floor((score - 10) / 2);
}
export function proficiencyBonus(level: number): number {
return 2 + Math.floor((level - 1) / 4);
}
export function armorClass(dexMod: number, armorBonus = 0): number {
return 10 + dexMod + armorBonus;
}
// Level 1 always uses the maximum possible Hit Die roll; every level after
// uses the fixed "average" value (floor(hitDieSize / 2) + 1).
export function hpMax(level: number, hitDieSize: number, conMod: number): number {
const base = hitDieSize + conMod;
if (level <= 1) return base;
const perLevelAverage = Math.floor(hitDieSize / 2) + 1;
return base + (level - 1) * (perLevelAverage + conMod);
}
export function saveBonus(abilityMod: number, proficient: boolean, profBonus: number): number {
return abilityMod + (proficient ? profBonus : 0);
}
export function skillBonus(abilityMod: number, proficient: boolean, profBonus: number, expertise = false): number {
if (!proficient) return abilityMod;
return abilityMod + profBonus * (expertise ? 2 : 1);
}
export function passivePerception(perceptionSkillBonus: number): number {
return 10 + perceptionSkillBonus;
}
export function spellSaveDC(profBonus: number, spellAbilityMod: number): number {
return 8 + profBonus + spellAbilityMod;
}
export function spellAttackBonus(profBonus: number, spellAbilityMod: number): number {
return profBonus + spellAbilityMod;
}
export function fmtMod(n: number): string {
return n >= 0 ? `+${n}` : `${n}`;
}
export interface PreviewAbilities {
STR: number;
DEX: number;
CON: number;
INT: number;
WIS: number;
CHA: number;
}
export const ABILITY_ORDER: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
+19
View File
@@ -0,0 +1,19 @@
import type { Species } from '../api/types';
// Quick visual identification in lists/tables — purely decorative.
export const SPECIES_ICONS: Record<Species, string> = {
HUMAN: '🙂',
HALF_ELF: '🧝',
ELF: '🧝‍♀️',
GNOME: '🍄',
DWARF: '⛏️',
HALFLING: '🌾',
DRAGONBORN: '🐉',
TIEFLING: '😈',
HALF_ORC: '🪓',
ORC: '👹',
};
export function speciesIcon(species: Species | null): string {
return species ? SPECIES_ICONS[species] || '❓' : '❓';
}
+263 -30
View File
@@ -18,12 +18,21 @@ import {
useCreateNote,
useUpdateCampaign,
useRegenerateInvite,
useCampaignLocations,
useCreateLocation,
useDeleteLocation,
useCampaignLibrary,
useAddWeaponFromLibrary,
useAddSpellFromLibrary,
useAddMonsterFromLibrary,
} from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { usePagination, Pagination } from '../components/Paginated';
import { extractErrorMessage } from '../lib/format';
import type { CharacterSummary } from '../api/types';
import { speciesIcon } from '../lib/speciesIcons';
import type { CharacterSummary, LibraryMonster, LibrarySpell, LibraryWeapon } from '../api/types';
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
const inProgress = character.buildStage !== 'COMPLETE';
@@ -31,6 +40,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
<div className="card flex-between flex-wrap">
<div>
<div className="flex-row">
<span aria-hidden="true">{speciesIcon(character.species)}</span>
<Link to={`/characters/${character.id}`} style={{ fontFamily: 'var(--font-display)', color: 'var(--accent-strong)', fontSize: '1.05rem' }}>
{character.name}
</Link>
@@ -39,6 +49,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
{character.species ? `${character.species} ` : ''}
{character.characterClass || (inProgress ? 'Not yet built' : '')} · Level {character.level}
{character.currentLocationName && ` · 📍 ${character.currentLocationName}`}
</p>
</div>
<div className="flex-row">
@@ -60,6 +71,45 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
);
}
function LibraryPicker<T extends { key: string; name: string }>({
items,
onAdd,
adding,
renderMeta,
placeholder,
}: {
items: T[];
onAdd: (key: string) => void;
adding: boolean;
renderMeta: (item: T) => string;
placeholder: string;
}) {
const [query, setQuery] = useState('');
const filtered = items.filter((i) => i.name.toLowerCase().includes(query.toLowerCase()));
const { pageItems, page, pageCount, prevPage, nextPage } = usePagination(filtered, 6);
return (
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
<input placeholder={placeholder} value={query} onChange={(e) => setQuery(e.target.value)} className="mt-sm" style={{ width: '100%' }} />
<div className="mt-sm">
{pageItems.map((item) => (
<div key={item.key} className="flex-between" style={{ padding: '0.35rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{item.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6, fontSize: '0.85rem' }}>{renderMeta(item)}</span>
</div>
<button className="btn btn-ghost" disabled={adding} onClick={() => onAdd(item.key)}>
+ Add
</button>
</div>
))}
{filtered.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No matches.</p>}
</div>
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} />
</div>
);
}
export function CampaignHomePage() {
const { campaignId = '' } = useParams();
const { user } = useAuth();
@@ -73,6 +123,8 @@ export function CampaignHomePage() {
const { data: weapons } = useCampaignWeapons(campaignId);
const { data: spells } = useCampaignSpells(campaignId);
const { data: notes } = useCampaignNotes(campaignId);
const { data: locations } = useCampaignLocations(campaignId);
const { data: library } = useCampaignLibrary(campaignId);
const createCharacter = useCreateCharacter(campaignId);
const deleteCharacter = useDeleteCharacter(campaignId);
@@ -84,15 +136,33 @@ export function CampaignHomePage() {
const createNote = useCreateNote();
const updateCampaign = useUpdateCampaign(campaignId);
const regenerateInvite = useRegenerateInvite(campaignId);
const createLocation = useCreateLocation(campaignId);
const deleteLocation = useDeleteLocation(campaignId);
const addWeaponFromLibrary = useAddWeaponFromLibrary(campaignId);
const addSpellFromLibrary = useAddSpellFromLibrary(campaignId);
const addMonsterFromLibrary = useAddMonsterFromLibrary(campaignId);
const [newCharName, setNewCharName] = useState('');
const [newEncounterName, setNewEncounterName] = useState('');
const [noteBody, setNoteBody] = useState('');
const [dmNoteBody, setDmNoteBody] = useState('');
const [newLocationName, setNewLocationName] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showMonsterForm, setShowMonsterForm] = useState(false);
const [showWeaponForm, setShowWeaponForm] = useState(false);
const [showSpellForm, setShowSpellForm] = useState(false);
const [showWeaponLibrary, setShowWeaponLibrary] = useState(false);
const [showSpellLibrary, setShowSpellLibrary] = useState(false);
const [showMonsterLibrary, setShowMonsterLibrary] = useState(false);
const weaponsPage = usePagination(weapons, 8);
const spellsPage = usePagination(spells, 8);
const monstersPage = usePagination(monsters, 8);
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
const sharedNotesPage = usePagination(sharedNotes, 6);
const dmNotesPage = usePagination(dmNotes, 6);
if (isLoading) return <Spinner label="Unrolling the campaign map…" />;
if (error || !data) return <ErrorBanner message="Could not load this campaign." />;
@@ -131,6 +201,20 @@ export function CampaignHomePage() {
setNoteBody('');
}
async function handleCreateDmNote(e: FormEvent) {
e.preventDefault();
if (!dmNoteBody.trim()) return;
await createNote.mutateAsync({ campaignId, body: dmNoteBody, visibility: 'DM_ONLY' });
setDmNoteBody('');
}
async function handleCreateLocation(e: FormEvent) {
e.preventDefault();
if (!newLocationName.trim()) return;
await createLocation.mutateAsync({ name: newLocationName });
setNewLocationName('');
}
return (
<div>
<div className="page-header">
@@ -138,7 +222,12 @@ export function CampaignHomePage() {
<h1>{campaign.name}</h1>
<p className="subtitle">{campaign.description}</p>
</div>
<span className={`badge ${isDM ? 'badge-dm' : 'badge-player'}`}>{isDM ? 'Dungeon Master' : 'Player'}</span>
<div className="flex-row">
<Link to={`/campaigns/${campaignId}/party`} className="btn">
📊 Party Overview
</Link>
<span className={`badge ${isDM ? 'badge-dm' : 'badge-player'}`}>{isDM ? 'Dungeon Master' : 'Player'}</span>
</div>
</div>
{isDM && (
@@ -271,15 +360,56 @@ export function CampaignHomePage() {
))}
</div>
{isDM && (
<div className="mt-lg card">
<div className="card-title">
<span>Locations</span>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>Track where each character currently is.</p>
<form onSubmit={handleCreateLocation} className="flex-row flex-wrap">
<input
placeholder="Location name (e.g. The Rusty Tankard)"
value={newLocationName}
onChange={(e) => setNewLocationName(e.target.value)}
style={{ flex: 1, minWidth: 160 }}
/>
<button type="submit" className="btn btn-primary">Add Location</button>
</form>
<div className="mt-sm">
{locations?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No locations yet.</p>}
{locations?.map((loc) => (
<div key={loc.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<span>📍 {loc.name}</span>
<button className="btn btn-ghost" onClick={() => deleteLocation.mutate(loc.id)}>✕</button>
</div>
))}
</div>
</div>
)}
{isDM && (
<>
<div className="mt-lg card">
<div className="card-title">
<span>Weapons Pool</span>
<button className="btn btn-ghost" onClick={() => setShowWeaponForm((s) => !s)}>
{showWeaponForm ? 'Close' : '+ Add Weapon'}
</button>
<div className="flex-row gap-sm">
<button className="btn btn-ghost" onClick={() => setShowWeaponLibrary((s) => !s)}>
{showWeaponLibrary ? 'Close Library' : '📚 From Library'}
</button>
<button className="btn btn-ghost" onClick={() => setShowWeaponForm((s) => !s)}>
{showWeaponForm ? 'Close' : '+ Add Weapon'}
</button>
</div>
</div>
{showWeaponLibrary && library && (
<LibraryPicker<LibraryWeapon>
items={library.weapons}
adding={addWeaponFromLibrary.isPending}
onAdd={(key) => addWeaponFromLibrary.mutate(key)}
placeholder="Search the weapon library…"
renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType}`}
/>
)}
{showWeaponForm && (
<form
onSubmit={async (e) => {
@@ -309,20 +439,48 @@ export function CampaignHomePage() {
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
<div className="mt-sm flex-row flex-wrap">
{weapons?.map((w) => (
<span key={w.id} className="badge">{w.name} ({w.damageDice})</span>
))}
<div className="table-wrap mt-sm">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Damage</th>
</tr>
</thead>
<tbody>
{weaponsPage.pageItems.map((w) => (
<tr key={w.id}>
<td>{w.name}</td>
<td>{w.damageDice} {w.damageType}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
</div>
<div className="mt-md card">
<div className="card-title">
<span>Spell List</span>
<button className="btn btn-ghost" onClick={() => setShowSpellForm((s) => !s)}>
{showSpellForm ? 'Close' : '+ Add Spell'}
</button>
<div className="flex-row gap-sm">
<button className="btn btn-ghost" onClick={() => setShowSpellLibrary((s) => !s)}>
{showSpellLibrary ? 'Close Library' : '📚 From Library'}
</button>
<button className="btn btn-ghost" onClick={() => setShowSpellForm((s) => !s)}>
{showSpellForm ? 'Close' : '+ Add Spell'}
</button>
</div>
</div>
{showSpellLibrary && library && (
<LibraryPicker<LibrarySpell>
items={library.spells}
adding={addSpellFromLibrary.isPending}
onAdd={(key) => addSpellFromLibrary.mutate(key)}
placeholder="Search the spell library…"
renderMeta={(s) => `${s.level === 0 ? 'Cantrip' : `Lv ${s.level}`} · ${s.school || ''}`}
/>
)}
{showSpellForm && (
<form
onSubmit={async (e) => {
@@ -347,20 +505,48 @@ export function CampaignHomePage() {
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
<div className="mt-sm flex-row flex-wrap">
{spells?.map((s) => (
<span key={s.id} className="badge">{s.name} (Lv{s.level})</span>
))}
<div className="table-wrap mt-sm">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Level</th>
</tr>
</thead>
<tbody>
{spellsPage.pageItems.map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td>{s.level === 0 ? 'Cantrip' : s.level}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
</div>
<div className="mt-md card">
<div className="card-title">
<span>Monsters</span>
<button className="btn btn-ghost" onClick={() => setShowMonsterForm((s) => !s)}>
{showMonsterForm ? 'Close' : '+ Add Monster'}
</button>
<div className="flex-row gap-sm">
<button className="btn btn-ghost" onClick={() => setShowMonsterLibrary((s) => !s)}>
{showMonsterLibrary ? 'Close Library' : '📚 From Library'}
</button>
<button className="btn btn-ghost" onClick={() => setShowMonsterForm((s) => !s)}>
{showMonsterForm ? 'Close' : '+ Add Monster'}
</button>
</div>
</div>
{showMonsterLibrary && library && (
<LibraryPicker<LibraryMonster>
items={library.monsters}
adding={addMonsterFromLibrary.isPending}
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
placeholder="Search the monster library…"
renderMeta={(m) => `CR ${m.challengeRating} · AC ${m.ac} · HP ${m.maxHp}`}
/>
)}
{showMonsterForm && (
<form
onSubmit={async (e) => {
@@ -395,20 +581,37 @@ export function CampaignHomePage() {
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
<div className="mt-sm">
{monsters?.map((m) => (
<div key={m.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<span>{m.name} — AC {m.ac}, HP {m.maxHp}</span>
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
</div>
))}
<div className="table-wrap mt-sm">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>AC</th>
<th>HP</th>
<th />
</tr>
</thead>
<tbody>
{monstersPage.pageItems.map((m) => (
<tr key={m.id}>
<td>{m.name}</td>
<td>{m.ac}</td>
<td>{m.maxHp}</td>
<td>
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={monstersPage.page} pageCount={monstersPage.pageCount} onPrev={monstersPage.prevPage} onNext={monstersPage.nextPage} />
</div>
</>
)}
<div className="mt-lg card">
<div className="card-title">Campaign Notes</div>
<div className="card-title">Shared Notes</div>
<form onSubmit={handleCreateNote} className="flex-row flex-wrap">
<textarea
placeholder="Add a shared note for the party…"
@@ -420,17 +623,47 @@ export function CampaignHomePage() {
<button type="submit" className="btn btn-primary">Add Note</button>
</form>
<div className="mt-sm">
{notes?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No notes yet.</p>}
{notes?.map((n) => (
{sharedNotesPage.pageItems.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No notes yet.</p>}
{sharedNotesPage.pageItems.map((n) => (
<div key={n.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<p style={{ margin: 0 }}>{n.body}</p>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{n.body}</p>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
— {n.author?.displayName} · {new Date(n.createdAt).toLocaleString()}
</span>
</div>
))}
</div>
<Pagination page={sharedNotesPage.page} pageCount={sharedNotesPage.pageCount} onPrev={sharedNotesPage.prevPage} onNext={sharedNotesPage.nextPage} />
</div>
{isDM && (
<div className="mt-md card">
<div className="card-title">DM Notes</div>
<p className="subtitle" style={{ marginTop: 0 }}>Only you can see these — plot threads, secrets, NPC tracking.</p>
<form onSubmit={handleCreateDmNote} className="flex-row flex-wrap">
<textarea
placeholder="Add a private note…"
rows={2}
value={dmNoteBody}
onChange={(e) => setDmNoteBody(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">Add</button>
</form>
<div className="mt-sm">
{dmNotesPage.pageItems.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No DM notes yet.</p>}
{dmNotesPage.pageItems.map((n) => (
<div key={n.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{n.body}</p>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
{new Date(n.createdAt).toLocaleString()}
</span>
</div>
))}
</div>
<Pagination page={dmNotesPage.page} pageCount={dmNotesPage.pageCount} onPrev={dmNotesPage.prevPage} onNext={dmNotesPage.nextPage} />
</div>
)}
</div>
);
}
+549 -211
View File
@@ -1,11 +1,13 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useBuildChoose, useBuildFinalize, useBuildRoll, useCampaign, useCampaignSpells, useCampaignWeapons, useCharacter } from '../api/queries';
import { useBuildChoose, useBuildFinalize, useBuildRoll, useCampaign, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage } from '../lib/format';
import { InfoTip } from '../components/InfoTip';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod } from '../lib/format';
import { abilityModifier, speciesBonusFor } from '../lib/speciesData';
import { CLASS_SPELLCASTING_ABILITY } from '../lib/classData';
import type { Ability, CharacterClass, Species } from '../api/types';
import { CLASS_SPELLCASTING_ABILITY, CLASS_HIT_DIE, CLASS_SAVE_PROFICIENCIES } from '../lib/classData';
import { armorClass, hpMax, passivePerception, proficiencyBonus, saveBonus, spellAttackBonus, spellSaveDC } from '../lib/previewCalc';
import type { Ability, CharacterClass, DiceMode, Species } from '../api/types';
interface SpeciesChoices {
type: 'choice' | 'wildcard';
@@ -47,15 +49,18 @@ export function CharacterBuilderPage() {
const roll = useBuildRoll(characterId);
const choose = useBuildChoose(characterId);
const finalize = useBuildFinalize(characterId);
const patchDiceMode = usePatchDiceMode(characterId);
const [formError, setFormError] = useState<string | null>(null);
const [halfElfPick, setHalfElfPick] = useState<Ability[]>([]);
const [pendingSpecies, setPendingSpecies] = useState<Species | null>(null);
const [assignments, setAssignments] = useState<Partial<Record<Ability, number>>>({});
const [skillPicks, setSkillPicks] = useState<Record<string, boolean>>({});
const [expertisePicks, setExpertisePicks] = useState<Record<string, boolean>>({});
const [equipWeapons, setEquipWeapons] = useState<string[]>([]);
const [equipSpells, setEquipSpells] = useState<string[]>([]);
const [diceModeConfirmed, setDiceModeConfirmed] = useState(false);
const [adjustingSkills, setAdjustingSkills] = useState(false);
const [skillPicks, setSkillPicks] = useState<Record<string, boolean>>({});
const [expertisePicks, setExpertisePicks] = useState<Record<string, boolean>>({});
const campaignId = character?.campaignId;
const { data: campaignData } = useCampaign(campaignId);
@@ -72,11 +77,13 @@ export function CharacterBuilderPage() {
const bs = (character.buildState || {}) as BuildState;
const stage = character.buildStage;
const diceMode = character.diceMode;
const isDM = campaignData?.campaign.role === 'DM';
async function safeRoll() {
async function safeRoll(body?: { value?: number; abilitySets?: number[][] }) {
setFormError(null);
try {
await roll.mutateAsync();
await roll.mutateAsync(body);
} catch (err) {
setFormError(extractErrorMessage(err));
}
@@ -91,218 +98,342 @@ export function CharacterBuilderPage() {
}
}
async function chooseDiceMode(mode: DiceMode) {
setFormError(null);
try {
await patchDiceMode.mutateAsync(mode);
setDiceModeConfirmed(true);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
const needsDiceModeChoice = stage === 'ROLLING_SPECIES' && !bs.speciesRoll && !diceModeConfirmed;
return (
<div>
<div className="page-header">
<div>
<h1>Rolling Up {character.name}</h1>
<p className="subtitle">Roll the dice at the table — every result is locked in server-side.</p>
<div className="build-layout">
<div>
<div className="page-header">
<div>
<h1>Rolling Up {character.name}</h1>
<p className="subtitle">Roll the dice at the table — every result is locked in server-side.</p>
</div>
</div>
<BuildProgress stage={stage} />
{formError && <ErrorBanner message={formError} />}
{needsDiceModeChoice ? (
<div className="card mt-md text-center">
<div className="card-title">Before you roll — digital or physical dice?</div>
<p>Pick how this character's dice get rolled. This can't be changed once rolling starts.</p>
<div className="flex-row flex-wrap" style={{ justifyContent: 'center' }}>
<button className="btn btn-primary btn-lg" onClick={() => chooseDiceMode('DIGITAL')} disabled={patchDiceMode.isPending}>
🎲 Digital Dice
<span className="subtitle" style={{ display: 'block', fontSize: '0.75rem' }}>The app rolls for you</span>
</button>
<button className="btn btn-lg" onClick={() => chooseDiceMode('PHYSICAL')} disabled={patchDiceMode.isPending}>
🎯 Physical Dice
<span className="subtitle" style={{ display: 'block', fontSize: '0.75rem' }}>You roll at the table, type in the result</span>
</button>
</div>
</div>
) : (
<>
{stage === 'ROLLING_SPECIES' && (
<div className="card mt-md text-center">
<div className="card-title">Step 1 — Species</div>
{!bs.speciesRoll ? (
<RollControl sides={6} diceMode={diceMode} onRoll={safeRoll} rolling={roll.isPending} label="Roll a d6 to see which species you can choose between." />
) : (
<>
<div className="roll-result animate">{bs.speciesRoll}</div>
<p className="mt-sm">
{bs.speciesChoices?.type === 'wildcard' ? 'Wildcard! Pick any species:' : 'Choose your species:'}
</p>
<div className="flex-row flex-wrap" style={{ justifyContent: 'center' }}>
{bs.speciesChoices?.options.map((s) => (
<button
key={s}
className={`btn ${pendingSpecies === s ? 'btn-primary' : ''}`}
onClick={() => {
setPendingSpecies(s as Species);
setHalfElfPick([]);
if (s !== 'HALF_ELF') safeChoose({ species: s });
}}
>
{SPECIES_LABELS[s]}
</button>
))}
</div>
{pendingSpecies === 'HALF_ELF' && (
<div className="card mt-md" style={{ textAlign: 'left' }}>
<p>Half-Elf gets a fixed +2 CHA, plus +1/+1 to two other abilities of your choice:</p>
<div className="flex-row flex-wrap">
{HALF_ELF_ELIGIBLE.map((a) => (
<label key={a} className="checkbox-row">
<input
type="checkbox"
checked={halfElfPick.includes(a)}
disabled={!halfElfPick.includes(a) && halfElfPick.length >= 2}
onChange={(e) =>
setHalfElfPick((prev) =>
e.target.checked ? [...prev, a] : prev.filter((x) => x !== a)
)
}
/>
{ABILITY_LABELS[a]}
</label>
))}
</div>
<button
className="btn btn-primary mt-sm"
disabled={halfElfPick.length !== 2}
onClick={() => safeChoose({ species: 'HALF_ELF', halfElfBonusAbilities: halfElfPick })}
>
Confirm Half-Elf
</button>
</div>
)}
</>
)}
</div>
)}
{stage === 'ROLLING_CLASS' && (
<div className="card mt-md text-center">
<div className="card-title">Step 2 — Class</div>
{!bs.classRoll || bs.classChoices?.type === 'reroll' ? (
<>
{bs.classChoices?.type === 'reroll' && (
<div className="roll-result animate mb-2">{bs.classRoll}</div>
)}
<RollControl
sides={8}
diceMode={diceMode}
onRoll={safeRoll}
rolling={roll.isPending}
label={bs.classChoices?.type === 'reroll' ? 'You rolled a 7 — reroll once!' : 'Roll a d8 to see which classes you can choose between.'}
/>
</>
) : (
<>
<div className="roll-result animate">{bs.classRoll}</div>
<p className="mt-sm">
{bs.classChoices?.type === 'wildcard' ? 'Wildcard! Pick any class:' : 'Choose your class:'}
</p>
<div className="flex-row flex-wrap" style={{ justifyContent: 'center' }}>
{bs.classChoices?.options.map((c) => (
<button key={c} className="btn" onClick={() => safeChoose({ characterClass: c })}>
{CLASS_LABELS[c]}
</button>
))}
</div>
</>
)}
</div>
)}
{stage === 'ROLLING_MOTIVATION' && (
<div className="card mt-md text-center">
<div className="card-title">Step 3 — Motivation (Flavor)</div>
{!bs.motivationRoll ? (
<RollControl sides={6} diceMode={diceMode} onRoll={safeRoll} rolling={roll.isPending} label="Roll a d6 for your character's driving motivation." />
) : (
<>
<div className="roll-result animate">{bs.motivationRoll}</div>
<p className="mt-sm" style={{ fontStyle: 'italic' }}>
"{bs.motivationText}"
</p>
<button className="btn btn-primary mt-sm" onClick={() => safeChoose({})}>
Confirm & Continue
</button>
</>
)}
</div>
)}
{stage === 'ASSIGNING_SCORES' && (
<AssignScoresStep
bs={bs}
diceMode={diceMode}
assignments={assignments}
setAssignments={setAssignments}
onRoll={safeRoll}
onSubmit={() => safeChoose({ assignments })}
rolling={roll.isPending}
/>
)}
{stage === 'ASSIGNING_PROFICIENCIES' && (
<div className="card mt-md">
<div className="card-title">
Step 5 — Skill Proficiencies <InfoTip term="SKILL_BONUS" />
</div>
<p className="subtitle">
Auto-generated from your class's skill list. Saving throw proficiencies are fixed by class and were
also set automatically.
</p>
{!adjustingSkills ? (
<>
<div className="flex-row flex-wrap">
{(bs.skillProficiencies || []).map((sp) => (
<span key={sp.skill} className="badge badge-dm">
{SKILL_LABELS[sp.skill]}
</span>
))}
</div>
<div className="flex-row mt-md">
<button className="btn btn-primary" onClick={() => safeChoose({})}>
Confirm & Continue
</button>
{isDM && (
<button
className="btn btn-ghost"
onClick={() => {
const picks: Record<string, boolean> = {};
const expertise: Record<string, boolean> = {};
for (const sp of bs.skillProficiencies || []) {
picks[sp.skill] = true;
expertise[sp.skill] = sp.expertise;
}
setSkillPicks(picks);
setExpertisePicks(expertise);
setAdjustingSkills(true);
}}
>
DM: Adjust picks
</button>
)}
</div>
</>
) : (
<>
<div className="grid grid-cols-3">
{ALL_SKILLS.map((skill) => (
<label key={skill} className="checkbox-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
<span className="flex-row">
<input
type="checkbox"
checked={Boolean(skillPicks[skill])}
onChange={(e) => setSkillPicks((p) => ({ ...p, [skill]: e.target.checked }))}
/>
{SKILL_LABELS[skill]}
</span>
{skillPicks[skill] && (
<span className="flex-row" style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginLeft: '1.5rem' }}>
<input
type="checkbox"
checked={Boolean(expertisePicks[skill])}
onChange={(e) => setExpertisePicks((p) => ({ ...p, [skill]: e.target.checked }))}
/>
expertise
</span>
)}
</label>
))}
</div>
<button
className="btn btn-primary mt-md"
onClick={() =>
safeChoose({
skillProficiencies: ALL_SKILLS.filter((s) => skillPicks[s]).map((s) => ({
skill: s,
proficient: true,
expertise: Boolean(expertisePicks[s]),
})),
})
}
>
Confirm Skills
</button>
</>
)}
</div>
)}
{stage === 'CHOOSING_EQUIPMENT' && (
<EquipmentStep
bs={bs}
campaignData={campaignData}
weapons={weapons}
spells={spells}
equipWeapons={equipWeapons}
setEquipWeapons={setEquipWeapons}
equipSpells={equipSpells}
setEquipSpells={setEquipSpells}
onSubmit={() => safeChoose({ weaponIds: equipWeapons, spellIds: equipSpells })}
onFinalize={async () => {
try {
const finalized = await finalize.mutateAsync();
navigate(`/characters/${finalized.id}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}}
finalizing={finalize.isPending}
/>
)}
</>
)}
</div>
<BuildProgress stage={stage} />
<aside className="build-preview">
<BuildPreview bs={bs} level={character.level} armorBonus={character.armorBonus} assignments={assignments} />
</aside>
</div>
);
}
{formError && <ErrorBanner message={formError} />}
function RollControl({
sides,
diceMode,
onRoll,
rolling,
label,
}: {
sides: number;
diceMode: DiceMode;
onRoll: (body?: { value?: number }) => void;
rolling: boolean;
label: string;
}) {
const [value, setValue] = useState('');
{stage === 'ROLLING_SPECIES' && (
<div className="card mt-md text-center">
<div className="card-title">Step 1 — Species</div>
{!bs.speciesRoll ? (
<>
<p>Roll a d6 to see which species you can choose between.</p>
<button className="btn-roll" onClick={safeRoll} disabled={roll.isPending}>
🎲 Roll d6
</button>
</>
) : (
<>
<div className="roll-result animate">{bs.speciesRoll}</div>
<p className="mt-sm">
{bs.speciesChoices?.type === 'wildcard' ? 'Wildcard! Pick any species:' : 'Choose your species:'}
</p>
<div className="flex-row flex-wrap" style={{ justifyContent: 'center' }}>
{bs.speciesChoices?.options.map((s) => (
<button
key={s}
className={`btn ${pendingSpecies === s ? 'btn-primary' : ''}`}
onClick={() => {
setPendingSpecies(s as Species);
setHalfElfPick([]);
if (s !== 'HALF_ELF') safeChoose({ species: s });
}}
>
{SPECIES_LABELS[s]}
</button>
))}
</div>
{pendingSpecies === 'HALF_ELF' && (
<div className="card mt-md" style={{ textAlign: 'left' }}>
<p>Half-Elf gets a fixed +2 CHA, plus +1/+1 to two other abilities of your choice:</p>
<div className="flex-row flex-wrap">
{HALF_ELF_ELIGIBLE.map((a) => (
<label key={a} className="checkbox-row">
<input
type="checkbox"
checked={halfElfPick.includes(a)}
disabled={!halfElfPick.includes(a) && halfElfPick.length >= 2}
onChange={(e) =>
setHalfElfPick((prev) =>
e.target.checked ? [...prev, a] : prev.filter((x) => x !== a)
)
}
/>
{ABILITY_LABELS[a]}
</label>
))}
</div>
<button
className="btn btn-primary mt-sm"
disabled={halfElfPick.length !== 2}
onClick={() => safeChoose({ species: 'HALF_ELF', halfElfBonusAbilities: halfElfPick })}
>
Confirm Half-Elf
</button>
</div>
)}
</>
)}
</div>
)}
{stage === 'ROLLING_CLASS' && (
<div className="card mt-md text-center">
<div className="card-title">Step 2 — Class</div>
{!bs.classRoll || bs.classChoices?.type === 'reroll' ? (
<>
{bs.classChoices?.type === 'reroll' && (
<div className="roll-result animate mb-2">{bs.classRoll}</div>
)}
<p>{bs.classChoices?.type === 'reroll' ? 'You rolled a 7 — reroll once!' : 'Roll a d8 to see which classes you can choose between.'}</p>
<button className="btn-roll" onClick={safeRoll} disabled={roll.isPending}>
🎲 Roll d8
</button>
</>
) : (
<>
<div className="roll-result animate">{bs.classRoll}</div>
<p className="mt-sm">
{bs.classChoices?.type === 'wildcard' ? 'Wildcard! Pick any class:' : 'Choose your class:'}
</p>
<div className="flex-row flex-wrap" style={{ justifyContent: 'center' }}>
{bs.classChoices?.options.map((c) => (
<button key={c} className="btn" onClick={() => safeChoose({ characterClass: c })}>
{CLASS_LABELS[c]}
</button>
))}
</div>
</>
)}
</div>
)}
{stage === 'ROLLING_MOTIVATION' && (
<div className="card mt-md text-center">
<div className="card-title">Step 3 — Motivation (Flavor)</div>
{!bs.motivationRoll ? (
<>
<p>Roll a d6 for your character's driving motivation.</p>
<button className="btn-roll" onClick={safeRoll} disabled={roll.isPending}>
🎲 Roll d6
</button>
</>
) : (
<>
<div className="roll-result animate">{bs.motivationRoll}</div>
<p className="mt-sm" style={{ fontStyle: 'italic' }}>
"{bs.motivationText}"
</p>
<button className="btn btn-primary mt-sm" onClick={() => safeChoose({})}>
Confirm & Continue
</button>
</>
)}
</div>
)}
{stage === 'ASSIGNING_SCORES' && (
<AssignScoresStep
bs={bs}
assignments={assignments}
setAssignments={setAssignments}
onRoll={safeRoll}
onSubmit={() => safeChoose({ assignments })}
rolling={roll.isPending}
/>
)}
{stage === 'ASSIGNING_PROFICIENCIES' && (
<div className="card mt-md">
<div className="card-title">Step 5 — Skill Proficiencies</div>
<p className="subtitle">Saving throw proficiencies are fixed by class and were set automatically.</p>
<div className="grid grid-cols-3">
{ALL_SKILLS.map((skill) => (
<label key={skill} className="checkbox-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
<span className="flex-row">
<input
type="checkbox"
checked={Boolean(skillPicks[skill])}
onChange={(e) => setSkillPicks((p) => ({ ...p, [skill]: e.target.checked }))}
/>
{SKILL_LABELS[skill]}
</span>
{skillPicks[skill] && (
<span className="flex-row" style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginLeft: '1.5rem' }}>
<input
type="checkbox"
checked={Boolean(expertisePicks[skill])}
onChange={(e) => setExpertisePicks((p) => ({ ...p, [skill]: e.target.checked }))}
/>
expertise
</span>
)}
</label>
))}
</div>
if (diceMode === 'PHYSICAL') {
return (
<>
<p>{label} Roll it at the table, then type the result in below.</p>
<div className="flex-row" style={{ justifyContent: 'center' }}>
<input
type="number"
min={1}
max={sides}
placeholder={`1–${sides}`}
value={value}
onChange={(e) => setValue(e.target.value)}
style={{ width: 80 }}
/>
<button
className="btn btn-primary mt-md"
onClick={() =>
safeChoose({
skillProficiencies: ALL_SKILLS.filter((s) => skillPicks[s]).map((s) => ({
skill: s,
proficient: true,
expertise: Boolean(expertisePicks[s]),
})),
})
}
className="btn btn-primary"
disabled={rolling || !value}
onClick={() => onRoll({ value: Number(value) })}
>
Confirm Skills
Submit d{sides} Roll
</button>
</div>
)}
</>
);
}
{stage === 'CHOOSING_EQUIPMENT' && (
<EquipmentStep
bs={bs}
campaignData={campaignData}
weapons={weapons}
spells={spells}
equipWeapons={equipWeapons}
setEquipWeapons={setEquipWeapons}
equipSpells={equipSpells}
setEquipSpells={setEquipSpells}
onSubmit={() => safeChoose({ weaponIds: equipWeapons, spellIds: equipSpells })}
onFinalize={async () => {
try {
const finalized = await finalize.mutateAsync();
navigate(`/characters/${finalized.id}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}}
finalizing={finalize.isPending}
/>
)}
</div>
return (
<>
<p>{label}</p>
<button className="btn-roll" onClick={() => onRoll()} disabled={rolling}>
🎲 Roll d{sides}
</button>
</>
);
}
@@ -322,6 +453,7 @@ function BuildProgress({ stage }: { stage: string }) {
function AssignScoresStep({
bs,
diceMode,
assignments,
setAssignments,
onRoll,
@@ -329,18 +461,22 @@ function AssignScoresStep({
rolling,
}: {
bs: BuildState;
diceMode: DiceMode;
assignments: Partial<Record<Ability, number>>;
setAssignments: (a: Partial<Record<Ability, number>>) => void;
onRoll: () => void;
onRoll: (body?: { abilitySets?: number[][] }) => void;
onSubmit: () => void;
rolling: boolean;
}) {
if (!bs.abilityRolls) {
if (diceMode === 'PHYSICAL') {
return <PhysicalAbilityRollForm onRoll={onRoll} rolling={rolling} />;
}
return (
<div className="card mt-md text-center">
<div className="card-title">Step 4 — Ability Scores</div>
<p>Roll 4d6 (drop the lowest) six times — one set per ability.</p>
<button className="btn-roll" onClick={onRoll} disabled={rolling}>
<button className="btn-roll" onClick={() => onRoll()} disabled={rolling}>
🎲 Roll Ability Scores
</button>
</div>
@@ -405,6 +541,46 @@ function AssignScoresStep({
);
}
function PhysicalAbilityRollForm({ onRoll, rolling }: { onRoll: (body?: { abilitySets?: number[][] }) => void; rolling: boolean }) {
const [sets, setSets] = useState<string[][]>(Array.from({ length: 6 }, () => ['', '', '', '']));
const allFilled = sets.every((set) => set.every((v) => v !== '' && Number(v) >= 1 && Number(v) <= 6));
function updateDie(setIdx: number, dieIdx: number, value: string) {
setSets((prev) => prev.map((set, i) => (i === setIdx ? set.map((d, j) => (j === dieIdx ? value : d)) : set)));
}
return (
<div className="card mt-md">
<div className="card-title">Step 4 — Ability Scores (Physical Dice)</div>
<p>Roll 4d6 six times at the table, then enter each set's four results below (drop-the-lowest is applied automatically).</p>
{sets.map((set, i) => (
<div key={i} className="flex-row flex-wrap mt-sm">
<span className="badge">Set {i + 1}</span>
{set.map((v, j) => (
<input
key={j}
type="number"
min={1}
max={6}
value={v}
onChange={(e) => updateDie(i, j, e.target.value)}
style={{ width: 60 }}
/>
))}
</div>
))}
<button
className="btn btn-primary mt-md"
disabled={!allFilled || rolling}
onClick={() => onRoll({ abilitySets: sets.map((set) => set.map(Number)) })}
>
Submit All Six Sets
</button>
</div>
);
}
function EquipmentStep({
bs,
campaignData,
@@ -497,3 +673,165 @@ function EquipmentStep({
</div>
);
}
// Live "what does this change" preview — recomputes the projected sheet as
// species/class/scores/skills firm up, using the same pure formulas as the
// server's calculation engine (ported in lib/previewCalc.ts). Display-only;
// the server remains authoritative at finalize.
function BuildPreview({
bs,
level,
armorBonus,
assignments,
}: {
bs: BuildState;
level: number;
armorBonus: number;
assignments: Partial<Record<Ability, number>>;
}) {
const species = bs.species ?? null;
const halfElfBonusAbilities = bs.halfElfBonusAbilities || [];
const characterClass = bs.characterClass ?? null;
const abilityFinals: Partial<Record<Ability, number>> = {};
for (const ability of ABILITIES) {
const bonus = speciesBonusFor(species, ability, halfElfBonusAbilities);
const assignedIndex = assignments[ability] ?? bs.abilityAssignments?.[ability];
const rolledTotal = assignedIndex !== undefined ? bs.abilityRolls?.[assignedIndex]?.total : undefined;
if (rolledTotal !== undefined) {
abilityFinals[ability] = rolledTotal + bonus;
} else if (species) {
abilityFinals[ability] = 10 + bonus; // baseline preview before rolling
}
}
const dexMod = abilityFinals.DEX !== undefined ? abilityModifier(abilityFinals.DEX) : null;
const conMod = abilityFinals.CON !== undefined ? abilityModifier(abilityFinals.CON) : null;
const profBonus = proficiencyBonus(level || 1);
const hitDie = characterClass ? CLASS_HIT_DIE[characterClass] : null;
const saveProfs = characterClass ? CLASS_SAVE_PROFICIENCIES[characterClass] : [];
const spellAbility = characterClass ? CLASS_SPELLCASTING_ABILITY[characterClass] : null;
const perceptionProficient = Boolean(bs.skillProficiencies?.some((s) => s.skill === 'PERCEPTION'));
const wisMod = abilityFinals.WIS !== undefined ? abilityModifier(abilityFinals.WIS) : null;
return (
<div className="card">
<div className="card-title">Live Preview</div>
{!species && !characterClass && (
<p className="subtitle" style={{ marginTop: 0 }}>Pick a species and class to see your projected stats build up here.</p>
)}
{species && (
<p style={{ marginTop: 0 }}>
<strong>{SPECIES_LABELS[species]}</strong>
{Object.entries({ STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 })
.map(([a]) => [a as Ability, speciesBonusFor(species, a as Ability, halfElfBonusAbilities)] as const)
.filter(([, bonus]) => bonus !== 0)
.map(([a, bonus]) => (
<span key={a} className="preview-highlight" style={{ marginLeft: 6 }}>
{fmtMod(bonus)} {a}
</span>
))}
</p>
)}
{characterClass && (
<p>
<strong>{CLASS_LABELS[characterClass]}</strong>
<span style={{ color: 'var(--text-muted)' }}> · d{hitDie} hit die</span>
{saveProfs && saveProfs.length > 0 && (
<span style={{ color: 'var(--text-muted)' }}> · {saveProfs.map((a) => ABILITY_LABELS[a]).join('/')} saves</span>
)}
</p>
)}
{Object.keys(abilityFinals).length > 0 && (
<div className="grid grid-cols-3 mt-sm">
{ABILITIES.map((a) =>
abilityFinals[a] !== undefined ? (
<div className="stat-block" key={a}>
<span className="stat-label">{a}</span>
<span className="stat-value">{abilityFinals[a]}</span>
<span className="stat-sub">{fmtMod(abilityModifier(abilityFinals[a]!))}</span>
</div>
) : null
)}
</div>
)}
<div className="divider" />
<div className="grid grid-cols-2">
<div>
<span className="stat-label">AC</span>
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) : '—'}</div>
</div>
<div>
<span className="stat-label">Max HP</span>
<div className="stat-value">{hitDie && conMod !== null ? hpMax(level || 1, hitDie, conMod) : '—'}</div>
</div>
<div>
<span className="stat-label">Initiative</span>
<div className="stat-value">{dexMod !== null ? fmtMod(dexMod) : '—'}</div>
</div>
<div>
<span className="stat-label">Prof. Bonus</span>
<div className="stat-value">{fmtMod(profBonus)}</div>
</div>
</div>
{characterClass && saveProfs.length > 0 && (
<>
<div className="divider" />
<div className="card-title" style={{ fontSize: '0.85rem' }}>Saving Throws</div>
{ABILITIES.map((a) => {
const mod = abilityFinals[a] !== undefined ? abilityModifier(abilityFinals[a]!) : null;
if (mod === null) return null;
const proficient = saveProfs.includes(a);
return (
<div key={a} className="flex-between" style={{ padding: '0.15rem 0', fontSize: '0.85rem' }}>
<span>
{ABILITY_LABELS[a]} {proficient && <span className="badge">prof</span>}
</span>
<span className="mono">{fmtMod(saveBonus(mod, proficient, profBonus))}</span>
</div>
);
})}
</>
)}
{wisMod !== null && (
<>
<div className="divider" />
<div className="flex-between" style={{ fontSize: '0.85rem' }}>
<span>Passive Perception</span>
<span className="mono">{passivePerception(perceptionProficient ? profBonus + wisMod : wisMod)}</span>
</div>
</>
)}
{spellAbility && abilityFinals[spellAbility] !== undefined && (
<>
<div className="divider" />
<div className="card-title" style={{ fontSize: '0.85rem' }}>Spellcasting ({ABILITY_LABELS[spellAbility]})</div>
<p style={{ margin: 0, fontSize: '0.85rem' }}>
Save DC {spellSaveDC(profBonus, abilityModifier(abilityFinals[spellAbility]!))} · Attack{' '}
{fmtMod(spellAttackBonus(profBonus, abilityModifier(abilityFinals[spellAbility]!)))}
</p>
</>
)}
{bs.skillProficiencies && bs.skillProficiencies.length > 0 && (
<>
<div className="divider" />
<div className="card-title" style={{ fontSize: '0.85rem' }}>Skills</div>
<div className="flex-row flex-wrap gap-sm">
{bs.skillProficiencies.map((sp) => (
<span key={sp.skill} className="badge">{SKILL_LABELS[sp.skill]}</span>
))}
</div>
</>
)}
</div>
);
}
+141 -21
View File
@@ -15,12 +15,18 @@ import {
useCharacterNotes,
useCreateNote,
useCampaign,
useCampaignLocations,
} from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, extractErrorMessage } from '../lib/format';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, hpBarClass, extractErrorMessage } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { hpMax as previewHpMax } from '../lib/previewCalc';
import { CLASS_HIT_DIE } from '../lib/classData';
import type { Ability } from '../api/types';
export function CharacterSheetPage() {
@@ -33,6 +39,7 @@ export function CharacterSheetPage() {
const { data: campaignData } = useCampaign(character?.campaignId);
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: locations } = useCampaignLocations(character?.campaignId);
const { data: notes } = useCharacterNotes(characterId);
const assignWeapon = useAssignWeapon(characterId);
@@ -51,6 +58,11 @@ export function CharacterSheetPage() {
const [pickedSpellId, setPickedSpellId] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const weaponsPage = usePagination(character?.weapons, 8);
const spellsPage = usePagination(character?.spells, 8);
const itemsPage = usePagination(character?.inventoryItems, 8);
const notesPage = usePagination(notes, 6);
if (isLoading) return <Spinner label="Unfurling the character sheet…" />;
if (error || !character) return <ErrorBanner message="Could not load this character." />;
@@ -79,6 +91,14 @@ export function CharacterSheetPage() {
setHpDelta('');
}
function recomputeMaxHp() {
if (!character || !character.characterClass) return;
const hitDie = CLASS_HIT_DIE[character.characterClass];
const conMod = sheet.abilities.CON.modifier;
const newMax = previewHpMax(character.level, hitDie, conMod);
patchCharacter.mutate({ currentHp: newMax });
}
async function handleAddItem(e: FormEvent) {
e.preventDefault();
if (!newItemName.trim()) return;
@@ -120,11 +140,14 @@ export function CharacterSheetPage() {
<div>
<div className="page-header">
<div>
<h1>{character.name}</h1>
<h1>
<span aria-hidden="true">{speciesIcon(character.species)}</span> {character.name}
</h1>
<p className="subtitle">
{character.species && SPECIES_LABELS[character.species]} {character.characterClass && CLASS_LABELS[character.characterClass]} · Level{' '}
{character.level}
{character.motivation && ` · "${character.motivation}"`}
{character.currentLocation && ` · 📍 ${character.currentLocation.name}`}
</p>
</div>
<Link to={`/campaigns/${character.campaignId}`} className="btn btn-ghost">
@@ -136,36 +159,48 @@ export function CharacterSheetPage() {
<div className="grid grid-cols-3">
<div className="stat-block">
<span className="stat-label">HP</span>
<span className="stat-label">
HP <InfoTip term="HP" />
</span>
<span className="stat-value">
{character.currentHp}/{sheet.hp.max}
</span>
{character.tempHp > 0 && <span className="stat-sub">+{character.tempHp} temp</span>}
</div>
<div className="stat-block">
<span className="stat-label">AC</span>
<span className="stat-label">
AC <InfoTip term="AC" />
</span>
<span className="stat-value">{sheet.armorClass}</span>
</div>
<div className="stat-block">
<span className="stat-label">Initiative</span>
<span className="stat-label">
Initiative <InfoTip term="INITIATIVE" />
</span>
<span className="stat-value">{fmtMod(sheet.initiative)}</span>
</div>
<div className="stat-block">
<span className="stat-label">Speed</span>
<span className="stat-label">
Speed <InfoTip term="SPEED" />
</span>
<span className="stat-value">{sheet.speed}</span>
</div>
<div className="stat-block">
<span className="stat-label">Prof. Bonus</span>
<span className="stat-label">
Prof. Bonus <InfoTip term="PROFICIENCY_BONUS" />
</span>
<span className="stat-value">{fmtMod(sheet.proficiencyBonus)}</span>
</div>
<div className="stat-block">
<span className="stat-label">Passive Perception</span>
<span className="stat-label">
Passive Perception <InfoTip term="PASSIVE_PERCEPTION" />
</span>
<span className="stat-value">{sheet.passivePerception}</span>
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className="hp-bar-fill" style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
<div className={hpBarClass(character.currentHp, sheet.hp.max)} style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
</div>
{canEdit && (
@@ -184,7 +219,7 @@ export function CharacterSheetPage() {
+ Heal
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Temp HP
Temp HP <InfoTip term="TEMP_HP" />
<input
type="number"
min={0}
@@ -194,7 +229,7 @@ export function CharacterSheetPage() {
/>
</label>
<label className="flex-row gap-sm">
Armor Bonus
Armor Bonus <InfoTip term="ARMOR_BONUS" />
<input
type="number"
min={0}
@@ -206,12 +241,50 @@ export function CharacterSheetPage() {
</div>
)}
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<label className="flex-row gap-sm">
Level
<input
type="number"
min={1}
max={20}
defaultValue={character.level}
style={{ width: 60 }}
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
/>
</label>
<button className="btn btn-ghost" onClick={recomputeMaxHp} title="Recompute max HP from the current level and Constitution">
Recompute Max HP <InfoTip term="HIT_DICE" />
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Location
<select
value={character.currentLocationId || ''}
onChange={(e) => patchCharacter.mutate({ currentLocationId: e.target.value || null })}
>
<option value="">— none —</option>
{locations?.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</option>
))}
</select>
</label>
</div>
)}
{!isDM && character.currentLocation && (
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
)}
<div className="card mt-md">
<div className="card-title">Ability Scores</div>
<div className="grid grid-cols-3">
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
<div className="stat-block" key={ability}>
<span className="stat-label">{ABILITY_LABELS[ability]}</span>
<span className="stat-label">
{ABILITY_LABELS[ability]} <InfoTip term="ABILITY_MODIFIER" />
</span>
<span className="stat-value">{sheet.abilities[ability].final}</span>
<span className="stat-sub">{fmtMod(sheet.abilities[ability].modifier)}</span>
</div>
@@ -221,7 +294,9 @@ export function CharacterSheetPage() {
<div className="grid grid-cols-2 mt-md">
<div className="card">
<div className="card-title">Saving Throws</div>
<div className="card-title">
Saving Throws <InfoTip term="SAVING_THROW" />
</div>
{(Object.keys(sheet.saves) as Ability[]).map((ability) => (
<div key={ability} className="flex-between" style={{ padding: '0.3rem 0' }}>
<span>
@@ -232,7 +307,9 @@ export function CharacterSheetPage() {
))}
</div>
<div className="card">
<div className="card-title">Skills</div>
<div className="card-title">
Skills <InfoTip term="SKILL_BONUS" />
</div>
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
{Object.entries(sheet.skills).map(([skill, detail]) => (
<div key={skill} className="flex-between" style={{ padding: '0.25rem 0' }}>
@@ -253,17 +330,21 @@ export function CharacterSheetPage() {
{sheet.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting ({ABILITY_LABELS[sheet.spellcasting.ability]})</div>
<div className="card-title">
Spellcasting ({ABILITY_LABELS[sheet.spellcasting.ability]}) <InfoTip term="SPELL_SAVE_DC" />
</div>
<p style={{ margin: 0 }}>
Save DC <strong>{sheet.spellcasting.saveDC}</strong> · Attack Bonus{' '}
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong>
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong> <InfoTip term="SPELL_ATTACK_BONUS" />
</p>
</div>
)}
<div className="card mt-md">
<div className="card-title">Weapons</div>
{character.weapons.map((cw) => (
<div className="card-title">
Weapons <InfoTip term="ATTACK_BONUS" />
</div>
{weaponsPage.pageItems.map((cw) => (
<div key={cw.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{cw.weapon.name}</strong>
@@ -283,6 +364,7 @@ export function CharacterSheetPage() {
)}
</div>
))}
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedWeaponId} onChange={(e) => setPickedWeaponId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
@@ -305,7 +387,7 @@ export function CharacterSheetPage() {
{campaignSpells && campaignSpells.length > 0 && (
<div className="card mt-md">
<div className="card-title">Spells Known</div>
{character.spells.map((cs) => (
{spellsPage.pageItems.map((cs) => (
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{cs.spell.name}</strong>
@@ -323,6 +405,7 @@ export function CharacterSheetPage() {
)}
</div>
))}
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedSpellId} onChange={(e) => setPickedSpellId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
@@ -345,7 +428,7 @@ export function CharacterSheetPage() {
<div className="card mt-md">
<div className="card-title">Inventory</div>
{character.inventoryItems.map((item) => (
{itemsPage.pageItems.map((item) => (
<div key={item.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{item.name}</strong>
@@ -368,6 +451,7 @@ export function CharacterSheetPage() {
)}
</div>
))}
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
{canEdit && (
<form onSubmit={handleAddItem} className="flex-row mt-sm flex-wrap">
<input
@@ -395,6 +479,41 @@ export function CharacterSheetPage() {
</div>
)}
<div className="card mt-md">
<div className="card-title">Background</div>
<textarea
rows={4}
defaultValue={character.background || ''}
placeholder={canEdit ? "Where this character came from, what drives them…" : 'No background written yet.'}
readOnly={!canEdit}
onBlur={(e) => canEdit && patchCharacter.mutate({ background: e.target.value })}
/>
</div>
<div className="card mt-md">
<div className="card-title">Appearance</div>
<textarea
rows={3}
defaultValue={character.appearance || ''}
placeholder={canEdit ? 'Height, build, notable features, typical outfit…' : 'No appearance written yet.'}
readOnly={!canEdit}
onBlur={(e) => canEdit && patchCharacter.mutate({ appearance: e.target.value })}
/>
</div>
{isOwner && (
<div className="card mt-md">
<div className="card-title">Private Notes</div>
<p className="subtitle" style={{ marginTop: 0 }}>Only you can see this — not even the DM.</p>
<textarea
rows={4}
defaultValue={character.privateNotes || ''}
placeholder="Secrets, plans, things your character wouldn't say out loud…"
onBlur={(e) => patchCharacter.mutate({ privateNotes: e.target.value })}
/>
</div>
)}
<div className="card mt-md">
<div className="card-title">Notes</div>
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
@@ -410,7 +529,7 @@ export function CharacterSheetPage() {
</button>
</form>
<div className="mt-sm">
{notes?.map((n) => (
{notesPage.pageItems.map((n) => (
<div key={n.id} style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<p style={{ margin: 0 }}>{n.body}</p>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
@@ -419,6 +538,7 @@ export function CharacterSheetPage() {
</div>
))}
</div>
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} />
</div>
<QuickStatsFab characterId={characterId} />
+43 -2
View File
@@ -13,7 +13,7 @@ import {
} from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { extractErrorMessage } from '../lib/format';
import { extractErrorMessage, fmtMod, hpBarClass } from '../lib/format';
import type { EncounterParticipant } from '../api/types';
function ParticipantRow({
@@ -28,7 +28,9 @@ function ParticipantRow({
onRemove: () => void;
}) {
const [hpDelta, setHpDelta] = useState('');
const [showCombatInfo, setShowCombatInfo] = useState(false);
const pct = Math.max(0, Math.min(100, (participant.currentHp / participant.maxHp) * 100));
const combatInfo = participant.characterCombatInfo;
function applyDelta(sign: 1 | -1) {
const amount = Number(hpDelta);
@@ -59,7 +61,7 @@ function ParticipantRow({
)}
</div>
<div className="hp-bar-track mt-sm">
<div className="hp-bar-fill" style={{ width: `${pct}%` }} />
<div className={hpBarClass(participant.currentHp, participant.maxHp)} style={{ width: `${pct}%` }} />
</div>
<div className="flex-between mt-sm flex-wrap">
<span>
@@ -106,6 +108,45 @@ function ParticipantRow({
))}
</div>
)}
{combatInfo && (
<div className="mt-sm">
<button className="btn btn-ghost" onClick={() => setShowCombatInfo((s) => !s)}>
{showCombatInfo ? 'Hide' : '⚔ Show'} Weapons & Spells
</button>
{showCombatInfo && (
<div className="mt-sm">
{combatInfo.weapons.length === 0 && combatInfo.spells.length === 0 && (
<p style={{ color: 'var(--text-muted)', margin: 0 }}>Nothing equipped or prepared.</p>
)}
{combatInfo.weapons.map((w) => (
<div key={w.characterWeaponId} className="flex-between" style={{ padding: '0.2rem 0' }}>
<span>
{w.name} {w.equipped && <span className="badge">equipped</span>}
<span style={{ color: 'var(--text-muted)', marginLeft: 6, fontSize: '0.85rem' }}>
{w.damageDice} {w.damageType} {fmtMod(w.damageBonus)}
</span>
</span>
<span className="mono">{fmtMod(w.attackBonus)}</span>
</div>
))}
{combatInfo.spells.length > 0 && (
<div className="flex-row flex-wrap mt-sm">
{combatInfo.spells.map((s) => (
<span key={s.characterSpellId} className={`badge ${s.prepared ? 'badge-dm' : ''}`}>
{s.name} (Lv {s.level})
</span>
))}
</div>
)}
{combatInfo.spellcasting && (
<p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Spell Save DC {combatInfo.spellcasting.saveDC} · Attack {fmtMod(combatInfo.spellcasting.attackBonus)}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
-3
View File
@@ -58,9 +58,6 @@ export function LoginPage() {
<p className="subtitle mt-md">
New adventurer? <Link to="/register">Create an account</Link>
</p>
<p className="subtitle" style={{ fontSize: '0.8rem' }}>
Demo: dm@example.com / player@example.com — password123
</p>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { Link, useParams } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { useCampaign, useCampaignCharacters } from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { usePagination, Pagination } from '../components/Paginated';
import { InfoTip } from '../components/InfoTip';
import { CLASS_LABELS, SPECIES_LABELS, hpBarClass } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
// A DM's one-screen view of every character's stats at a glance; a player
// sees the same table, scoped to their own character(s) plus a read-only
// view of the rest of the party — same visibility split as the character
// list on the campaign home page.
export function PartyOverviewPage() {
const { campaignId = '' } = useParams();
const { user } = useAuth();
useCampaignSocket(campaignId);
const { data, isLoading: campaignLoading, error: campaignError } = useCampaign(campaignId);
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
const { pageItems, page, pageCount, prevPage, nextPage } = usePagination(characters, 12);
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
if (campaignError || charsError || !data) return <ErrorBanner message="Could not load the party overview." />;
const isDM = data.campaign.role === 'DM';
return (
<div>
<div className="page-header">
<div>
<h1>Party Overview</h1>
<p className="subtitle">{isDM ? 'Every character in this campaign, at a glance.' : 'Your party.'}</p>
</div>
<Link to={`/campaigns/${campaignId}`} className="btn btn-ghost">
← Back to Campaign
</Link>
</div>
{characters?.length === 0 && <div className="empty-state">No characters yet.</div>}
{characters && characters.length > 0 && (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Character</th>
<th>Species</th>
<th>Class</th>
<th>Level</th>
<th>
HP <InfoTip term="HP" />
</th>
<th>
AC <InfoTip term="AC" />
</th>
<th>Location</th>
</tr>
</thead>
<tbody>
{pageItems.map((c) => {
const inProgress = c.buildStage !== 'COMPLETE';
const isMine = c.userId === user?.id;
return (
<tr key={c.id}>
<td>
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
</Link>
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
</td>
<td>{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
<td>{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
<td>{c.level}</td>
<td>
{c.maxHp != null ? (
<div style={{ minWidth: 90 }}>
<span className="mono">{c.currentHp}/{c.maxHp}</span>
<div className="hp-bar-track mt-sm" style={{ height: 6 }}>
<div
className={hpBarClass(c.currentHp, c.maxHp)}
style={{ width: `${Math.max(0, Math.min(100, (c.currentHp / c.maxHp) * 100))}%` }}
/>
</div>
</div>
) : (
'—'
)}
</td>
<td>{c.armorClass ?? '—'}</td>
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} />
</div>
);
}