"use client"; import { useRouter } from "next/navigation"; import { Archive, Plus, Trash2 } from "lucide-react"; import { useEffect, useRef, useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ShareButton } from "@/components/share-button"; import type { ListDetailDto, ListItemDto } from "../server/queries"; import { getList } from "../server/queries"; import { addItem, archiveList, deleteItem, renameList, toggleItem, updateItem, } from "../server/actions"; export function ListDetail({ initialList }: { initialList: ListDetailDto }) { const router = useRouter(); const [list, setList] = useState(initialList); const [draft, setDraft] = useState(""); const [isPending, startTransition] = useTransition(); const inputRef = useRef(null); useEffect(() => { const events = new EventSource(`/api/lists/${initialList.id}/events`); events.onmessage = () => { getList(initialList.id) .then(setList) .catch(() => undefined); }; return () => events.close(); }, [initialList.id]); function submitItem() { const text = draft.trim(); if (!text) return; startTransition(async () => { const next = await addItem({ listId: list.id, text }); setList(next); setDraft(""); requestAnimationFrame(() => inputRef.current?.focus()); }); } function setItemDone(item: ListItemDto, done: boolean) { setList((current) => ({ ...current, items: current.items.map((row) => (row.id === item.id ? { ...row, done } : row)), })); startTransition(async () => { setList(await toggleItem({ id: item.id, done })); }); } function editItemText(item: ListItemDto, text: string) { setList((current) => ({ ...current, items: current.items.map((row) => (row.id === item.id ? { ...row, text } : row)), })); } function commitItemText(item: ListItemDto) { if (!item.text.trim()) return; startTransition(async () => { setList(await updateItem({ id: item.id, text: item.text })); }); } function removeItem(item: ListItemDto) { setList((current) => ({ ...current, items: current.items.filter((row) => row.id !== item.id), })); startTransition(async () => { setList(await deleteItem({ id: item.id })); }); } function commitListName() { if (!list.name.trim()) return; startTransition(async () => { await renameList({ id: list.id, name: list.name }); }); } function archiveCurrentList() { startTransition(async () => { await archiveList({ id: list.id }); router.push("/lists"); }); } const openItems = list.items.filter((i) => !i.done); const doneItems = list.items.filter((i) => i.done); return (
setList({ ...list, name: event.target.value })} onBlur={commitListName} /> {list.type} · {list.openCount} open
{ event.preventDefault(); submitItem(); }} > setDraft(event.target.value)} /> {openItems.length === 0 && doneItems.length === 0 && (
Nothing here yet.
)} {openItems.map((item) => ( setItemDone(item, done)} onEdit={(text) => editItemText(item, text)} onCommit={() => commitItemText(item)} onRemove={() => removeItem(item)} /> ))} {doneItems.length > 0 && ( <>
Done · {doneItems.length}
{doneItems.map((item) => ( setItemDone(item, done)} onEdit={(text) => editItemText(item, text)} onCommit={() => commitItemText(item)} onRemove={() => removeItem(item)} /> ))} )}
); } function ListItemRow({ item, onToggle, onEdit, onCommit, onRemove, }: { item: ListItemDto; onToggle: (done: boolean) => void; onEdit: (text: string) => void; onCommit: () => void; onRemove: () => void; }) { const swipeOrigin = useRef<{ x: number; y: number } | null>(null); const isScrolling = useRef(false); const [swipeX, setSwipeX] = useState(0); const THRESHOLD = 80; // px to commit the swipe action const MAX_SWIPE = 100; // px maximum visual displacement function handlePointerDown(e: React.PointerEvent) { if ((e.target as HTMLElement).closest("button, input")) return; e.currentTarget.setPointerCapture(e.pointerId); swipeOrigin.current = { x: e.clientX, y: e.clientY }; isScrolling.current = false; setSwipeX(0); } function handlePointerMove(e: React.PointerEvent) { if (!swipeOrigin.current) return; const dx = e.clientX - swipeOrigin.current.x; const dy = Math.abs(e.clientY - swipeOrigin.current.y); // If the gesture is primarily vertical, treat as scroll and abort swipe. if (!isScrolling.current && dy > Math.abs(dx) && dy > 8) { isScrolling.current = true; setSwipeX(0); return; } if (isScrolling.current) return; setSwipeX(Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, dx))); } function handlePointerUp(e: React.PointerEvent) { if (swipeOrigin.current && !isScrolling.current) { const dx = e.clientX - swipeOrigin.current.x; if (dx > THRESHOLD) onToggle(!item.done); // right → complete / uncomplete if (dx < -THRESHOLD) onRemove(); // left → delete } swipeOrigin.current = null; setSwipeX(0); } function handlePointerCancel() { swipeOrigin.current = null; setSwipeX(0); } // Background tint that deepens as the swipe approaches the threshold. const swipeProgress = Math.abs(swipeX) / MAX_SWIPE; const swipeBg = swipeX > 16 ? `color-mix(in oklab, var(--ok) ${swipeProgress * 55}%, var(--card))` : swipeX < -16 ? `color-mix(in oklab, var(--bad) ${swipeProgress * 55}%, var(--card))` : undefined; return (
{/* Swipe affordance icons revealed behind the sliding content */}
{/* Sliding content row */}
onEdit(event.target.value)} onBlur={onCommit} /> {/* Always visible on touch (list-row-delete makes opacity-0 override to 1 on mobile) */}
); }