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.
254 lines
8.1 KiB
TypeScript
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…
|
|
</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 />
|
|
</>
|
|
);
|
|
}
|