fix(06): make AppNav persistent across routes so Lists keeps the nav
- Lift AppNav from CalendarShell to App.tsx as a sibling of <Routes> - App.tsx fetches /api/me (same query key as CalendarShell — deduplicated by TanStack Query) - App.tsx provides the outer layout (phone: column, desktop: row) with AppNav always rendered - CalendarShell simplified: no longer manages AppNav, outer flex layout stays in App.tsx - AuthSplash gains overlay prop (position:fixed inset:0 z-index:999) so it covers AppNav when needed - CalendarShell uses AuthSplash with overlay=true so auth splashes cover full viewport - Remove onOpenSettings prop from CalendarShell (wired directly in App.tsx to SettingsSheet) - Desktop sidebar nav (FamilySync brand, Calendar/Lists links) now persists on /lists route
This commit is contained in:
+90
-6
@@ -7,10 +7,21 @@
|
|||||||
* /lists → ListsIndex
|
* /lists → ListsIndex
|
||||||
* /lists/:listId → ListDetail (placeholder for Plan 04-04)
|
* /lists/:listId → ListDetail (placeholder for Plan 04-04)
|
||||||
*
|
*
|
||||||
* BottomTabBar is rendered as a sibling of <Routes> so it persists across route
|
* Layout:
|
||||||
* changes. On desktop (≥768px) AppNav sidebar handles navigation — BottomTabBar
|
* AppNav is rendered as a PERSISTENT sibling of <Routes> (outside any Route),
|
||||||
* is only visible on phone via CSS, but we render it in the tree at all sizes so
|
* so it survives route transitions (FIX 3). AppNav provides:
|
||||||
* the tab state remains consistent.
|
* - Phone (≤767px): 48px top bar (the header above the main content area)
|
||||||
|
* - Desktop (≥768px): 240px left sidebar with nav links + colour legend
|
||||||
|
*
|
||||||
|
* BottomTabBar is also a sibling of <Routes> so the tab state remains consistent.
|
||||||
|
* On desktop (≥768px) BottomTabBar is hidden via CSS (FIX 4).
|
||||||
|
*
|
||||||
|
* Auth flow:
|
||||||
|
* /api/me is fetched once at the App level. While loading or on error the
|
||||||
|
* full-screen AuthSplash overlay is shown (CalendarShell's AuthSplash is
|
||||||
|
* positioned fixed with z-index so it covers AppNav too). AppNav renders
|
||||||
|
* with empty / partial data until auth resolves — this is fine because the
|
||||||
|
* AuthSplash overlay hides the AppNav during that window.
|
||||||
*
|
*
|
||||||
* navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to
|
* navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to
|
||||||
* /lists/* — the SW denylist only excludes /callback, /api/, and /health, so
|
* /lists/* — the SW denylist only excludes /callback, /api/, and /health, so
|
||||||
@@ -19,36 +30,109 @@
|
|||||||
* Phase 5 additions:
|
* Phase 5 additions:
|
||||||
* - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10)
|
* - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10)
|
||||||
* - SettingsSheet: avatar-triggered bottom sheet with master notifications toggle (D-09)
|
* - SettingsSheet: avatar-triggered bottom sheet with master notifications toggle (D-09)
|
||||||
|
*
|
||||||
|
* Phase 6 fix (FIX 3):
|
||||||
|
* - AppNav lifted from CalendarShell to this level so /lists keeps the nav chrome.
|
||||||
|
* - /api/me query shared so AppNav has user data on all routes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { CalendarShell } from './components/CalendarShell.js'
|
import { CalendarShell } from './components/CalendarShell.js'
|
||||||
import { ListsIndex } from './routes/ListsIndex.js'
|
import { ListsIndex } from './routes/ListsIndex.js'
|
||||||
import { ListDetail } from './routes/ListDetail.js'
|
import { ListDetail } from './routes/ListDetail.js'
|
||||||
import { BottomTabBar } from './components/BottomTabBar.js'
|
import { BottomTabBar } from './components/BottomTabBar.js'
|
||||||
|
import { AppNav } from './components/AppNav.js'
|
||||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'
|
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'
|
||||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'
|
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'
|
||||||
import { SettingsSheet } from './components/SettingsSheet.js'
|
import { SettingsSheet } from './components/SettingsSheet.js'
|
||||||
|
import { fetchMe } from './api/client.js'
|
||||||
|
|
||||||
|
function isPhone(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const phone = isPhone()
|
||||||
|
|
||||||
|
// Fetch current user once at the app shell level so AppNav has member data on
|
||||||
|
// ALL routes. This is the same query key (['me']) used by CalendarShell, so
|
||||||
|
// TanStack Query deduplicates the request — no double fetch.
|
||||||
|
const meQuery = useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Derive members for AppNav from the shared /api/me response
|
||||||
|
const members = useMemo(() => {
|
||||||
|
if (!meQuery.data?.user) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: String(meQuery.data.user.id),
|
||||||
|
name: meQuery.data.user.displayName ?? 'Member',
|
||||||
|
color: meQuery.data.user.color,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [meQuery.data])
|
||||||
|
|
||||||
|
// Outer layout: phone = column, desktop = row (AppNav sidebar + content)
|
||||||
|
const outerStyle: React.CSSProperties = {
|
||||||
|
height: '100dvh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: phone ? 'column' : 'row',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content area: fills remaining space next to / below AppNav
|
||||||
|
const contentStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
{/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
|
{/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
|
||||||
<PermissionDeniedBanner />
|
<PermissionDeniedBanner />
|
||||||
|
|
||||||
|
<div style={outerStyle}>
|
||||||
|
{/* Persistent AppNav — phone: top bar; desktop: left sidebar.
|
||||||
|
Renders on ALL routes so nav chrome survives route transitions (FIX 3). */}
|
||||||
|
<AppNav
|
||||||
|
members={members}
|
||||||
|
currentUserColor={meQuery.data?.user.color}
|
||||||
|
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content area — all routes render here */}
|
||||||
|
<div style={contentStyle}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/calendar"
|
path="/calendar"
|
||||||
element={<CalendarShell onOpenSettings={() => setSettingsOpen(true)} />}
|
element={<CalendarShell />}
|
||||||
/>
|
/>
|
||||||
<Route path="/lists" element={<ListsIndex />} />
|
<Route path="/lists" element={<ListsIndex />} />
|
||||||
<Route path="/lists/:listId" element={<ListDetail />} />
|
<Route path="/lists/:listId" element={<ListDetail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BottomTabBar — phone-only (hidden ≥768px via CSS, FIX 4) */}
|
||||||
<BottomTabBar />
|
<BottomTabBar />
|
||||||
|
|
||||||
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||||
and Notification.permission === 'default' and not dismissed */}
|
and Notification.permission === 'default' and not dismissed */}
|
||||||
<PushPermissionPrompt />
|
<PushPermissionPrompt />
|
||||||
|
|||||||
@@ -38,12 +38,20 @@ export interface AuthSplashProps {
|
|||||||
* Defaults to "Taking you to the sign-in page…" (cold-load copy per UI-SPEC §Surface 1).
|
* Defaults to "Taking you to the sign-in page…" (cold-load copy per UI-SPEC §Surface 1).
|
||||||
*/
|
*/
|
||||||
body?: string
|
body?: string
|
||||||
|
/**
|
||||||
|
* When true, renders with position:fixed inset:0 z-index:999 so the splash
|
||||||
|
* covers the entire viewport including the persistent AppNav (FIX 3).
|
||||||
|
* Use this when CalendarShell (a child of the app shell) needs to show an
|
||||||
|
* auth interstitial that hides the nav chrome.
|
||||||
|
*/
|
||||||
|
overlay?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthSplash({
|
export function AuthSplash({
|
||||||
state,
|
state,
|
||||||
heading = 'Signing you in',
|
heading = 'Signing you in',
|
||||||
body = 'Taking you to the sign-in page…',
|
body = 'Taking you to the sign-in page…',
|
||||||
|
overlay = false,
|
||||||
}: AuthSplashProps) {
|
}: AuthSplashProps) {
|
||||||
const isDeadEnd = state === 'dead-end'
|
const isDeadEnd = state === 'dead-end'
|
||||||
const showSpinner = !isDeadEnd
|
const showSpinner = !isDeadEnd
|
||||||
@@ -53,7 +61,9 @@ export function AuthSplash({
|
|||||||
role="status"
|
role="status"
|
||||||
aria-label="Signing you in"
|
aria-label="Signing you in"
|
||||||
style={{
|
style={{
|
||||||
height: '100dvh',
|
...(overlay
|
||||||
|
? { position: 'fixed', inset: 0, zIndex: 999 }
|
||||||
|
: { height: '100dvh' }),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -17,9 +17,10 @@
|
|||||||
* - Navigation: Schedule-X's built-in header is the sole navigation bar (Today/‹›/view switcher
|
* - Navigation: Schedule-X's built-in header is the sole navigation bar (Today/‹›/view switcher
|
||||||
* + weekday-name row). The custom ViewToolbar and calendar-controls plugin have been removed.
|
* + weekday-name row). The custom ViewToolbar and calendar-controls plugin have been removed.
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout (FIX 3):
|
||||||
* - Phone (≤767px): AppNav top bar → Schedule-X (header + grid)
|
* AppNav has been lifted to App.tsx and is no longer rendered here. CalendarShell fills
|
||||||
* - Tablet/Desktop (≥768px): AppNav left sidebar (240px) + main area (Schedule-X header + grid)
|
* its parent container (a flex:1 area in App.tsx). Auth splashes use position:fixed to
|
||||||
|
* overlay everything including AppNav.
|
||||||
*
|
*
|
||||||
* State branches:
|
* State branches:
|
||||||
* isLoading (initial) → SkeletonCalendar
|
* isLoading (initial) → SkeletonCalendar
|
||||||
@@ -51,7 +52,6 @@ import { EventDetailPopover } from './EventDetailPopover.js'
|
|||||||
import { EventForm } from './EventForm.js'
|
import { EventForm } from './EventForm.js'
|
||||||
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'
|
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'
|
||||||
import { SyncStateToast } from './SyncStateToast.js'
|
import { SyncStateToast } from './SyncStateToast.js'
|
||||||
import { AppNav } from './AppNav.js'
|
|
||||||
import { ColorLegend } from './ColorLegend.js'
|
import { ColorLegend } from './ColorLegend.js'
|
||||||
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
||||||
import { InstallPrompt } from './InstallPrompt.js'
|
import { InstallPrompt } from './InstallPrompt.js'
|
||||||
@@ -73,7 +73,7 @@ function isPhone(): boolean {
|
|||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void } = {}) {
|
export function CalendarShell() {
|
||||||
// Use per-field selectors so CalendarShell does NOT subscribe to openEventId.
|
// Use per-field selectors so CalendarShell does NOT subscribe to openEventId.
|
||||||
// Without selectors, any popover open/close triggers a full re-render here,
|
// Without selectors, any popover open/close triggers a full re-render here,
|
||||||
// which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B).
|
// which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B).
|
||||||
@@ -93,7 +93,8 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
// AuthSplash shows the tap-to-retry recovery instead of spinning forever (D-11).
|
// AuthSplash shows the tap-to-retry recovery instead of spinning forever (D-11).
|
||||||
const [loginRedirectExhausted, setLoginRedirectExhausted] = useState(false)
|
const [loginRedirectExhausted, setLoginRedirectExhausted] = useState(false)
|
||||||
|
|
||||||
// Fetch current user to build per-member color config
|
// Fetch current user to build per-member color config.
|
||||||
|
// Same query key (['me']) as App.tsx — TanStack Query deduplicates, no double fetch.
|
||||||
const meQuery = useQuery({
|
const meQuery = useQuery({
|
||||||
queryKey: ['me'],
|
queryKey: ['me'],
|
||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
@@ -124,7 +125,7 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
// Create plugins once (stable across renders)
|
// Create plugins once (stable across renders)
|
||||||
const eventsService = useState(() => createEventsServicePlugin())[0]
|
const eventsService = useState(() => createEventsServicePlugin())[0]
|
||||||
|
|
||||||
// Build members list from /api/me for AppNav + ColorLegend
|
// Build members list from /api/me for ColorLegend (AppNav uses the same data from App.tsx)
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
if (!meQuery.data?.user) return []
|
if (!meQuery.data?.user) return []
|
||||||
return [
|
return [
|
||||||
@@ -255,6 +256,9 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
// Gate the calendar render on auth state so no calendar shell, skeleton, or
|
// Gate the calendar render on auth state so no calendar shell, skeleton, or
|
||||||
// "Sign-in required" alert paints before Authelia (D-10 success criterion 5).
|
// "Sign-in required" alert paints before Authelia (D-10 success criterion 5).
|
||||||
//
|
//
|
||||||
|
// Auth splashes use position:fixed (inset:0, z-index:999) to overlay the entire
|
||||||
|
// viewport including the persistent AppNav in App.tsx (FIX 3).
|
||||||
|
//
|
||||||
// loading: meQuery is still pending — show the neutral "Signing you in" splash.
|
// loading: meQuery is still pending — show the neutral "Signing you in" splash.
|
||||||
// No skeleton, no CalendarContent — nothing app-specific until authed.
|
// No skeleton, no CalendarContent — nothing app-specific until authed.
|
||||||
// isError: meQuery failed — the maybeRedirectToLogin() useEffect fires immediately
|
// isError: meQuery failed — the maybeRedirectToLogin() useEffect fires immediately
|
||||||
@@ -263,18 +267,18 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
// returns false and this splash stays — the dead-end "Tap to try again" is
|
// returns false and this splash stays — the dead-end "Tap to try again" is
|
||||||
// rendered by AuthSplash's dead-end state (wired in Task 3 via sessionExpired).
|
// rendered by AuthSplash's dead-end state (wired in Task 3 via sessionExpired).
|
||||||
if (meQuery.isLoading) {
|
if (meQuery.isLoading) {
|
||||||
return <AuthSplash state="loading" />
|
return <AuthSplash state="loading" overlay />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meQuery.isError) {
|
if (meQuery.isError) {
|
||||||
// loginRedirectExhausted: the one-shot guard was already set (prior navigation),
|
// loginRedirectExhausted: the one-shot guard was already set (prior navigation),
|
||||||
// so maybeRedirectToLogin() returned false — show dead-end tap-to-retry (D-11).
|
// so maybeRedirectToLogin() returned false — show dead-end tap-to-retry (D-11).
|
||||||
// Otherwise the useEffect at line 196 already fired maybeRedirectToLogin() and
|
// Otherwise the useEffect above already fired maybeRedirectToLogin() and
|
||||||
// the browser is navigating — show the redirecting splash while it does.
|
// the browser is navigating — show the redirecting splash while it does.
|
||||||
if (loginRedirectExhausted) {
|
if (loginRedirectExhausted) {
|
||||||
return <AuthSplash state="dead-end" />
|
return <AuthSplash state="dead-end" overlay />
|
||||||
}
|
}
|
||||||
return <AuthSplash state="redirecting" />
|
return <AuthSplash state="redirecting" overlay />
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session-expiry interstitial (D-11) ─────────────────────────────────────
|
// ── Session-expiry interstitial (D-11) ─────────────────────────────────────
|
||||||
@@ -287,13 +291,14 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
state="redirecting"
|
state="redirecting"
|
||||||
heading="Session expired"
|
heading="Session expired"
|
||||||
body="Signing you back in…"
|
body="Signing you back in…"
|
||||||
|
overlay
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Calendar content ───────────────────────────────────────────────────────
|
// ── Calendar content ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// The content panel (right of sidebar on desktop, full-width on phone).
|
// The content panel fills its parent container (a flex:1 area set by App.tsx).
|
||||||
//
|
//
|
||||||
// This is a plain JSX value, NOT a nested `function CalendarContent()` rendered
|
// This is a plain JSX value, NOT a nested `function CalendarContent()` rendered
|
||||||
// as `<CalendarContent />`. A component defined inside render has a new identity
|
// as `<CalendarContent />`. A component defined inside render has a new identity
|
||||||
@@ -395,98 +400,28 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Full layout ────────────────────────────────────────────────────────────
|
// ── Layout ─────────────────────────────────────────────────────────────────
|
||||||
|
// CalendarShell fills its container (App.tsx provides the outer layout with AppNav).
|
||||||
|
// Both phone and desktop use the same flex column structure here since AppNav is
|
||||||
|
// now outside this component.
|
||||||
|
|
||||||
if (phone) {
|
|
||||||
// Phone: stacked layout — AppNav top bar + content below + FAB
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '100dvh',
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
fontFamily: 'var(--font-family-base)',
|
fontFamily: 'var(--font-family-base)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
height: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppNav
|
|
||||||
members={members}
|
|
||||||
currentUserColor={meQuery.data?.user.color}
|
|
||||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
/>
|
|
||||||
<InstallPrompt />
|
|
||||||
{calendarContent}
|
|
||||||
<EventDetailPopover />
|
|
||||||
|
|
||||||
{/* New Event FAB — phone: bottom-right floating action button (UI-SPEC §Interaction Contract) */}
|
|
||||||
<button
|
|
||||||
aria-label="New Event"
|
|
||||||
onClick={() => setEventForm(true, 'create')}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 'var(--space-6)',
|
|
||||||
right: 'var(--space-6)',
|
|
||||||
width: '56px',
|
|
||||||
height: '56px',
|
|
||||||
minWidth: '56px',
|
|
||||||
minHeight: '56px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'var(--color-text-primary)',
|
|
||||||
color: '#ffffff',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
|
||||||
zIndex: 100,
|
|
||||||
fontFamily: 'var(--font-family-base)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={24} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
|
||||||
{eventFormOpen && <EventForm />}
|
|
||||||
|
|
||||||
{/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */}
|
|
||||||
<DeleteConfirmationDialog />
|
|
||||||
|
|
||||||
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
|
||||||
<SyncStateToast />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tablet/Desktop: sidebar + main area
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100dvh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
fontFamily: 'var(--font-family-base)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Left sidebar: AppNav (includes ColorLegend on desktop) */}
|
|
||||||
<AppNav
|
|
||||||
members={members}
|
|
||||||
currentUserColor={meQuery.data?.user.color}
|
|
||||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
|
||||||
{/* New Event toolbar button — tablet/desktop (UI-SPEC §Interaction Contract) */}
|
{/* New Event toolbar button — desktop only (phone uses FAB below) */}
|
||||||
|
{!phone && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -520,9 +455,39 @@ export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void
|
|||||||
New Event
|
New Event
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{calendarContent}
|
{calendarContent}
|
||||||
</div>
|
|
||||||
|
{/* Phone: New Event FAB — bottom-right floating action button (UI-SPEC §Interaction Contract) */}
|
||||||
|
{phone && (
|
||||||
|
<button
|
||||||
|
aria-label="New Event"
|
||||||
|
onClick={() => setEventForm(true, 'create')}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'var(--space-6)',
|
||||||
|
right: 'var(--space-6)',
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
minWidth: '56px',
|
||||||
|
minHeight: '56px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--color-text-primary)',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
||||||
|
zIndex: 100,
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={24} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
||||||
<EventDetailPopover />
|
<EventDetailPopover />
|
||||||
|
|||||||
Reference in New Issue
Block a user