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 { useUiStore } from '@/stores/uiStore';
|
||||
import { CommandPalette } from '@/components/CommandPalette';
|
||||
@@ -63,8 +63,11 @@ function ThemeToggle() {
|
||||
export function RootLayout() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
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 sessionRole = useSessionStore((s) => s.role);
|
||||
const joinIntent = useSessionStore((s) => s.joinIntent);
|
||||
const activeCampaign = useActiveCampaign();
|
||||
// While the GM is hosting, mirror state to players (inert unless hosting).
|
||||
useSessionBroadcaster(activeCampaign ?? null);
|
||||
@@ -82,6 +85,15 @@ export function RootLayout() {
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<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>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<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" />}
|
||||
</Button>
|
||||
{activeCampaign && <HandoutControl />}
|
||||
@@ -121,14 +133,16 @@ export function RootLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<ErrorBoundary resetKey={pathname}>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<main className="flex-1 overflow-auto">
|
||||
<ErrorBoundary resetKey={pathname}>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
<SessionSidebar open={sessionDockOpen} onClose={() => setSessionDock(false)} />
|
||||
</div>
|
||||
|
||||
<RollTray />
|
||||
<SessionSidebar open={sessionOpen} onClose={() => setSessionOpen(false)} />
|
||||
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,14 +18,14 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
||||
const code = role === 'gm' ? joinCode : intent?.joinCode;
|
||||
const inSession = role !== 'off' || !!intent;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
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
|
||||
className={cn(
|
||||
'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',
|
||||
)}
|
||||
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"
|
||||
aria-label="Session panel"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line p-3">
|
||||
|
||||
@@ -15,12 +15,15 @@ interface UiState {
|
||||
activeMapId: string | null;
|
||||
/** a handout currently shown to players (title/body/optional image) */
|
||||
activeHandout: Handout | null;
|
||||
/** whether the docked session sidebar is open (persisted) */
|
||||
sessionDockOpen: boolean;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
setActiveCampaign: (id: string | null) => void;
|
||||
setActiveEncounter: (id: string | null) => void;
|
||||
setActiveMap: (id: string | null) => void;
|
||||
setActiveHandout: (h: Handout | null) => void;
|
||||
setSessionDock: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>()(
|
||||
@@ -31,6 +34,7 @@ export const useUiStore = create<UiState>()(
|
||||
activeEncounterId: null,
|
||||
activeMapId: null,
|
||||
activeHandout: null,
|
||||
sessionDockOpen: false,
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
@@ -40,6 +44,7 @@ export const useUiStore = create<UiState>()(
|
||||
setActiveEncounter: (id) => set({ activeEncounterId: id }),
|
||||
setActiveMap: (id) => set({ activeMapId: id }),
|
||||
setActiveHandout: (activeHandout) => set({ activeHandout }),
|
||||
setSessionDock: (sessionDockOpen) => set({ sessionDockOpen }),
|
||||
}),
|
||||
{
|
||||
name: 'ttrpg-ui',
|
||||
|
||||
Reference in New Issue
Block a user