V2 Phase 5+6: connectivity, presence, navigation & friction
Phase 5 — live-session & multi-device hardening: - connectivityStore + SyncStatusIndicator in the shell: shows "Offline" when the browser is offline and the cloud autosave state (saving / saved / failed) when signed in; useCloudAutosave now reports its status. Owns online/offline events. - server presence heartbeat: protocol-level ping/pong per socket terminates a vanished connection (~30s), so a player who closes their laptop drops out of the GM's roster instead of lingering. No client changes needed. Phase 6 — onboarding & friction: - navigation: surfaced the previously orphaned routes in the rail — a new "World" group (Notes, NPCs, Quests, Calendar) and Homebrew + Assistant under Reference. (Empty-state hints and map rename already existed.) - combat keyboard control: n/→ next turn, p/← previous turn, guarded so it never fires while typing; button tooltips document it. Test hygiene: scoped now-ambiguous nav links in e2e to the Primary landmark, and fixed pre-existing stale emoji selectors in the realtime suite (📡 Host → Host, 📨 Just for you → Just for you) left over from the Living Codex emoji purge. Gate green: tsc + 277 unit + 34 e2e + 2 realtime. App + server build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
|
|||||||
await gm.getByLabel('Settings').click();
|
await gm.getByLabel('Settings').click();
|
||||||
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
await gm.getByRole('button', { name: '📡 Host' }).click();
|
await gm.getByRole('button', { name: 'Host' }).click();
|
||||||
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
||||||
expect(code).toHaveLength(6);
|
expect(code).toHaveLength(6);
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
|
|||||||
await gm.getByRole('button', { name: /Share with/ }).click();
|
await gm.getByRole('button', { name: /Share with/ }).click();
|
||||||
await gm.getByLabel('Share title').fill('Secret passage');
|
await gm.getByLabel('Share title').fill('Secret passage');
|
||||||
await gm.getByRole('button', { name: /Send to/ }).click();
|
await gm.getByRole('button', { name: /Send to/ }).click();
|
||||||
await expect(player.getByText('📨 Just for you')).toBeVisible();
|
await expect(player.getByText('Just for you')).toBeVisible();
|
||||||
await expect(player.getByText('Secret passage')).toBeVisible();
|
await expect(player.getByText('Secret passage')).toBeVisible();
|
||||||
|
|
||||||
await gmCtx.close();
|
await gmCtx.close();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test('a player claims a seat and manages their character; edits round-trip to th
|
|||||||
await gm.getByLabel('Settings').click();
|
await gm.getByLabel('Settings').click();
|
||||||
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
await gm.getByRole('button', { name: '📡 Host' }).click();
|
await gm.getByRole('button', { name: 'Host' }).click();
|
||||||
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
||||||
expect(code).toHaveLength(6);
|
expect(code).toHaveLength(6);
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ test('level-up advisor offers routes and expands a chosen one (deterministic)',
|
|||||||
test('assistant page renders the campaign insights section', async ({ page }) => {
|
test('assistant page renders the campaign insights section', async ({ page }) => {
|
||||||
await page.getByLabel('Settings').click();
|
await page.getByLabel('Settings').click();
|
||||||
await page.getByRole('button', { name: 'Load sample campaign' }).click();
|
await page.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
await page.getByRole('link', { name: 'Assistant' }).click();
|
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
|
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
|
||||||
// Sample has a single encounter → not enough for a trend yet.
|
// Sample has a single encounter → not enough for a trend yet.
|
||||||
await expect(page.getByText(/No trends detected yet/)).toBeVisible();
|
await expect(page.getByText(/No trends detected yet/)).toBeVisible();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test('assistant flags a bloodied PC and builds an encounter', async ({ page }) =
|
|||||||
|
|
||||||
// Assistant shows a resource suggestion
|
// Assistant shows a resource suggestion
|
||||||
await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'Assistant' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible();
|
||||||
await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible();
|
await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test('homebrew: create a custom monster and find it in the compendium', async ({
|
|||||||
await page.locator('input[data-autofocus]').fill('Brew');
|
await page.locator('input[data-autofocus]').fill('Brew');
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'Homebrew' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Homebrew' }).click();
|
||||||
|
|
||||||
// Create a homebrew monster and name it
|
// Create a homebrew monster and name it
|
||||||
await page.getByRole('button', { name: /New monster/ }).click();
|
await page.getByRole('button', { name: /New monster/ }).click();
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
|
|||||||
|
|
||||||
// NPC
|
// NPC
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'NPCs' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'NPCs' }).click();
|
||||||
await page.getByRole('button', { name: '+ New NPC' }).first().click();
|
await page.getByRole('button', { name: '+ New NPC' }).first().click();
|
||||||
await expect(page.getByLabel('NPC name')).toBeVisible();
|
await expect(page.getByLabel('NPC name')).toBeVisible();
|
||||||
|
|
||||||
// Quest with an objective
|
// Quest with an objective
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'Quests' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Quests' }).click();
|
||||||
await page.getByRole('button', { name: '+ New quest' }).first().click();
|
await page.getByRole('button', { name: '+ New quest' }).first().click();
|
||||||
await page.getByPlaceholder('Add objective…').fill('Find the Tome');
|
await page.getByPlaceholder('Add objective…').fill('Find the Tome');
|
||||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||||
@@ -44,7 +44,7 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
|
|||||||
|
|
||||||
// Calendar advances days
|
// Calendar advances days
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'Calendar' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Calendar' }).click();
|
||||||
await page.getByRole('button', { name: '+1 week' }).click();
|
await page.getByRole('button', { name: '+1 week' }).click();
|
||||||
await expect(page.getByText('7', { exact: true })).toBeVisible();
|
await expect(page.getByText('7', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-1
@@ -23,6 +23,10 @@ const bearer = (req: FastifyRequest): string | undefined => {
|
|||||||
|
|
||||||
// per-socket token bucket: 80 messages / 10s
|
// per-socket token bucket: 80 messages / 10s
|
||||||
const RATE = { capacity: 80, refillMs: 10_000 };
|
const RATE = { capacity: 80, refillMs: 10_000 };
|
||||||
|
// Protocol-level keepalive: ping each socket on an interval and terminate one
|
||||||
|
// that misses a pong, so a vanished player (closed laptop, dead network) is
|
||||||
|
// detected and drops out of the GM's roster within ~one interval.
|
||||||
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
export function buildServer() {
|
export function buildServer() {
|
||||||
const app = Fastify({ bodyLimit: BODY_LIMIT });
|
const app = Fastify({ bodyLimit: BODY_LIMIT });
|
||||||
@@ -155,6 +159,15 @@ export function buildServer() {
|
|||||||
let tokens = RATE.capacity;
|
let tokens = RATE.capacity;
|
||||||
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
|
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
|
||||||
|
|
||||||
|
// Heartbeat: a socket that doesn't answer the previous ping is dead → drop it.
|
||||||
|
let alive = true;
|
||||||
|
socket.on('pong', () => { alive = true; });
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (!alive) { try { socket.terminate(); } catch { /* already gone */ } return; }
|
||||||
|
alive = false;
|
||||||
|
try { socket.ping(); } catch { /* closed */ }
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
|
||||||
socket.on('message', (raw: Buffer) => {
|
socket.on('message', (raw: Buffer) => {
|
||||||
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
|
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
|
||||||
let parsed;
|
let parsed;
|
||||||
@@ -177,7 +190,7 @@ export function buildServer() {
|
|||||||
case 'setName': hub.setName(sender, m.name); break;
|
case 'setName': hub.setName(sender, m.name); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
|
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -3,6 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
|||||||
import {
|
import {
|
||||||
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
||||||
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
|
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
|
||||||
|
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
@@ -20,10 +21,11 @@ import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
|||||||
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
|
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
|
||||||
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
||||||
import { SignalsBell } from '@/features/assistant/SignalsBell';
|
import { SignalsBell } from '@/features/assistant/SignalsBell';
|
||||||
|
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
|
||||||
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
|
|
||||||
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'reference' };
|
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' };
|
||||||
const NAV: NavItem[] = [
|
const NAV: NavItem[] = [
|
||||||
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
|
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
|
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
|
||||||
@@ -31,12 +33,19 @@ const NAV: NavItem[] = [
|
|||||||
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
|
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
|
||||||
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
|
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
|
||||||
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
|
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
|
||||||
|
{ to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' },
|
||||||
|
{ to: '/npcs', label: 'NPCs', icon: Drama, exact: false, group: 'world' },
|
||||||
|
{ to: '/quests', label: 'Quests', icon: Target, exact: false, group: 'world' },
|
||||||
|
{ to: '/calendar', label: 'Calendar', icon: CalendarDays, exact: false, group: 'world' },
|
||||||
{ to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' },
|
{ to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' },
|
||||||
|
{ to: '/homebrew', label: 'Homebrew', icon: FlaskConical, exact: false, group: 'reference' },
|
||||||
|
{ to: '/assistant', label: 'Assistant', icon: Sparkles, exact: false, group: 'reference' },
|
||||||
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
|
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
|
||||||
];
|
];
|
||||||
const GROUPS: { id: NavItem['group']; label: string | null }[] = [
|
const GROUPS: { id: NavItem['group']; label: string | null }[] = [
|
||||||
{ id: 'top', label: null },
|
{ id: 'top', label: null },
|
||||||
{ id: 'play', label: 'At the Table' },
|
{ id: 'play', label: 'At the Table' },
|
||||||
|
{ id: 'world', label: 'World' },
|
||||||
{ id: 'reference', label: 'Reference' },
|
{ id: 'reference', label: 'Reference' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -206,6 +215,7 @@ export function RootLayout() {
|
|||||||
</Button>
|
</Button>
|
||||||
{activeCampaign && <HandoutControl />}
|
{activeCampaign && <HandoutControl />}
|
||||||
<SessionControl />
|
<SessionControl />
|
||||||
|
<SyncStatusIndicator />
|
||||||
<SignalsBell />
|
<SignalsBell />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
|
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Cloud, CloudOff, RefreshCw, WifiOff } from 'lucide-react';
|
||||||
|
import { cloudUsername } from '@/lib/cloud/client';
|
||||||
|
import { useConnectivityStore } from '@/stores/connectivityStore';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact shell indicator for connectivity + cloud sync. Shows "Offline" when
|
||||||
|
* the browser is offline, otherwise the autosave state (saving / saved / failed)
|
||||||
|
* while signed into the cloud. Renders nothing when online and not using cloud,
|
||||||
|
* to keep the bar quiet. Also owns the online/offline event wiring.
|
||||||
|
*/
|
||||||
|
export function SyncStatusIndicator() {
|
||||||
|
const online = useConnectivityStore((s) => s.online);
|
||||||
|
const cloudSync = useConnectivityStore((s) => s.cloudSync);
|
||||||
|
const setOnline = useConnectivityStore((s) => s.setOnline);
|
||||||
|
const signedIn = !!cloudUsername();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const on = () => setOnline(true);
|
||||||
|
const off = () => setOnline(false);
|
||||||
|
window.addEventListener('online', on);
|
||||||
|
window.addEventListener('offline', off);
|
||||||
|
setOnline(navigator.onLine);
|
||||||
|
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); };
|
||||||
|
}, [setOnline]);
|
||||||
|
|
||||||
|
if (!online) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning" title="You're offline — changes are saved locally and will sync when you reconnect." role="status">
|
||||||
|
<WifiOff size={14} aria-hidden /> <span className="hidden sm:inline">Offline</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signedIn) return null;
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
idle: null,
|
||||||
|
saving: { Icon: RefreshCw, text: 'Saving…', cls: 'text-muted', spin: true, title: 'Saving your campaign to the cloud…' },
|
||||||
|
saved: { Icon: Cloud, text: 'Saved', cls: 'text-success', spin: false, title: 'Your campaign is backed up to the cloud.' },
|
||||||
|
error: { Icon: CloudOff, text: 'Sync failed', cls: 'text-danger', spin: false, title: 'Cloud sync failed — your data is safe locally and will retry on the next change.' },
|
||||||
|
} as const;
|
||||||
|
const s = map[cloudSync];
|
||||||
|
if (!s) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('flex items-center gap-1 rounded-md px-2 py-1 text-xs', s.cls)} title={s.title} role="status" aria-live="polite">
|
||||||
|
<s.Icon size={14} aria-hidden className={s.spin ? 'animate-spin' : undefined} />
|
||||||
|
<span className="hidden sm:inline">{s.text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/lib/db/db';
|
import { db } from '@/lib/db/db';
|
||||||
import { cloudUsername, pushBackup } from '@/lib/cloud/client';
|
import { cloudUsername, pushBackup } from '@/lib/cloud/client';
|
||||||
|
import { useConnectivityStore } from '@/stores/connectivityStore';
|
||||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +31,12 @@ export function useCloudAutosave(): void {
|
|||||||
}, [], undefined);
|
}, [], undefined);
|
||||||
|
|
||||||
const save = useDebouncedCallback(() => {
|
const save = useDebouncedCallback(() => {
|
||||||
if (cloudUsername()) void pushBackup().catch(() => { /* offline / transient — next change retries */ });
|
if (!cloudUsername()) return;
|
||||||
|
const conn = useConnectivityStore.getState();
|
||||||
|
conn.setCloudSync('saving');
|
||||||
|
void pushBackup()
|
||||||
|
.then(() => useConnectivityStore.getState().setCloudSync('saved'))
|
||||||
|
.catch(() => useConnectivityStore.getState().setCloudSync('error')); // offline / transient — next change retries
|
||||||
}, 8000);
|
}, 8000);
|
||||||
|
|
||||||
// Skip the initial load; only push once data actually changes after mount.
|
// Skip the initial load; only push once data actually changes after mount.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
@@ -121,6 +121,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
const current = currentCombatant(encounter);
|
const current = currentCombatant(encounter);
|
||||||
const isActive = encounter.status === 'active';
|
const isActive = encounter.status === 'active';
|
||||||
|
|
||||||
|
// Keyboard turn control for a GM running the table hands-free. Guarded so it
|
||||||
|
// never fires while typing in a field, and only during an active fight.
|
||||||
|
const advanceRef = useRef(advanceTurn);
|
||||||
|
advanceRef.current = advanceTurn;
|
||||||
|
const prevRef = useRef(() => mutate(previousTurn));
|
||||||
|
prevRef.current = () => mutate(previousTurn);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return;
|
||||||
|
if (e.key === 'n' || e.key === 'ArrowRight') { e.preventDefault(); advanceRef.current(); }
|
||||||
|
else if (e.key === 'p' || e.key === 'ArrowLeft') { e.preventDefault(); prevRef.current(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Control bar */}
|
{/* Control bar */}
|
||||||
@@ -175,10 +194,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
)}
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
|
<Button variant="secondary" onClick={() => mutate(previousTurn)} title="Previous turn (p or ←)">
|
||||||
<ChevronLeft size={15} aria-hidden /> Prev
|
<ChevronLeft size={15} aria-hidden /> Prev
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={advanceTurn}>
|
<Button variant="primary" onClick={advanceTurn} title="Next turn (n or →)">
|
||||||
Next turn <ChevronRight size={15} aria-hidden />
|
Next turn <ChevronRight size={15} aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
|
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type CloudSync = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
interface ConnectivityState {
|
||||||
|
/** browser online/offline (navigator.onLine + events) */
|
||||||
|
online: boolean;
|
||||||
|
/** cloud autosave state, surfaced in the shell */
|
||||||
|
cloudSync: CloudSync;
|
||||||
|
lastSavedAt: number | null;
|
||||||
|
setOnline: (online: boolean) => void;
|
||||||
|
setCloudSync: (cloudSync: CloudSync) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ephemeral connectivity + sync status (intentionally not persisted). */
|
||||||
|
export const useConnectivityStore = create<ConnectivityState>((set) => ({
|
||||||
|
online: typeof navigator === 'undefined' ? true : navigator.onLine,
|
||||||
|
cloudSync: 'idle',
|
||||||
|
lastSavedAt: null,
|
||||||
|
setOnline: (online) => set({ online }),
|
||||||
|
setCloudSync: (cloudSync) =>
|
||||||
|
set((s) => ({ cloudSync, lastSavedAt: cloudSync === 'saved' ? Date.now() : s.lastSavedAt })),
|
||||||
|
}));
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user