Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd301182b |
@@ -106,6 +106,12 @@ including a character sheet or an encounter) and **Account**.
|
||||
items) and by level in the class that gives them.
|
||||
- **Armor holds Dexterity back** as in the rules: all of it counts toward AC
|
||||
in light armor, at most +2 in medium, none in heavy.
|
||||
- **Battles that keep to the fight.** While a battle is under way the scene
|
||||
description shows only between rounds, cards' details start closed (the
|
||||
spotlight, or on a phone the card on turn, has them), and the DM's setup,
|
||||
Experience and Rewards panels fold to their titles. Button labels wrap
|
||||
inside their buttons, and on a phone quick stats no longer sits on Log out
|
||||
nor incoming rolls on the bottom bar.
|
||||
- **Stricter battles.** A spell with no slot of its level or higher left
|
||||
cannot be cast (cantrips never need one), and a player changes the weapon
|
||||
in hand only as the battle begins or on their own turn after attacking.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
+16
-1
@@ -155,6 +155,16 @@ characters, and characters keep their current HP.
|
||||
|
||||
Both save when the DM clicks out of the box.
|
||||
|
||||
In a battle under way the Scene box is only there between rounds: while the
|
||||
first in the turn order is on turn. For the rest of the round it is out of
|
||||
the way, for the DM and the players alike. Before the battle begins and once
|
||||
it is over it is always there.
|
||||
|
||||
**Folded panels.** While a battle is in progress, "Add to the scene",
|
||||
Experience and Rewards on the DM's side column start folded to their title
|
||||
(click the title to open one), so the fight is what fills the screen. They
|
||||
open again when the battle ends, or before it begins.
|
||||
|
||||
## 5. Who is in the scene
|
||||
|
||||
The DM adds people from the "Add to the scene" panel.
|
||||
@@ -240,7 +250,12 @@ Each has a card. The card's left edge is coloured by kind, and it shows:
|
||||
- **NPCs:** role, attitude and description; for the DM also AC, attack,
|
||||
damage and DM notes.
|
||||
|
||||
Details start open for the DM and closed for players. Weapons and spells
|
||||
In a battle every card's details start closed, so the list stays short: on a
|
||||
wide screen the spotlight shows whoever is on turn in full, and on a narrower
|
||||
one the card of whoever is on turn opens its details by itself (and closes
|
||||
them again when the turn passes). In a quest or free roam the details start
|
||||
open for the DM and closed for players. "▸ Show details" / "▾ Hide details"
|
||||
on a card overrides this for that card. Weapons and spells
|
||||
above a character's level are left out, because the character cannot use
|
||||
them.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Suspense, useEffect, useRef } from 'react';
|
||||
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||
@@ -15,6 +15,19 @@ export function Layout() {
|
||||
useRememberCampaign(onCampaign?.params.campaignId);
|
||||
const campaignId = useLastCampaignId();
|
||||
|
||||
// How tall the top bar really is, as --topbar-h, so that what sticks under
|
||||
// it (the turn order in a battle) sits right below it on any screen.
|
||||
const topbar = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const bar = topbar.current;
|
||||
if (!bar) return;
|
||||
const measure = () => document.documentElement.style.setProperty('--topbar-h', `${bar.offsetHeight}px`);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(bar);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 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 = [
|
||||
@@ -39,7 +52,7 @@ export function Layout() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<header className="topbar" ref={topbar}>
|
||||
<Link to="/campaigns" className="topbar-brand">
|
||||
<span className="die" aria-hidden>
|
||||
⚄
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Whether a CSS media query matches right now, kept up to date as the window
|
||||
// is resized: useMediaQuery('(min-width: 960px)').
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const list = window.matchMedia(query);
|
||||
const onChange = () => setMatches(list.matches);
|
||||
onChange();
|
||||
list.addEventListener('change', onChange);
|
||||
return () => list.removeEventListener('change', onChange);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
+28
-28
@@ -25,6 +25,8 @@
|
||||
--radius: 8px;
|
||||
--font-display: 'Cinzel', Georgia, serif;
|
||||
--font-body: 'Crimson Pro', Georgia, 'Times New Roman', serif;
|
||||
/* The top bar's height, measured by Layout.tsx; this is only until then. */
|
||||
--topbar-h: 3.6rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@@ -262,7 +264,12 @@ textarea {
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
/* A label longer than the room there is wraps inside the button rather
|
||||
than running out of it (or under the button beside it). */
|
||||
max-width: 100%;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
@@ -883,7 +890,7 @@ input[type='radio'] {
|
||||
/* Who acts when, as one line of names: this round, a flag, the next round. */
|
||||
.initiative-track {
|
||||
position: sticky;
|
||||
top: 3.6rem;
|
||||
top: var(--topbar-h);
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2176,16 +2183,26 @@ textarea {
|
||||
.fab-dice:hover {
|
||||
transform: none;
|
||||
}
|
||||
.dice-incoming {
|
||||
left: 0.85rem;
|
||||
bottom: calc(var(--bottom-nav-height) + 0.6rem);
|
||||
width: calc(100vw - 1.7rem);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
/* Log out has a set width on a phone, so quick stats can sit just left of it. */
|
||||
.topbar-user .btn {
|
||||
width: 5.2rem;
|
||||
padding: 0.45rem 0.3rem;
|
||||
}
|
||||
.fab-quickstats {
|
||||
top: 0.3rem;
|
||||
top: calc((var(--topbar-h) - 40px) / 2);
|
||||
bottom: auto;
|
||||
right: 6.4rem;
|
||||
right: calc(0.85rem + 5.2rem + 0.5rem);
|
||||
z-index: 31;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -2194,7 +2211,7 @@ textarea {
|
||||
}
|
||||
/* The brand makes room for it. */
|
||||
.app-shell:has(.fab-quickstats) .topbar-brand {
|
||||
max-width: calc(100vw - 11rem);
|
||||
max-width: calc(100vw - 0.85rem - 6.55rem - 40px - 1.25rem);
|
||||
}
|
||||
.drawer {
|
||||
width: 100vw;
|
||||
@@ -2237,7 +2254,6 @@ textarea {
|
||||
min-width: min(100%, 8rem) !important;
|
||||
}
|
||||
.btn {
|
||||
white-space: normal;
|
||||
padding: 0.6rem 0.9rem;
|
||||
}
|
||||
.grid-cols-2 {
|
||||
@@ -2289,11 +2305,6 @@ textarea {
|
||||
.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;
|
||||
}
|
||||
@@ -2748,7 +2759,7 @@ button.slot-pip:disabled {
|
||||
/* Everyone in the scene on one line that stays under the top bar. */
|
||||
.turn-strip {
|
||||
position: sticky;
|
||||
top: 3.6rem;
|
||||
top: var(--topbar-h);
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
@@ -2813,20 +2824,17 @@ button.slot-pip:disabled {
|
||||
.turn-chip.has-acted {
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* The attack bar sits under the strip, not behind it. */
|
||||
.attack-bar {
|
||||
top: 7.6rem;
|
||||
}
|
||||
|
||||
/* On a wide screen the side column stays in view, scrolling on its own. */
|
||||
@media (min-width: 960px) {
|
||||
.encounter-side {
|
||||
position: sticky;
|
||||
top: 7.6rem;
|
||||
max-height: calc(100vh - 8.4rem);
|
||||
top: calc(var(--topbar-h) + 4rem);
|
||||
max-height: calc(100vh - var(--topbar-h) - 4.8rem);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
padding-right: 2px;
|
||||
/* Room to scroll the last card up past the quick-stats button. */
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
}
|
||||
.spotlight {
|
||||
@@ -2886,6 +2894,7 @@ button.slot-pip:disabled {
|
||||
summary.card-title {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
summary.card-title::-webkit-details-marker {
|
||||
display: none;
|
||||
@@ -2902,15 +2911,6 @@ details:not([open]) > summary.card-title::after {
|
||||
content: '▸';
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.turn-strip {
|
||||
top: 2.9rem;
|
||||
}
|
||||
.attack-bar {
|
||||
top: 6.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* In one column the card on turn is already at the top of the list. */
|
||||
@media (max-width: 959px) {
|
||||
.spotlight,
|
||||
|
||||
@@ -45,6 +45,7 @@ import { GearInForce } from '../components/CharacterPanel';
|
||||
import { SheetNumbers } from '../components/SheetNumbers';
|
||||
import { ConditionChip, ConditionPicker, ResourceTracker, StateBadges } from '../components/CharacterState';
|
||||
import { useHotkeys } from '../hooks/useHotkeys';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
import { Rollable } from '../components/Rollable';
|
||||
import { GroupCheckModal } from '../components/GroupCheckModal';
|
||||
import { withBonus } from '../lib/diceRoller';
|
||||
@@ -496,6 +497,7 @@ function CombatantCard({
|
||||
onTarget,
|
||||
isPinned,
|
||||
onPin,
|
||||
detailsOpen,
|
||||
onRemove,
|
||||
onError,
|
||||
}: {
|
||||
@@ -519,17 +521,18 @@ function CombatantCard({
|
||||
// Whether this combatant's stats are the ones kept in view, and the switch for it.
|
||||
isPinned: boolean;
|
||||
onPin: () => void;
|
||||
// Whether the details start open (until the viewer opens or closes them).
|
||||
detailsOpen: boolean;
|
||||
onRemove: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const [hpDelta, setHpDelta] = useState('');
|
||||
// How many rounds the next condition added lasts; empty for until removed.
|
||||
const [rounds, setRounds] = useState('');
|
||||
// Details start open for the DM (who runs the scene from this screen) and
|
||||
// closed for players; null = "not toggled yet", so it follows isDM once the
|
||||
// campaign role has loaded.
|
||||
// null = "not toggled yet", so the details follow detailsOpen (which
|
||||
// changes as the turn passes, and once the campaign role has loaded).
|
||||
const [detailsToggled, setDetailsToggled] = useState<boolean | null>(null);
|
||||
const showDetails = detailsToggled ?? isDM;
|
||||
const showDetails = detailsToggled ?? detailsOpen;
|
||||
|
||||
const info = p.characterCombatInfo;
|
||||
const monster = p.monster;
|
||||
@@ -987,12 +990,14 @@ function ExperiencePanel({
|
||||
difficulty,
|
||||
xpAwarded,
|
||||
partySize,
|
||||
folded,
|
||||
onError,
|
||||
}: {
|
||||
encounterId: string;
|
||||
difficulty: EncounterDifficulty;
|
||||
xpAwarded: number | null;
|
||||
partySize: number;
|
||||
folded: boolean; // starts closed, as it does while a battle is under way
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const awardXp = useAwardXp(encounterId);
|
||||
@@ -1001,11 +1006,11 @@ function ExperiencePanel({
|
||||
const { thresholds, rating } = difficulty;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<details className="card" open={!folded}>
|
||||
<summary className="card-title">
|
||||
<span>Experience</span>
|
||||
{rating && <span className={`badge ${RATING_BADGES[rating]}`}>{RATING_LABELS[rating]}</span>}
|
||||
</div>
|
||||
</summary>
|
||||
{difficulty.monsters === 0 ? (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
No monster here has a challenge rating, so there is nothing to judge the difficulty by. XP can still be awarded: type an amount.
|
||||
@@ -1060,7 +1065,7 @@ function ExperiencePanel({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1076,6 +1081,7 @@ function RewardsPanel({
|
||||
characters,
|
||||
weapons,
|
||||
items,
|
||||
folded,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
@@ -1083,6 +1089,7 @@ function RewardsPanel({
|
||||
characters: CharacterSummary[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
folded: boolean; // starts closed, as it does while a battle is under way
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const addReward = useAddReward(encounter.id);
|
||||
@@ -1130,8 +1137,8 @@ function RewardsPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">Rewards</div>
|
||||
<details className="card" open={!folded}>
|
||||
<summary className="card-title">Rewards{rewards.length > 0 && <span className="section-count">{rewards.length}</span>}</summary>
|
||||
{isDM && rewards.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Line up coins, weapons and items here. Players only see a reward once you award it.
|
||||
@@ -1284,7 +1291,7 @@ function RewardsPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1319,6 +1326,8 @@ export function EncounterPage() {
|
||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||
const navigate = useNavigate();
|
||||
// Wide enough for the side column, and so for the spotlight in it.
|
||||
const wide = useMediaQuery('(min-width: 960px)');
|
||||
|
||||
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
|
||||
const [pickId, setPickId] = useState('');
|
||||
@@ -1454,6 +1463,17 @@ export function EncounterPage() {
|
||||
);
|
||||
const unrolled = participants.filter((p) => p.kind !== 'CHARACTER' && p.initiativeRoll == null).length;
|
||||
|
||||
// In a battle every card's details start closed, so the list stays short:
|
||||
// the spotlight beside it shows whoever is on turn in full, and where there
|
||||
// is no room for the spotlight (one column) their own card opens instead.
|
||||
// Elsewhere the DM, who runs the scene from this screen, gets them open.
|
||||
const detailsOpen = (p: EncounterParticipant) => (isBattle ? p.id === active?.id && !wide : isDM);
|
||||
// A battle under way folds away what is for before and after it.
|
||||
const inBattle = isBattle && encounter.status === 'ACTIVE';
|
||||
// The scene is set before the fight and between rounds (while the first in
|
||||
// the order is on turn), and stays out of the way during the round.
|
||||
const showScene = !inBattle || active?.id === standing[0]?.id;
|
||||
|
||||
const card = (p: EncounterParticipant) => (
|
||||
<CombatantCard
|
||||
key={p.id}
|
||||
@@ -1473,6 +1493,7 @@ export function EncounterPage() {
|
||||
}
|
||||
isPinned={p.id === pinnedId}
|
||||
onPin={() => setPinnedId(p.id === pinnedId ? null : p.id)}
|
||||
detailsOpen={detailsOpen(p)}
|
||||
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
||||
onError={onError}
|
||||
/>
|
||||
@@ -1655,7 +1676,7 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isDM || encounter.description) && (
|
||||
{(isDM || encounter.description) && showScene && (
|
||||
<div className="card">
|
||||
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
|
||||
{isDM ? (
|
||||
@@ -1790,8 +1811,8 @@ export function EncounterPage() {
|
||||
/>
|
||||
)}
|
||||
{isDM && (
|
||||
<div className="card">
|
||||
<div className="card-title">Add to the scene</div>
|
||||
<details className="card" open={!inBattle}>
|
||||
<summary className="card-title">Add to the scene</summary>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<select
|
||||
aria-label="What to add"
|
||||
@@ -1855,7 +1876,7 @@ export function EncounterPage() {
|
||||
Create {pickType === 'npc' ? 'NPCs' : 'monsters'} on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link> first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{isBattle && (encounter.recentActions ?? []).length > 0 && (
|
||||
@@ -1895,6 +1916,7 @@ export function EncounterPage() {
|
||||
difficulty={encounter.difficulty}
|
||||
xpAwarded={encounter.xpAwarded ?? null}
|
||||
partySize={participants.filter((p) => p.kind === 'CHARACTER' && p.character).length}
|
||||
folded={inBattle}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
@@ -1905,6 +1927,7 @@ export function EncounterPage() {
|
||||
characters={builtCharacters}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
folded={inBattle}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user