Files
familysync/apps/pwa/src/routes/ListsIndex.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

254 lines
8.1 KiB
TypeScript

/**
* ListsIndex — Lists overview route (/lists).
*
* UI-SPEC §ListsIndex:
* - Full-height scrollable column with 16px horizontal padding
* - "Lists" heading + FAB ("+ New List") in top-right corner
* - State branches: isLoading → skeleton, isError → error+retry, data → list cards
* - Empty state: ListsEmptyState when no lists
* - ListCard for each list — navigates to /lists/:id on tap
* - ListDeleteDialog for delete confirmation (D-06)
* - CreateListSheet for new-list creation (D-01)
*
* Data flow:
* TanStack Query ['lists'] → fetchLists → render list cards or empty state
* useMutation(deleteList) → optimistic remove → onError rollback → onSettled invalidate
*
* T-04-06 XSS guard: all user-supplied strings rendered via ListCard/ListDeleteDialog
* as plain-text JSX children — no dangerouslySetInnerHTML anywhere.
*/
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { Plus } from 'lucide-react';
import { fetchLists, deleteList } from '../api/listsClient.js';
import type { List, ListsResponse } from '../api/listsClient.js';
import { useListsStore } from '../store/listsStore.js';
import { ListCard } from '../components/ListCard.js';
import { ListDeleteDialog } from '../components/ListDeleteDialog.js';
import { ListsEmptyState } from '../components/ListsEmptyState.js';
import { CreateListSheet } from '../components/CreateListSheet.js';
// ── Main Component ─────────────────────────────────────────────────────────
export function ListsIndex() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const setCreateListSheetOpen = useListsStore((s) => s.setCreateListSheetOpen);
const [deleteTarget, setDeleteTarget] = useState<List | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['lists'],
queryFn: fetchLists,
retry: 2,
staleTime: 30 * 1000,
});
const lists = data?.lists ?? [];
// Delete mutation with optimistic removal
const deleteMutation = useMutation({
mutationFn: (listId: number) => deleteList(listId),
onMutate: async (listId) => {
await queryClient.cancelQueries({ queryKey: ['lists'] });
const previous = queryClient.getQueryData<ListsResponse>(['lists']);
queryClient.setQueryData<ListsResponse>(['lists'], (old) => ({
lists: (old?.lists ?? []).filter((l) => l.id !== listId),
}));
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['lists'], context.previous);
}
// TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)
},
onSettled: () => {
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
void queryClient.invalidateQueries({ queryKey: ['lists'] });
},
onSuccess: () => {
void navigate('/lists');
setDeleteTarget(null);
},
});
const handleDeleteRequest = (list: List) => {
setDeleteTarget(list);
};
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id);
};
const handleDeleteClose = () => {
setDeleteTarget(null);
};
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)',
}}
>
{/* Plain text — XSS guard */}
Lists
</h1>
{/* FAB / inline button — opens CreateListSheet */}
<button
type="button"
aria-label="New list"
onClick={() => setCreateListSheetOpen(true)}
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
role="list"
aria-live="polite"
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',
}}
>
{/* Plain text — XSS guard */}
Loading lists&hellip;
</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)',
}}
>
{/* Plain text — XSS guard */}
Could not load lists
</div>
<button
type="button"
onClick={() => {
void 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',
}}
>
{/* Plain text — XSS guard */}
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} onDelete={handleDeleteRequest} />
))}
</div>
)}
</div>
</div>
{/* Delete confirmation dialog (D-06) */}
<ListDeleteDialog
list={deleteTarget}
onClose={handleDeleteClose}
onConfirm={handleDeleteConfirm}
isPending={deleteMutation.isPending}
/>
{/* Create list sheet (D-01) */}
<CreateListSheet />
</>
);
}