diff --git a/apps/pwa/src/api/listsClient.ts b/apps/pwa/src/api/listsClient.ts index fbd2b57..834762c 100644 --- a/apps/pwa/src/api/listsClient.ts +++ b/apps/pwa/src/api/listsClient.ts @@ -4,8 +4,8 @@ * 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. + * Exports: fetchLists, createList, patchList, deleteList + * Types: List, ListItem, ListsResponse */ const BASE = '/api' @@ -26,6 +26,10 @@ export interface List { name: string isShared: boolean ownerId: number + activeCount: number + doneCount: number + createdAt?: string + updatedAt?: string } export interface ListItem { @@ -45,3 +49,31 @@ export interface ListsResponse { export async function fetchLists(): Promise { return apiFetch('/lists').then((r) => r.json() as Promise) } + +export async function createList(payload: { + name: string + isShared: boolean +}): Promise { + return apiFetch('/lists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then((r) => r.json() as Promise) +} + +export async function patchList( + id: number, + patch: { name?: string; isShared?: boolean }, +): Promise { + return apiFetch(`/lists/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }).then((r) => r.json() as Promise) +} + +export async function deleteList(id: number): Promise<{ id: number }> { + return apiFetch(`/lists/${id}`, { + method: 'DELETE', + }).then((r) => r.json() as Promise<{ id: number }>) +} diff --git a/apps/pwa/src/components/CreateListSheet.tsx b/apps/pwa/src/components/CreateListSheet.tsx new file mode 100644 index 0000000..8594e84 --- /dev/null +++ b/apps/pwa/src/components/CreateListSheet.tsx @@ -0,0 +1,336 @@ +/** + * CreateListSheet — new-list creation form (LIST-01, D-01). + * + * UI-SPEC §CreateListSheet: + * - Mobile: bottom sheet (slides up from bottom, 50vh height, backdrop overlay) + * - Desktop: centered modal (max-width 360px) + * - Heading: "New list" + * - Name input: auto-focused, placeholder "e.g. Groceries" + * - Shared/Private toggle: "Shared" default (D-01), clearly labeled + * - "Create" button: accent var(--color-member-0), disabled while name empty, + * destructive border on blank-submit attempt + * - "Cancel": ghost text button + * - Escape to close, backdrop click to close + * + * Behavior: + * - Open/close driven by listsStore.createListSheetOpen + * - useMutation(createList) with optimistic insert + onError rollback + onSettled invalidate + * + * T-04-06 XSS guard: all text is static or plain-text JSX children (no dangerouslySetInnerHTML). + */ + +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createList } from '../api/listsClient.js' +import type { List, ListsResponse } from '../api/listsClient.js' +import { useListsStore } from '../store/listsStore.js' + +export function CreateListSheet() { + const isOpen = useListsStore((s) => s.createListSheetOpen) + const setOpen = useListsStore((s) => s.setCreateListSheetOpen) + const queryClient = useQueryClient() + + const [name, setName] = useState('') + const [isShared, setIsShared] = useState(true) // D-01: default shared + const [attemptedEmpty, setAttemptedEmpty] = useState(false) + const inputRef = useRef(null) + + // Auto-focus name input when sheet opens + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus() + // Reset form state on open + setName('') + setIsShared(true) + setAttemptedEmpty(false) + } + }, [isOpen]) + + // Escape key listener + useEffect(() => { + if (!isOpen) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose() + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleClose = () => { + setOpen(false) + setName('') + setIsShared(true) + setAttemptedEmpty(false) + } + + const mutation = useMutation({ + mutationFn: (payload: { name: string; isShared: boolean }) => createList(payload), + onMutate: async (payload) => { + // Optimistic insert: add a temporary entry to the lists cache + await queryClient.cancelQueries({ queryKey: ['lists'] }) + const previous = queryClient.getQueryData(['lists']) + const tempId = -Date.now() // negative temp ID so it won't collide with real IDs + queryClient.setQueryData(['lists'], (old) => ({ + lists: [ + ...(old?.lists ?? []), + { + id: tempId, + name: payload.name, + isShared: payload.isShared, + ownerId: 0, // unknown until response + activeCount: 0, + doneCount: 0, + } as List, + ], + })) + return { previous } + }, + onError: (_err, _vars, context) => { + // Rollback on error + if (context?.previous) { + queryClient.setQueryData(['lists'], context.previous) + } + }, + onSettled: () => { + // Always invalidate to get canonical server state + queryClient.invalidateQueries({ queryKey: ['lists'] }) + }, + onSuccess: () => { + handleClose() + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) { + setAttemptedEmpty(true) + return + } + mutation.mutate({ name: name.trim(), isShared }) + } + + if (!isOpen) return null + + const isNameEmpty = !name.trim() + const inputBorderColor = attemptedEmpty && isNameEmpty + ? 'var(--color-destructive)' + : 'var(--color-border)' + + return ( + <> + {/* Backdrop */} + + + {/* Delete confirmation dialog (D-06) */} + + + {/* Create list sheet (D-01) */} + + ) }