Files
familysync/apps/pwa/src/App.tsx
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

142 lines
5.4 KiB
TypeScript

/**
* App — BrowserRouter shell with react-router declarative routing.
*
* Routes:
* / → redirect to /calendar
* /calendar → CalendarShell
* /lists → ListsIndex
* /lists/:listId → ListDetail (placeholder for Plan 04-04)
*
* Layout:
* AppNav is rendered as a PERSISTENT sibling of <Routes> (outside any Route),
* so it survives route transitions (FIX 3). AppNav provides:
* - 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
* /lists/* — the SW denylist only excludes /callback, /api/, and /health, so
* /lists/* is served from cache correctly.
*
* Phase 5 additions:
* - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10)
* - 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, useMemo } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { CalendarShell } from './components/CalendarShell.js';
import { ListsIndex } from './routes/ListsIndex.js';
import { ListDetail } from './routes/ListDetail.js';
import { BottomTabBar } from './components/BottomTabBar.js';
import { AppNav } from './components/AppNav.js';
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.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() {
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 (
<BrowserRouter>
{/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
<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>
<Route path="/" element={<Navigate to="/calendar" replace />} />
<Route path="/calendar" element={<CalendarShell />} />
<Route path="/lists" element={<ListsIndex />} />
<Route path="/lists/:listId" element={<ListDetail />} />
</Routes>
</div>
</div>
{/* BottomTabBar — phone-only (hidden ≥768px via CSS, FIX 4) */}
<BottomTabBar />
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
and Notification.permission === 'default' and not dismissed */}
<PushPermissionPrompt />
{/* Settings sheet — master notifications toggle (D-09), opened from avatar */}
<SettingsSheet isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
</BrowserRouter>
);
}