P14: dock the session sidebar (persistent column, not a temp drawer)
- The session panel now docks as a persistent right-hand column on desktop and only floats as an overlay drawer on phones (md breakpoint). Open state persists (uiStore.sessionDockOpen). The header button opens it (idempotent); the ✕ closes. - Players get it auto-opened on join — they live in the panel during play. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+23
-9
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { CommandPalette } from '@/components/CommandPalette';
|
import { CommandPalette } from '@/components/CommandPalette';
|
||||||
@@ -63,8 +63,11 @@ function ThemeToggle() {
|
|||||||
export function RootLayout() {
|
export function RootLayout() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const [sessionOpen, setSessionOpen] = useState(false);
|
const sessionDockOpen = useUiStore((s) => s.sessionDockOpen);
|
||||||
|
const setSessionDock = useUiStore((s) => s.setSessionDock);
|
||||||
const sessionConnected = useSessionStore((s) => s.status === 'connected');
|
const sessionConnected = useSessionStore((s) => s.status === 'connected');
|
||||||
|
const sessionRole = useSessionStore((s) => s.role);
|
||||||
|
const joinIntent = useSessionStore((s) => s.joinIntent);
|
||||||
const activeCampaign = useActiveCampaign();
|
const activeCampaign = useActiveCampaign();
|
||||||
// While the GM is hosting, mirror state to players (inert unless hosting).
|
// While the GM is hosting, mirror state to players (inert unless hosting).
|
||||||
useSessionBroadcaster(activeCampaign ?? null);
|
useSessionBroadcaster(activeCampaign ?? null);
|
||||||
@@ -82,6 +85,15 @@ export function RootLayout() {
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Players get the session panel docked open by default (once) — they live in it.
|
||||||
|
const autoOpenedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoOpenedRef.current && joinIntent && sessionRole === 'player') {
|
||||||
|
autoOpenedRef.current = true;
|
||||||
|
setSessionDock(true);
|
||||||
|
}
|
||||||
|
}, [joinIntent, sessionRole, setSessionDock]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header className="flex items-center gap-4 border-b border-line bg-panel px-4 py-2">
|
<header className="flex items-center gap-4 border-b border-line bg-panel px-4 py-2">
|
||||||
@@ -107,7 +119,7 @@ export function RootLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<PlayerSessionBadge />
|
<PlayerSessionBadge />
|
||||||
<Button size="sm" variant="ghost" onClick={() => setSessionOpen(true)} title="Session panel — who's here, rolls, chat" aria-label="Open session panel">
|
<Button size="sm" variant="ghost" onClick={() => setSessionDock(true)} title="Session panel — who's here, rolls, chat" aria-label="Open session panel">
|
||||||
☰ Session{sessionConnected && <span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-success align-middle" />}
|
☰ Session{sessionConnected && <span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-success align-middle" />}
|
||||||
</Button>
|
</Button>
|
||||||
{activeCampaign && <HandoutControl />}
|
{activeCampaign && <HandoutControl />}
|
||||||
@@ -121,14 +133,16 @@ export function RootLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 overflow-auto">
|
<div className="flex min-h-0 flex-1">
|
||||||
<ErrorBoundary resetKey={pathname}>
|
<main className="flex-1 overflow-auto">
|
||||||
<Outlet />
|
<ErrorBoundary resetKey={pathname}>
|
||||||
</ErrorBoundary>
|
<Outlet />
|
||||||
</main>
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
<SessionSidebar open={sessionDockOpen} onClose={() => setSessionDock(false)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<RollTray />
|
<RollTray />
|
||||||
<SessionSidebar open={sessionOpen} onClose={() => setSessionOpen(false)} />
|
|
||||||
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
const code = role === 'gm' ? joinCode : intent?.joinCode;
|
const code = role === 'gm' ? joinCode : intent?.joinCode;
|
||||||
const inSession = role !== 'off' || !!intent;
|
const inSession = role !== 'off' || !!intent;
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{open && <div className="fixed inset-0 z-40 bg-black/40 print:hidden" onClick={onClose} aria-hidden />}
|
{/* On phones the panel floats over content with a backdrop; on desktop it docks as a column. */}
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/40 md:hidden print:hidden" onClick={onClose} aria-hidden />
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className="fixed right-0 top-0 z-50 flex h-full w-80 max-w-[88vw] flex-col border-l border-line bg-panel shadow-2xl print:hidden md:static md:z-auto md:h-auto md:w-80 md:max-w-none md:shrink-0 md:self-stretch md:shadow-none"
|
||||||
'fixed right-0 top-0 z-50 flex h-full w-80 max-w-[88vw] transform flex-col border-l border-line bg-panel shadow-2xl transition-transform print:hidden',
|
|
||||||
open ? 'translate-x-0' : 'translate-x-full',
|
|
||||||
)}
|
|
||||||
aria-label="Session panel"
|
aria-label="Session panel"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-line p-3">
|
<div className="flex items-center justify-between border-b border-line p-3">
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ interface UiState {
|
|||||||
activeMapId: string | null;
|
activeMapId: string | null;
|
||||||
/** a handout currently shown to players (title/body/optional image) */
|
/** a handout currently shown to players (title/body/optional image) */
|
||||||
activeHandout: Handout | null;
|
activeHandout: Handout | null;
|
||||||
|
/** whether the docked session sidebar is open (persisted) */
|
||||||
|
sessionDockOpen: boolean;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
setActiveCampaign: (id: string | null) => void;
|
setActiveCampaign: (id: string | null) => void;
|
||||||
setActiveEncounter: (id: string | null) => void;
|
setActiveEncounter: (id: string | null) => void;
|
||||||
setActiveMap: (id: string | null) => void;
|
setActiveMap: (id: string | null) => void;
|
||||||
setActiveHandout: (h: Handout | null) => void;
|
setActiveHandout: (h: Handout | null) => void;
|
||||||
|
setSessionDock: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUiStore = create<UiState>()(
|
export const useUiStore = create<UiState>()(
|
||||||
@@ -31,6 +34,7 @@ export const useUiStore = create<UiState>()(
|
|||||||
activeEncounterId: null,
|
activeEncounterId: null,
|
||||||
activeMapId: null,
|
activeMapId: null,
|
||||||
activeHandout: null,
|
activeHandout: null,
|
||||||
|
sessionDockOpen: false,
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
set({ theme });
|
set({ theme });
|
||||||
@@ -40,6 +44,7 @@ export const useUiStore = create<UiState>()(
|
|||||||
setActiveEncounter: (id) => set({ activeEncounterId: id }),
|
setActiveEncounter: (id) => set({ activeEncounterId: id }),
|
||||||
setActiveMap: (id) => set({ activeMapId: id }),
|
setActiveMap: (id) => set({ activeMapId: id }),
|
||||||
setActiveHandout: (activeHandout) => set({ activeHandout }),
|
setActiveHandout: (activeHandout) => set({ activeHandout }),
|
||||||
|
setSessionDock: (sessionDockOpen) => set({ sessionDockOpen }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ttrpg-ui',
|
name: 'ttrpg-ui',
|
||||||
|
|||||||
Reference in New Issue
Block a user