feat(04-01): wire BrowserRouter + BottomTabBar + empty Lists surface
- App.tsx: BrowserRouter with /calendar, /lists, /lists/:listId routes; / redirects to /calendar - BottomTabBar.tsx: fixed-bottom 56px tab bar with Calendar + Lists NavLinks, active accent - AppNav.tsx: add Calendar/Lists NavLinks to desktop sidebar (≥768px) - ListsIndex.tsx: full-height surface with isLoading/isError/empty state branches; "+ New List" FAB placeholder - ListDetail.tsx: placeholder stub for /lists/:listId (Plan 04-04 fills in) - listsStore.ts: Zustand UI-only store (activeTab, createListSheetOpen) - listsClient.ts: fetchLists + List/ListItem types (initial; Plans 04-02/03 expand) - Fix Wave-0 RED stubs: add vitest imports so stubs execute (todo) not error on import - Fix CalendarShell.test.tsx: wrap renderWithClient in MemoryRouter (AppNav uses NavLink)
This commit is contained in:
+34
-1
@@ -1,5 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* App — BrowserRouter shell with react-router declarative routing.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* / → redirect to /calendar
|
||||||
|
* /calendar → CalendarShell
|
||||||
|
* /lists → ListsIndex
|
||||||
|
* /lists/:listId → ListDetail (placeholder for Plan 04-04)
|
||||||
|
*
|
||||||
|
* BottomTabBar is rendered as a sibling of <Routes> so it persists across route
|
||||||
|
* changes. On desktop (≥768px) AppNav sidebar handles navigation — BottomTabBar
|
||||||
|
* is only visible on phone via CSS, but we render it in the tree at all sizes so
|
||||||
|
* the tab state remains consistent.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
||||||
import { CalendarShell } from './components/CalendarShell.js'
|
import { CalendarShell } from './components/CalendarShell.js'
|
||||||
|
import { ListsIndex } from './routes/ListsIndex.js'
|
||||||
|
import { ListDetail } from './routes/ListDetail.js'
|
||||||
|
import { BottomTabBar } from './components/BottomTabBar.js'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <CalendarShell />
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<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>
|
||||||
|
<BottomTabBar />
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Typed API client for the FamilySync lists API.
|
||||||
|
*
|
||||||
|
* credentials: 'include' is required so the OIDC session cookie is sent with
|
||||||
|
* every request (same pattern as client.ts).
|
||||||
|
*
|
||||||
|
* This file provides only the functions needed in Plan 04-01 (ListsIndex empty
|
||||||
|
* state). Plans 04-02 and 04-03 expand it with createList, deleteList, item CRUD.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface List {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
isShared: boolean
|
||||||
|
ownerId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItem {
|
||||||
|
id: number
|
||||||
|
listId: number
|
||||||
|
text: string
|
||||||
|
checked: boolean
|
||||||
|
rank: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListsResponse {
|
||||||
|
lists: List[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Functions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchLists(): Promise<ListsResponse> {
|
||||||
|
return apiFetch('/lists').then((r) => r.json() as Promise<ListsResponse>)
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
* - Grid is primary focal point; AppNav is secondary chrome
|
* - Grid is primary focal point; AppNav is secondary chrome
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { NavLink } from 'react-router'
|
||||||
|
import { CalendarDays, List } from 'lucide-react'
|
||||||
import { ColorLegend, type LegendMember } from './ColorLegend.js'
|
import { ColorLegend, type LegendMember } from './ColorLegend.js'
|
||||||
|
|
||||||
interface AppNavProps {
|
interface AppNavProps {
|
||||||
@@ -102,8 +104,23 @@ function PhoneNav({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tablet/Desktop: 240px left sidebar — app name + color legend */
|
/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend */
|
||||||
function DesktopNav({ members }: { members: LegendMember[] }) {
|
function DesktopNav({ members }: { members: LegendMember[] }) {
|
||||||
|
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: 'var(--space-2) var(--space-3, 12px)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
color: isActive ? 'var(--color-member-0)' : 'var(--color-text-primary)',
|
||||||
|
background: isActive ? 'color-mix(in srgb, var(--color-member-0) 10%, transparent)' : 'transparent',
|
||||||
|
minHeight: '44px',
|
||||||
|
transition: 'color 0.1s ease, background 0.1s ease',
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
style={{
|
style={{
|
||||||
@@ -133,6 +150,25 @@ function DesktopNav({ members }: { members: LegendMember[] }) {
|
|||||||
FamilySync
|
FamilySync
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Section nav links */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-1, 4px)',
|
||||||
|
marginBottom: 'var(--space-6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavLink to="/calendar" style={navLinkStyle} aria-label="Calendar">
|
||||||
|
<CalendarDays size={18} aria-hidden="true" />
|
||||||
|
Calendar
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/lists" style={navLinkStyle} aria-label="Lists">
|
||||||
|
<List size={18} aria-hidden="true" />
|
||||||
|
Lists
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Color legend */}
|
{/* Color legend */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* BottomTabBar — fixed-bottom navigation for phone (≤767px).
|
||||||
|
*
|
||||||
|
* UI-SPEC §BottomTabBar:
|
||||||
|
* - Fixed bottom, full-width, 56px + env(safe-area-inset-bottom)
|
||||||
|
* - Background: var(--color-surface-dim)
|
||||||
|
* - Border-top: 1px solid var(--color-border)
|
||||||
|
* - Two equal tabs: Calendar (CalendarDays icon) and Lists (List icon)
|
||||||
|
* - Active tab: icon + label in var(--color-member-0), 2px border-bottom indicator
|
||||||
|
* - Inactive tab: icon + label in var(--color-text-muted)
|
||||||
|
* - Touch target: min 44px guaranteed by 56px bar height
|
||||||
|
* - z-index: 200 (below dialogs at 300)
|
||||||
|
*
|
||||||
|
* Uses react-router NavLink for real-URL active detection (D-17).
|
||||||
|
* Only rendered on phone — DesktopNav handles desktop navigation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NavLink } from 'react-router'
|
||||||
|
import { CalendarDays, List } from 'lucide-react'
|
||||||
|
|
||||||
|
const tabBase: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '3px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 'var(--text-label-line-height, 1.4)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
minHeight: '44px',
|
||||||
|
borderBottom: '2px solid transparent',
|
||||||
|
transition: 'color 0.1s ease, border-color 0.1s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitTapHighlightColor: 'transparent',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabActiveOverride: React.CSSProperties = {
|
||||||
|
color: 'var(--color-member-0)',
|
||||||
|
borderBottom: '2px solid var(--color-member-0)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomTabBar() {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Main navigation"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
|
display: 'flex',
|
||||||
|
background: 'var(--color-surface-dim)',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
to="/calendar"
|
||||||
|
aria-label="Calendar"
|
||||||
|
style={({ isActive }) => ({
|
||||||
|
...tabBase,
|
||||||
|
...(isActive ? tabActiveOverride : {}),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<CalendarDays size={22} aria-hidden="true" />
|
||||||
|
<span>Calendar</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/lists"
|
||||||
|
aria-label="Lists"
|
||||||
|
style={({ isActive }) => ({
|
||||||
|
...tabBase,
|
||||||
|
...(isActive ? tabActiveOverride : {}),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<List size={22} aria-hidden="true" />
|
||||||
|
<span>Lists</span>
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import React from 'react'
|
|||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router'
|
||||||
|
|
||||||
// window.matchMedia is polyfilled in src/test-setup.ts (loaded via vitest.config setupFiles)
|
// window.matchMedia is polyfilled in src/test-setup.ts (loaded via vitest.config setupFiles)
|
||||||
|
|
||||||
@@ -128,7 +129,9 @@ function makeQueryClient() {
|
|||||||
function renderWithClient(ui: React.ReactElement) {
|
function renderWithClient(ui: React.ReactElement) {
|
||||||
const client = makeQueryClient()
|
const client = makeQueryClient()
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={client}>{ui}</QueryClientProvider>,
|
<MemoryRouter initialEntries={['/calendar']}>
|
||||||
|
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
* Run: pnpm --filter @familysync/pwa test
|
* Run: pnpm --filter @familysync/pwa test
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'vitest'
|
||||||
|
|
||||||
describe('useListSSE — D-11 bounded backoff', () => {
|
describe('useListSSE — D-11 bounded backoff', () => {
|
||||||
it.todo('starts connected when EventSource fires the open event')
|
it.todo('starts connected when EventSource fires the open event')
|
||||||
it.todo('attempts to reconnect with exponential backoff on error')
|
it.todo('attempts to reconnect with exponential backoff on error')
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
* Run: pnpm --filter @familysync/pwa test
|
* Run: pnpm --filter @familysync/pwa test
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'vitest'
|
||||||
|
|
||||||
describe('ListDetail — D-07 optimistic update + rollback', () => {
|
describe('ListDetail — D-07 optimistic update + rollback', () => {
|
||||||
it.todo('checking an item immediately updates the UI before server responds')
|
it.todo('checking an item immediately updates the UI before server responds')
|
||||||
it.todo('unchecking an item immediately updates the UI before server responds')
|
it.todo('unchecking an item immediately updates the UI before server responds')
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* ListDetail — Single list view (/lists/:listId).
|
||||||
|
*
|
||||||
|
* PLACEHOLDER: This is a stub route component for Plan 04-01.
|
||||||
|
* The full implementation (items, SSE, drag-to-reorder, etc.) is built in Plan 04-04.
|
||||||
|
*
|
||||||
|
* Purpose: allows react-router's /lists/:listId route to resolve without error
|
||||||
|
* so the BottomTabBar NavLink and any deep-link won't 404.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function ListDetail() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
gap: '12px',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
padding: '0 var(--space-4, 16px)',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-heading-size, 18px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
List view coming soon
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Full list detail with live sync is being built in Phase 4 Plan 4.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* ListsIndex — Lists overview route (/lists).
|
||||||
|
*
|
||||||
|
* UI-SPEC §ListsIndex:
|
||||||
|
* - Full-height scrollable column with 16px horizontal padding
|
||||||
|
* - "Lists" heading
|
||||||
|
* - FAB ("+ New List") — fixed bottom-right on phone, top-right inline on desktop
|
||||||
|
* - Empty state: ListsEmptyState when no lists
|
||||||
|
* - State branches: isLoading → skeleton, isError → error+retry, data → list cards
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* TanStack Query ['lists'] → fetchLists → render list or empty state
|
||||||
|
*
|
||||||
|
* Note: Create + delete logic landed in Plans 04-02/04-03.
|
||||||
|
* The FAB is a non-functional placeholder here (Plan 04-03 wires CreateListSheet).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { fetchLists, type List } from '../api/listsClient.js'
|
||||||
|
|
||||||
|
// ── Empty State ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ListsEmptyState() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 1,
|
||||||
|
padding: '48px var(--space-4)',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-heading-size, 18px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No lists yet
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
lineHeight: 'var(--text-body-line-height, 1.5)',
|
||||||
|
maxWidth: '280px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tap + to create your first shared list…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List Card (placeholder for Plan 04-03 full implementation) ─────────────
|
||||||
|
|
||||||
|
function ListCard({ list }: { list: List }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: 'var(--space-4) var(--space-6, 24px)',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||||
|
minHeight: '56px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-heading-size, 18px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{list.name}
|
||||||
|
</div>
|
||||||
|
{list.isShared && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '4px',
|
||||||
|
display: 'inline-block',
|
||||||
|
background: 'var(--color-surface-dim)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 400,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Shared
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ListsIndex() {
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['lists'],
|
||||||
|
queryFn: fetchLists,
|
||||||
|
retry: 2,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const lists = data?.lists ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0,
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
// Bottom padding to clear the 56px fixed tab bar on phone
|
||||||
|
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 'var(--space-6, 24px) var(--space-4, 16px) var(--space-4, 16px)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--text-display-size, 24px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
lineHeight: 'var(--text-display-line-height, 1.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Lists
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* FAB / inline button — non-functional placeholder until Plan 04-03 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="New list"
|
||||||
|
style={{
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--color-member-0)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={22} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '0 var(--space-4, 16px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-8, 32px) 0',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading lists…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{isError && !isLoading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-8, 32px) 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-destructive)',
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Could not load lists
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-member-0)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
padding: 'var(--space-2, 8px) var(--space-4, 16px)',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: '44px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && !isError && lists.length === 0 && <ListsEmptyState />}
|
||||||
|
|
||||||
|
{/* List cards */}
|
||||||
|
{!isLoading && !isError && lists.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-4, 16px)',
|
||||||
|
paddingBottom: 'var(--space-4, 16px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lists.map((list) => (
|
||||||
|
<ListCard key={list.id} list={list} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Zustand UI-state store for the lists surface.
|
||||||
|
*
|
||||||
|
* Owns ONLY UI-shape state — no server data ever enters this store.
|
||||||
|
* Server state (lists, items) lives in TanStack Query.
|
||||||
|
*
|
||||||
|
* Convention: follows calendarStore.ts pattern — no persist middleware,
|
||||||
|
* no immer. State is ephemeral; lists UI state does not need persistence.
|
||||||
|
*
|
||||||
|
* State contract:
|
||||||
|
* activeTab — which bottom-tab is selected (drives tab active-state)
|
||||||
|
* createListSheetOpen — whether the "New List" sheet/dialog is open
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ListsStore {
|
||||||
|
// UI-only state — no server data
|
||||||
|
activeTab: 'calendar' | 'lists'
|
||||||
|
createListSheetOpen: boolean
|
||||||
|
|
||||||
|
setActiveTab: (tab: 'calendar' | 'lists') => void
|
||||||
|
setCreateListSheetOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const useListsStore = create<ListsStore>()((set) => ({
|
||||||
|
activeTab: 'calendar',
|
||||||
|
createListSheetOpen: false,
|
||||||
|
|
||||||
|
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||||
|
setCreateListSheetOpen: (open) => set({ createListSheetOpen: open }),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user