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:
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
@@ -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] || '❓' : '❓';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user