V1.10.1: a phone-friendly layout, easier navigation, paged encounters

- The main destinations are always on screen: Campaigns, Campaign and
  Party (for the campaign last visited, from any page) and Account. They
  are links along the top bar on a wide screen, and a bar along the
  bottom on a phone or small tablet (900px or less).
- Nothing floats over the page on a phone: the dice roller is the last
  button of the bottom bar, and quick stats sits in the top bar.
- The campaign page and the character sheet start with a row of links to
  their sections.
- On a small screen every table is a stack of labelled cards, rows of
  controls wrap, fields shrink to fit, and things to tap are bigger.
- The encounter list is paged, ten to a page, as is the archived list.
  Moving an encounter still works across pages.
- Text boxes with no type given, and native checkboxes and dropdowns, now
  match the dark theme. Long monster notes and item descriptions wrap
  inside their table cell.
- All packages at version 1.10.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 14:37:31 +02:00
co-authored by Claude Opus 5.5
parent c852ecb2e4
commit 2dcb41ae4a
16 changed files with 646 additions and 79 deletions
+20 -1
View File
@@ -25,6 +25,25 @@ version was built from. The app has since moved on in places (gear levels,
the builder's story steps, picking a motivation); the documents above are the the builder's story steps, picking a motivation); the documents above are the
current behaviour. current behaviour.
## Getting around
The same four destinations are always on screen: **Campaigns** (the list),
**Campaign** and **Party** (for the campaign you were last in, from any page,
including a character sheet or an encounter) and **Account**.
- On a wide screen they are links along the top bar, and the dice roller
floats at the bottom left.
- On a phone or small tablet (900px wide or less) they are a bar along the
bottom of the screen, with the **dice roller** as its last button. Nothing
floats over the page: on a character sheet the ⚡ quick stats button sits in
the top bar.
- The campaign page and the character sheet are long, so each starts with a
row of **section links** (Characters, Encounters, NPCs, Monsters...) that
jump straight to that part of the page.
- On a small screen every table (weapons, items, spells, monsters, the party
overview) is shown as a stack of cards, one per row, with each figure
labelled, and rows of controls wrap rather than run off the side.
## Stack ## Stack
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation. - **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
@@ -85,7 +104,7 @@ and then restart the backend. It applies new migrations **and** regenerates
the Prisma client; without the second half the server keeps running with the the Prisma client; without the second half the server keeps running with the
old data model and pages that use anything new fail to load (for example old data model and pages that use anything new fail to load (for example
"Could not load this encounter"). Change your password from the account page "Could not load this encounter"). Change your password from the account page
(click your name in the top bar). ("Account" in the navigation).
## Tests ## Tests
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "1.10.0", "version": "1.10.1",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"main": "src/index.js", "main": "src/index.js",
+3 -3
View File
@@ -646,7 +646,7 @@ character in the same campaign.
| Party overview | One table of every character with an HP bar, AC, gold and location. | | Party overview | One table of every character with an HP bar, AC, gold and location. |
| Character sheet | Everything in this document. | | Character sheet | Everything in this document. |
| Character builder | A character that is still being built (section 2). | | Character builder | A character that is still being built (section 2). |
| Quick stats drawer | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. | | Quick stats drawer (the ⚡ button: bottom right, or in the top bar on a phone) | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
| Encounter page | Characters taking part in a battle or quest (next section). | | Encounter page | Characters taking part in a battle or quest (next section). |
Changes are pushed live to everyone who has the campaign open, so sheets and Changes are pushed live to everyone who has the campaign open, so sheets and
@@ -789,8 +789,8 @@ calculations for them.
- Armor and shields as items (only the single armor bonus number) - Armor and shields as items (only the single armor bonus number)
- Weapon proficiency (everyone is proficient with everything) - Weapon proficiency (everyone is proficient with everything)
- Rolling attacks, damage, saves or checks for a character (the dice roller, - Rolling attacks, damage, saves or checks for a character (the dice roller,
bottom left of every page, is a plain roller that knows nothing about the bottom left of every page, or the last button of the bottom bar on a phone,
sheet) is a plain roller that knows nothing about the sheet)
- Conditions on the sheet, and resting - Conditions on the sheet, and resting
- Automatic levelling from XP - Automatic levelling from XP
- Alignment, languages, tool proficiencies and backgrounds-as-rules - Alignment, languages, tool proficiencies and backgrounds-as-rules
+5 -1
View File
@@ -88,6 +88,10 @@ goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
date instead; that choice is remembered per browser and changes nothing for date instead; that choice is remembered per browser and changes nothing for
anyone else. ▲ and ▼ only show while sorting by campaign order. anyone else. ▲ and ▼ only show while sorting by campaign order.
The list shows **ten encounters a page**, with Prev and Next beneath it, and
the DM's archived list is paged the same way. The order runs on across pages:
moving the last encounter on a page down carries it to the top of the next.
**Archive.** "Archive" (on the list or the encounter page) puts an encounter **Archive.** "Archive" (on the list or the encounter page) puts an encounter
away without changing it. It moves to the DM's "Archived" section and away without changing it. It moves to the DM's "Archived" section and
disappears from the players' list; a player whose character is in it can still disappears from the players' list; a player whose character is in it can still
@@ -442,7 +446,7 @@ People outside the campaign cannot open its encounters.
## 13. What is not modelled ## 13. What is not modelled
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom - Rolling initiative, attacks, damage or saves (use the dice roller, bottom
left of every page) left of every page, or in the bottom bar on a phone)
- Applying an attack's damage automatically - Applying an attack's damage automatically
- Death saves for monsters and NPCs, and prompting for a save on a dying - Death saves for monsters and NPCs, and prompting for a save on a dying
character's turn character's turn
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.10.0", "version": "1.10.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
// A row of links to the sections of a long page. It finds them itself: any
// element with a `data-jump="Label"` attribute that is on the page right now
// gets a link, so sections only some people see need no special handling.
export function JumpNav() {
const [labels, setLabels] = useState<string[]>([]);
// Sections come and go as data loads, so the page is watched for them.
useEffect(() => {
const look = () => {
const found = [...document.querySelectorAll<HTMLElement>('[data-jump]')].map((el) => el.dataset.jump ?? '');
setLabels((current) => (current.join('|') === found.join('|') ? current : found));
};
const observer = new MutationObserver(look);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-jump'] });
look();
return () => observer.disconnect();
}, []);
if (labels.length < 3) return null;
return (
<nav className="jump-nav" aria-label="Sections of this page">
{labels.map((label) => (
<button
key={label}
type="button"
className="jump-link"
onClick={() => document.querySelector(`[data-jump="${label}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
>
{label}
</button>
))}
</nav>
);
}
+41 -8
View File
@@ -1,10 +1,39 @@
import { Link, Outlet } from 'react-router-dom'; import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
import { DiceRoller } from './DiceRoller'; import { DiceRoller } from './DiceRoller';
export function Layout() { export function Layout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
// The campaign pages say which campaign they are in their address; sheets
// and encounters report theirs themselves (useRememberCampaign).
const onCampaign = useMatch('/campaigns/:campaignId/*');
useRememberCampaign(onCampaign?.params.campaignId);
const campaignId = useLastCampaignId();
// The same destinations twice: along the top bar on a wide screen, and as a
// bar along the bottom, in reach of a thumb, on a phone.
const destinations = [
{ to: '/campaigns', end: true, icon: '🗺', label: 'Campaigns' },
...(campaignId
? [
{ to: `/campaigns/${campaignId}`, end: true, icon: '🏰', label: 'Campaign' },
{ to: `/campaigns/${campaignId}/party`, end: true, icon: '👥', label: 'Party' },
]
: []),
{ to: '/account', end: true, icon: '👤', label: 'Account' },
];
const links = (className: string) =>
destinations.map((d) => (
<NavLink key={d.to} to={d.to} end={d.end} className={({ isActive }) => `${className}${isActive ? ' is-active' : ''}`}>
<span className="nav-icon" aria-hidden>
{d.icon}
</span>
<span>{d.label}</span>
</NavLink>
));
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="topbar"> <header className="topbar">
@@ -14,22 +43,26 @@ export function Layout() {
</span> </span>
The Sunken Reach The Sunken Reach
</Link> </Link>
<nav className="topbar-nav"> {user && (
{user && ( <nav className="topbar-nav" aria-label="Main">
<div className="topbar-links">{links('topbar-link')}</div>
<div className="topbar-user"> <div className="topbar-user">
<Link to="/account" title="Account settings"> <span className="topbar-name">{user.displayName}</span>
{user.displayName}
</Link>
<button className="btn btn-ghost" onClick={logout}> <button className="btn btn-ghost" onClick={logout}>
Log out Log out
</button> </button>
</div> </div>
)} </nav>
</nav> )}
</header> </header>
<main className="page"> <main className="page">
<Outlet /> <Outlet />
</main> </main>
{user && (
<nav className="bottom-nav" aria-label="Main">
{links('bottom-nav-link')}
</nav>
)}
<DiceRoller /> <DiceRoller />
</div> </div>
); );
+419 -4
View File
@@ -1,4 +1,6 @@
:root { :root {
/* Dark throughout, so the browser draws its own controls dark too. */
color-scheme: dark;
--bg: #131009; --bg: #131009;
--bg-elevated: #1b160e; --bg-elevated: #1b160e;
--bg-card: #221b12; --bg-card: #221b12;
@@ -347,10 +349,8 @@ textarea {
color: var(--text-muted); color: var(--text-muted);
} }
input[type='text'], /* Every text-like field, including an <input> with no type given. */
input[type='email'], input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
input[type='password'],
input[type='number'],
select, select,
textarea { textarea {
background: var(--bg-inset); background: var(--bg-inset);
@@ -1290,3 +1290,418 @@ button.death-pip {
margin: 0; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
} }
/* ---------------------------------------------------------------------- */
/* Navigation: links along the top bar on a wide screen, a bar along the */
/* bottom on a phone; and a row of links to the sections of a long page */
/* ---------------------------------------------------------------------- */
.topbar-links {
display: flex;
align-items: center;
gap: 0.25rem;
}
.topbar-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 6px;
border: 1px solid transparent;
color: var(--text-muted);
font-family: var(--font-display);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.topbar-link:hover {
text-decoration: none;
color: var(--accent-strong);
border-color: var(--border);
}
.topbar-link.is-active {
color: var(--accent-strong);
border-color: var(--accent);
background: var(--accent-soft);
}
.topbar-name {
max-width: 14ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bottom-nav {
display: none;
}
.jump-nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1.25rem;
}
.jump-link {
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.4rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-inset);
color: var(--text-muted);
cursor: pointer;
}
.jump-link:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
/* A section jumped to stops below the sticky top bar, not under it. */
[data-jump] {
scroll-margin-top: 4.5rem;
}
/* A table cell that holds a sentence rather than a figure may wrap. */
table.data-table td.cell-title,
table.data-table td.cell-wide {
white-space: normal;
min-width: 12rem;
}
/* ---------------------------------------------------------------------- */
/* Small screens. Nothing may be wider than the screen: rows of controls */
/* wrap, fields shrink to fit, tables become stacked cards, and the main */
/* destinations move to a bar along the bottom. */
/* ---------------------------------------------------------------------- */
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body,
#root,
.app-shell {
min-height: 100dvh;
}
/* A field never forces its row wider than the space there is. */
input,
select,
textarea {
max-width: 100%;
min-width: 0;
}
@media (max-width: 900px) {
:root {
--bottom-nav-height: calc(3.6rem + env(safe-area-inset-bottom, 0px));
--dice-slot: 4.2rem;
}
body {
overflow-wrap: break-word;
}
.page {
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1.5rem);
}
.topbar {
padding: 0.5rem 0.85rem;
gap: 0.5rem;
}
.topbar-brand {
font-size: 1rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-links,
.topbar-name {
display: none;
}
.topbar-nav {
flex-wrap: nowrap;
}
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 70;
display: flex;
height: var(--bottom-nav-height);
padding-bottom: env(safe-area-inset-bottom, 0px);
background: linear-gradient(180deg, #1e1810 0%, #120e08 100%);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.45);
}
.bottom-nav-link {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
color: var(--text-muted);
font-family: var(--font-display);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-top: 2px solid transparent;
}
.bottom-nav-link:hover {
text-decoration: none;
}
.bottom-nav-link .nav-icon {
font-size: 1.25rem;
line-height: 1.2;
}
.bottom-nav-link.is-active {
color: var(--accent-strong);
border-top-color: var(--accent-strong);
background: var(--accent-soft);
}
/* Nothing floats over the page on a phone. The dice roller takes the last
place in the bottom bar, and quick stats a place in the top bar. */
.bottom-nav {
padding-right: var(--dice-slot);
}
.fab-dice {
left: auto;
right: 0;
bottom: 0;
z-index: 71;
width: var(--dice-slot);
height: var(--bottom-nav-height);
padding: 0 0 env(safe-area-inset-bottom, 0px);
flex-direction: column;
gap: 0.1rem;
border: none;
border-top: 2px solid transparent;
border-radius: 0;
background: transparent;
box-shadow: none;
font-size: 1.25rem;
line-height: 1.2;
color: var(--text-muted);
}
.fab-dice::after {
content: 'Dice';
font-family: var(--font-display);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fab-dice:hover {
transform: none;
}
.dice-panel {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
}
.fab-quickstats {
top: 0.3rem;
bottom: auto;
right: 6.4rem;
z-index: 31;
width: 40px;
height: 40px;
font-size: 1.15rem;
box-shadow: none;
}
/* The brand makes room for it. */
.app-shell:has(.fab-quickstats) .topbar-brand {
max-width: calc(100vw - 11rem);
}
.drawer {
width: 100vw;
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1rem);
z-index: 95;
}
.drawer-backdrop {
z-index: 94;
}
h1 {
font-size: 1.5rem;
}
.card {
padding: 0.9rem 0.85rem;
}
.card-title {
flex-wrap: wrap;
}
.page-header {
margin-bottom: 1rem;
}
/* Rows of controls wrap instead of running off the side, and what is in
them may shrink: an inline min-width never beats the screen. */
.flex-row,
.flex-between,
.detail-row {
flex-wrap: wrap;
}
.flex-row > *,
.flex-between > * {
max-width: 100%;
}
.flex-row > input,
.flex-row > select,
.flex-row > textarea,
.flex-row > a,
.flex-between > a {
min-width: min(100%, 8rem) !important;
}
.btn {
white-space: normal;
padding: 0.6rem 0.9rem;
}
.grid-cols-2 {
grid-template-columns: minmax(0, 1fr);
}
.grid-cols-3 {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 130px), 1fr));
}
.choice-grid {
grid-template-columns: 1fr;
}
.btn-roll {
font-size: 1.1rem;
padding: 1.1rem 1.25rem;
max-width: 100%;
}
.step-footer > * {
flex: 1 1 auto;
}
/* Things to tap are big enough to hit with a thumb. */
input[type='checkbox'],
input[type='radio'] {
width: 22px;
height: 22px;
flex: none;
}
.death-pip {
width: 1.5rem;
height: 1.5rem;
}
.btn-target {
padding: 0.4rem 0.75rem;
}
.condition-chip {
padding: 0.3rem 0.6rem;
}
.condition-chip button {
padding: 0.15rem 0.35rem;
font-size: 1rem;
}
.info-tip-trigger {
font-size: 1.05em;
padding: 0.2rem;
}
.pick-chip {
padding: 0.5rem 0.9rem;
}
.jump-link {
padding: 0.5rem 0.8rem;
}
.attack-bar {
top: 3.4rem;
max-height: 55dvh;
overflow-y: auto;
}
[data-jump] {
scroll-margin-top: 3.8rem;
}
/* Tables: every row becomes a card. The first cell is its heading; the
rest carry their column's name from data-label. */
.table-wrap {
overflow: visible;
border: none;
}
table.data-table,
table.data-table tbody {
display: block;
}
table.data-table thead {
display: none;
}
table.data-table tbody tr {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.9rem;
padding: 0.7rem 0.75rem;
margin-bottom: 0.6rem;
background: var(--bg-inset);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
}
table.data-table tbody tr:last-child {
border-bottom: 1px solid var(--border-soft);
margin-bottom: 0;
}
table.data-table tbody tr:hover {
background: var(--bg-inset);
}
table.data-table td {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0;
white-space: normal;
min-width: 0;
}
table.data-table td:empty {
display: none;
}
table.data-table td.cell-title,
table.data-table td.cell-wide {
display: block;
flex: 1 1 100%;
min-width: 0;
}
table.data-table td.cell-title {
font-size: 1.05rem;
}
table.data-table td[data-label]::before {
content: attr(data-label);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
flex: none;
}
table.data-table td.cell-actions {
margin-left: auto;
}
}
/* The narrowest phones: five places in the bottom bar still fit. */
@media (max-width: 380px) {
:root {
--dice-slot: 3.4rem;
}
.bottom-nav-link,
.fab-dice::after {
font-size: 0.58rem;
letter-spacing: 0;
}
}
/* Between a phone and a full desktop the top bar drops the name to stay on one line. */
@media (max-width: 1080px) {
.topbar-name {
display: none;
}
.topbar-nav {
flex-wrap: nowrap;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useSyncExternalStore } from 'react';
// The campaign the user was last in, so the navigation can always offer a way
// back to it — from a character sheet or an encounter, whose addresses don't
// say which campaign they belong to. Kept per browser.
const KEY = 'dnd_last_campaign';
const listeners = new Set<() => void>();
function read(): string | null {
return localStorage.getItem(KEY);
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function forgetCampaign() {
localStorage.removeItem(KEY);
listeners.forEach((listener) => listener());
}
export function useLastCampaignId(): string | null {
return useSyncExternalStore(subscribe, read);
}
// Call from any page that knows which campaign it is showing.
export function useRememberCampaign(campaignId: string | null | undefined) {
useEffect(() => {
if (!campaignId || read() === campaignId) return;
localStorage.setItem(KEY, campaignId);
listeners.forEach((listener) => listener());
}, [campaignId]);
}
+56 -39
View File
@@ -42,6 +42,7 @@ import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket'; import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner'; import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput'; import { NumberInput } from '../components/NumberInput';
import { JumpNav } from '../components/JumpNav';
import { LifeBadge } from '../components/DeathSaves'; import { LifeBadge } from '../components/DeathSaves';
import { usePagination, Pagination } from '../components/Paginated'; import { usePagination, Pagination } from '../components/Paginated';
import { WaresEditor } from '../components/Wares'; import { WaresEditor } from '../components/Wares';
@@ -134,7 +135,7 @@ function PoolRowControls({
}) { }) {
return ( return (
<> <>
<td> <td data-label="Starting option">
<input <input
type="checkbox" type="checkbox"
aria-label={`${entry.name} is a starting option`} aria-label={`${entry.name} is a starting option`}
@@ -142,7 +143,7 @@ function PoolRowControls({
onChange={(e) => onToggleStarting(e.target.checked)} onChange={(e) => onToggleStarting(e.target.checked)}
/> />
</td> </td>
<td> <td className="cell-actions">
<button <button
className="btn btn-ghost" className="btn btn-ghost"
aria-label={`Remove ${entry.name}`} aria-label={`Remove ${entry.name}`}
@@ -570,6 +571,8 @@ export function CampaignHomePage() {
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || []; const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || []; const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
const shownEncounters = sortEncounters(liveEncounters, encounterSort); const shownEncounters = sortEncounters(liveEncounters, encounterSort);
const encountersPage = usePagination(shownEncounters, 10);
const archivedEncountersPage = usePagination(archivedEncounters, 10);
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED'); const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY'); const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
const sharedNotesPage = usePagination(sharedNotes, 6); const sharedNotesPage = usePagination(sharedNotes, 6);
@@ -676,8 +679,10 @@ export function CampaignHomePage() {
</div> </div>
</div> </div>
<JumpNav />
{isDM && ( {isDM && (
<div className="card"> <div data-jump="Settings" className="card">
<div className="card-title"> <div className="card-title">
<span>Campaign Settings</span> <span>Campaign Settings</span>
<button className="btn btn-ghost" onClick={() => setShowSettings((s) => !s)}> <button className="btn btn-ghost" onClick={() => setShowSettings((s) => !s)}>
@@ -810,7 +815,7 @@ export function CampaignHomePage() {
{formError && <div className="mt-md"><ErrorBanner message={formError} /></div>} {formError && <div className="mt-md"><ErrorBanner message={formError} /></div>}
{!isDM && myCharacters.length > 0 && ( {!isDM && myCharacters.length > 0 && (
<div className="mt-lg"> <div data-jump="My characters" className="mt-lg">
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2> <h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
{myCharacters.map((c) => ( {myCharacters.map((c) => (
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} /> <CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
@@ -818,7 +823,7 @@ export function CampaignHomePage() {
</div> </div>
)} )}
<div className="mt-lg"> <div data-jump={isDM ? 'Characters' : 'Party'} className="mt-lg">
<div className="page-header" style={{ marginBottom: '0.75rem' }}> <div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2> <h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
</div> </div>
@@ -846,7 +851,7 @@ export function CampaignHomePage() {
))} ))}
</div> </div>
<div className="mt-lg"> <div data-jump="Encounters" className="mt-lg">
<div className="page-header" style={{ marginBottom: '0.75rem' }}> <div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2> <h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
{liveEncounters.length > 1 && ( {liveEncounters.length > 1 && (
@@ -888,13 +893,17 @@ export function CampaignHomePage() {
)} )}
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && ( {isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
<p className="subtitle" style={{ marginTop: 0 }}> <p className="subtitle" style={{ marginTop: 0 }}>
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it. This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it; moving past the
top or bottom of a page carries an encounter onto the next one.
</p> </p>
)} )}
{encounters && liveEncounters.length === 0 && ( {encounters && liveEncounters.length === 0 && (
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div> <div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
)} )}
{shownEncounters.map((enc, index) => ( {encountersPage.pageItems.map((enc) => {
// Its place in the whole list, not on this page: that is what moving it changes.
const index = shownEncounters.indexOf(enc);
return (
<div key={enc.id} className="card flex-between flex-wrap"> <div key={enc.id} className="card flex-between flex-wrap">
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}> <Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span> <span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
@@ -939,7 +948,9 @@ export function CampaignHomePage() {
)} )}
</span> </span>
</div> </div>
))} );
})}
<Pagination page={encountersPage.page} pageCount={encountersPage.pageCount} onPrev={encountersPage.prevPage} onNext={encountersPage.nextPage} />
{isDM && archivedEncounters.length > 0 && ( {isDM && archivedEncounters.length > 0 && (
<div className="mt-md"> <div className="mt-md">
@@ -952,7 +963,7 @@ export function CampaignHomePage() {
{showArchivedEncounters && ( {showArchivedEncounters && (
<> <>
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p> <p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
{archivedEncounters.map((enc) => ( {archivedEncountersPage.pageItems.map((enc) => (
<div key={enc.id} className="card flex-between flex-wrap"> <div key={enc.id} className="card flex-between flex-wrap">
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}> <Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span> <span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
@@ -979,6 +990,12 @@ export function CampaignHomePage() {
</span> </span>
</div> </div>
))} ))}
<Pagination
page={archivedEncountersPage.page}
pageCount={archivedEncountersPage.pageCount}
onPrev={archivedEncountersPage.prevPage}
onNext={archivedEncountersPage.nextPage}
/>
</> </>
)} )}
</div> </div>
@@ -986,7 +1003,7 @@ export function CampaignHomePage() {
</div> </div>
{!isDM && locations && locations.length > 0 && ( {!isDM && locations && locations.length > 0 && (
<div className="mt-lg card"> <div data-jump="Places" className="mt-lg card">
<div className="card-title">Places You've Been</div> <div className="card-title">Places You've Been</div>
{locations.map((loc) => ( {locations.map((loc) => (
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}> <div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
@@ -998,7 +1015,7 @@ export function CampaignHomePage() {
)} )}
{isDM && ( {isDM && (
<div className="mt-lg card"> <div data-jump="NPCs" className="mt-lg card">
<div className="card-title"> <div className="card-title">
<span>NPCs</span> <span>NPCs</span>
<button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}> <button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}>
@@ -1100,7 +1117,7 @@ export function CampaignHomePage() {
)} )}
{isDM && ( {isDM && (
<div className="mt-lg card"> <div data-jump="Locations" className="mt-lg card">
<div className="card-title"> <div className="card-title">
<span>Locations</span> <span>Locations</span>
</div> </div>
@@ -1144,7 +1161,7 @@ export function CampaignHomePage() {
must be at least a weapon's or item's level to use it, and at least a spell's level to cast it. must be at least a weapon's or item's level to use it, and at least a spell's level to cast it.
</p> </p>
</div> </div>
<div className="mt-sm card"> <div data-jump="Weapons" className="mt-sm card">
<div className="card-title"> <div className="card-title">
<span>Weapons Pool</span> <span>Weapons Pool</span>
<div className="flex-row gap-sm"> <div className="flex-row gap-sm">
@@ -1218,12 +1235,12 @@ export function CampaignHomePage() {
<tbody> <tbody>
{weaponsPage.pageItems.map((w) => ( {weaponsPage.pageItems.map((w) => (
<tr key={w.id}> <tr key={w.id}>
<td>{w.name}</td> <td className="cell-title">{w.name}</td>
<td>{w.damageDice} {w.damageType}</td> <td data-label="Base damage">{w.damageDice} {w.damageType}</td>
<td> <td data-label="Level">
<StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} /> <StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} />
</td> </td>
<td> <td data-label="Growth">
<ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} /> <ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} />
</td> </td>
<PoolRowControls <PoolRowControls
@@ -1239,7 +1256,7 @@ export function CampaignHomePage() {
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} /> <Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
</div> </div>
<div className="mt-md card"> <div data-jump="Items" className="mt-md card">
<div className="card-title"> <div className="card-title">
<span>Items Pool</span> <span>Items Pool</span>
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}> <button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
@@ -1294,13 +1311,13 @@ export function CampaignHomePage() {
<tbody> <tbody>
{itemsPage.pageItems.map((item) => ( {itemsPage.pageItems.map((item) => (
<tr key={item.id}> <tr key={item.id}>
<td>{item.name}</td> <td className="cell-title">{item.name}</td>
<td>{item.description}</td> <td className="cell-wide">{item.description}</td>
<td>{item.damageDice || '—'}</td> <td data-label="Base dice">{item.damageDice || '—'}</td>
<td> <td data-label="Level">
<StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} /> <StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} />
</td> </td>
<td> <td data-label="Growth">
{item.damageDice ? ( {item.damageDice ? (
<ScalingSelect value={item.damageScaling} onChange={(damageScaling) => patchItem.mutate({ id: item.id, damageScaling })} /> <ScalingSelect value={item.damageScaling} onChange={(damageScaling) => patchItem.mutate({ id: item.id, damageScaling })} />
) : ( ) : (
@@ -1321,7 +1338,7 @@ export function CampaignHomePage() {
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} /> <Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
</div> </div>
<div className="mt-md card"> <div data-jump="Spells" className="mt-md card">
<div className="card-title"> <div className="card-title">
<span>Spell List</span> <span>Spell List</span>
<div className="flex-row gap-sm"> <div className="flex-row gap-sm">
@@ -1405,15 +1422,15 @@ export function CampaignHomePage() {
<tbody> <tbody>
{spellsPage.pageItems.map((s) => ( {spellsPage.pageItems.map((s) => (
<tr key={s.id}> <tr key={s.id}>
<td> <td className="cell-title">
<strong>{s.name}</strong> <strong>{s.name}</strong>
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div> <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div>
</td> </td>
<td style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td> <td data-label="Level" style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
<td> <td data-label="Damage">
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} /> <SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
</td> </td>
<td> <td data-label="Revives">
<input <input
type="checkbox" type="checkbox"
aria-label={`${s.name} revives the dead`} aria-label={`${s.name} revives the dead`}
@@ -1434,7 +1451,7 @@ export function CampaignHomePage() {
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} /> <Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
</div> </div>
<div className="mt-md card"> <div data-jump="Monsters" className="mt-md card">
<div className="card-title"> <div className="card-title">
<span>Monsters</span> <span>Monsters</span>
<div className="flex-row gap-sm"> <div className="flex-row gap-sm">
@@ -1507,7 +1524,7 @@ export function CampaignHomePage() {
<tbody> <tbody>
{monstersPage.pageItems.map((m) => ( {monstersPage.pageItems.map((m) => (
<tr key={m.id}> <tr key={m.id}>
<td> <td className="cell-title">
<strong>{m.name}</strong> <strong>{m.name}</strong>
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div> <div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
{m.specialTrait && ( {m.specialTrait && (
@@ -1517,13 +1534,13 @@ export function CampaignHomePage() {
)} )}
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>} {m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
</td> </td>
<td>{m.challengeRating || '—'}</td> <td data-label="CR">{m.challengeRating || '—'}</td>
<td>{m.ac}</td> <td data-label="AC">{m.ac}</td>
<td>{m.maxHp}</td> <td data-label="HP">{m.maxHp}</td>
<td>{m.speed}</td> <td data-label="Speed">{m.speed}</td>
<td>{fmtMod(m.attackBonus)}</td> <td data-label="Attack">{fmtMod(m.attackBonus)}</td>
<td>{m.damageDice || '—'}</td> <td data-label="Damage">{m.damageDice || '—'}</td>
<td> <td className="cell-actions">
<button <button
className="btn btn-ghost" className="btn btn-ghost"
aria-label={`Edit ${m.name}`} aria-label={`Edit ${m.name}`}
@@ -1556,7 +1573,7 @@ export function CampaignHomePage() {
</> </>
)} )}
<div className="mt-lg card"> <div data-jump="Notes" className="mt-lg card">
<div className="card-title">Shared Notes</div> <div className="card-title">Shared Notes</div>
<form onSubmit={handleCreateNote} className="flex-row flex-wrap"> <form onSubmit={handleCreateNote} className="flex-row flex-wrap">
<textarea <textarea
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useRememberCampaign } from '../lib/lastCampaign';
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries'; import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner'; import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput'; import { NumberInput } from '../components/NumberInput';
@@ -95,6 +96,7 @@ export function CharacterBuilderPage() {
const { characterId = '' } = useParams(); const { characterId = '' } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: character, isLoading, error } = useCharacter(characterId); const { data: character, isLoading, error } = useCharacter(characterId);
useRememberCampaign(character?.campaignId);
const roll = useBuildRoll(characterId); const roll = useBuildRoll(characterId);
const choose = useBuildChoose(characterId); const choose = useBuildChoose(characterId);
const finalize = useBuildFinalize(characterId); const finalize = useBuildFinalize(characterId);
+13 -8
View File
@@ -32,6 +32,8 @@ import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket'; import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner'; import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput'; import { NumberInput } from '../components/NumberInput';
import { JumpNav } from '../components/JumpNav';
import { useRememberCampaign } from '../lib/lastCampaign';
import { HpBar } from '../components/HpBar'; import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves'; import { DeathSaves } from '../components/DeathSaves';
import { QuickStatsFab } from '../components/QuickStatsDrawer'; import { QuickStatsFab } from '../components/QuickStatsDrawer';
@@ -235,6 +237,7 @@ export function CharacterSheetPage() {
const { characterId = '' } = useParams(); const { characterId = '' } = useParams();
const { user } = useAuth(); const { user } = useAuth();
const { data: character, isLoading, error } = useCharacter(characterId); const { data: character, isLoading, error } = useCharacter(characterId);
useRememberCampaign(character?.campaignId);
const patchCharacter = usePatchCharacter(characterId); const patchCharacter = usePatchCharacter(characterId);
useCampaignSocket(character?.campaignId); useCampaignSocket(character?.campaignId);
@@ -552,7 +555,9 @@ export function CharacterSheetPage() {
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p> <p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
)} )}
<div className="card mt-md"> <JumpNav />
<div data-jump="Abilities" className="card mt-md">
<div className="card-title">Ability Scores</div> <div className="card-title">Ability Scores</div>
<div className="grid grid-cols-3"> <div className="grid grid-cols-3">
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => ( {(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
@@ -568,7 +573,7 @@ export function CharacterSheetPage() {
</div> </div>
<div className="grid grid-cols-2 mt-md"> <div className="grid grid-cols-2 mt-md">
<div className="card"> <div data-jump="Saves" className="card">
<div className="card-title"> <div className="card-title">
Saving Throws <InfoTip term="SAVING_THROW" /> Saving Throws <InfoTip term="SAVING_THROW" />
</div> </div>
@@ -581,7 +586,7 @@ export function CharacterSheetPage() {
</div> </div>
))} ))}
</div> </div>
<div className="card"> <div data-jump="Skills" className="card">
<div className="card-title"> <div className="card-title">
Skills <InfoTip term="SKILL_BONUS" /> Skills <InfoTip term="SKILL_BONUS" />
</div> </div>
@@ -615,7 +620,7 @@ export function CharacterSheetPage() {
</div> </div>
)} )}
<div className="card mt-md"> <div data-jump="Weapons" className="card mt-md">
<div className="card-title"> <div className="card-title">
Weapons <InfoTip term="ATTACK_BONUS" /> Weapons <InfoTip term="ATTACK_BONUS" />
</div> </div>
@@ -678,7 +683,7 @@ export function CharacterSheetPage() {
</div> </div>
{campaignSpells && campaignSpells.length > 0 && ( {campaignSpells && campaignSpells.length > 0 && (
<div className="card mt-md"> <div data-jump="Spells" className="card mt-md">
<div className="card-title">Spells Known</div> <div className="card-title">Spells Known</div>
{spellsPage.pageItems.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 key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
@@ -742,7 +747,7 @@ export function CharacterSheetPage() {
{!character.restricted && ( {!character.restricted && (
<> <>
<div className="card mt-md"> <div data-jump="Inventory" className="card mt-md">
<div className="card-title">Inventory</div> <div className="card-title">Inventory</div>
{itemsPage.pageItems.map((held) => ( {itemsPage.pageItems.map((held) => (
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}> <div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
@@ -822,7 +827,7 @@ export function CharacterSheetPage() {
)} )}
</div> </div>
<div className="card mt-md"> <div data-jump="Money" className="card mt-md">
<div className="card-title"> <div className="card-title">
<span>Money</span> <span>Money</span>
<span className="mono">{formatPurse(character)}</span> <span className="mono">{formatPurse(character)}</span>
@@ -999,7 +1004,7 @@ export function CharacterSheetPage() {
</div> </div>
)} )}
<div className="card mt-md"> <div data-jump="Notes" className="card mt-md">
<div className="card-title">Notes</div> <div className="card-title">Notes</div>
<form onSubmit={handleAddNote} className="flex-row flex-wrap"> <form onSubmit={handleAddNote} className="flex-row flex-wrap">
<textarea <textarea
+2
View File
@@ -31,6 +31,7 @@ import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar'; import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves'; import { DeathSaves } from '../components/DeathSaves';
import { TradePanel } from '../components/TradePanel'; import { TradePanel } from '../components/TradePanel';
import { useRememberCampaign } from '../lib/lastCampaign';
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format'; import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc'; import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type { import type {
@@ -835,6 +836,7 @@ export function EncounterPage() {
const { user } = useAuth(); const { user } = useAuth();
const { data: encounter, isLoading, error } = useEncounter(encounterId); const { data: encounter, isLoading, error } = useEncounter(encounterId);
useCampaignSocket(encounter?.campaignId); useCampaignSocket(encounter?.campaignId);
useRememberCampaign(encounter?.campaignId);
const { data: campaignData } = useCampaign(encounter?.campaignId); const { data: campaignData } = useCampaign(encounter?.campaignId);
const isDM = campaignData?.campaign.role === 'DM'; const isDM = campaignData?.campaign.role === 'DM';
+8 -8
View File
@@ -67,16 +67,16 @@ export function PartyOverviewPage() {
const isMine = c.userId === user?.id; const isMine = c.userId === user?.id;
return ( return (
<tr key={c.id}> <tr key={c.id}>
<td> <td className="cell-title">
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}> <Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name} <span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
</Link> </Link>
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>} {isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
</td> </td>
<td>{c.species ? SPECIES_LABELS[c.species] : '—'}</td> <td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
<td>{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td> <td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
<td>{c.level}</td> <td data-label="Level">{c.level}</td>
<td> <td data-label="HP">
{c.maxHp != null ? ( {c.maxHp != null ? (
<div style={{ minWidth: 90 }}> <div style={{ minWidth: 90 }}>
<span className="mono"> <span className="mono">
@@ -90,9 +90,9 @@ export function PartyOverviewPage() {
'—' '—'
)} )}
</td> </td>
<td>{c.armorClass ?? '—'}</td> <td data-label="AC">{c.armorClass ?? '—'}</td>
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td> <td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td> <td data-label="Location">{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr> </tr>
); );
})} })}
+4 -4
View File
@@ -1,19 +1,19 @@
{ {
"name": "dnd-campaign-manager", "name": "dnd-campaign-manager",
"version": "1.10.0", "version": "1.10.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dnd-campaign-manager", "name": "dnd-campaign-manager",
"version": "1.10.0", "version": "1.10.1",
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend"
] ]
}, },
"backend": { "backend": {
"version": "1.10.0", "version": "1.10.1",
"dependencies": { "dependencies": {
"@prisma/client": "^5.20.0", "@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
} }
}, },
"frontend": { "frontend": {
"version": "1.10.0", "version": "1.10.1",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.103.2", "@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0", "axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dnd-campaign-manager", "name": "dnd-campaign-manager",
"version": "1.10.0", "version": "1.10.1",
"private": true, "private": true,
"description": "D&D 5e campaign manager — monorepo root", "description": "D&D 5e campaign manager — monorepo root",
"workspaces": [ "workspaces": [