Files
famapp/src/modules/lists/components/list-detail.tsx
T

336 lines
11 KiB
TypeScript

"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<HTMLInputElement>(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 (
<div className="mx-auto grid w-full max-w-3xl gap-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex items-center gap-3 flex-1">
<Input
aria-label="List name"
className="serif h-auto border-transparent !bg-transparent px-0 text-[22px] font-medium tracking-tight shadow-none focus-visible:border-transparent focus-visible:ring-0"
value={list.name}
onChange={(event) => setList({ ...list, name: event.target.value })}
onBlur={commitListName}
/>
<span className="badge">
{list.type} · {list.openCount} open
</span>
</div>
<div className="flex items-center gap-2">
<ShareButton entityType="lists.list" entityId={list.id} canWrite={true} />
<Button variant="outline" size="sm" onClick={archiveCurrentList} disabled={isPending}>
<Archive className="size-3.5" />
Archive
</Button>
</div>
</header>
<div
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] shadow-[var(--shadow-1)] overflow-hidden"
style={{ borderColor: "var(--hair)" }}
>
<form
className="flex items-center gap-2 px-[14px] py-3 border-b-[0.5px]"
style={{ borderColor: "var(--hair)" }}
onSubmit={(event) => {
event.preventDefault();
submitItem();
}}
>
<Plus className="size-4 text-[var(--ink-mute)]" />
<Input
ref={inputRef}
aria-label="Add item"
autoFocus
placeholder={list.type === "shopping" ? "Add to list…" : "Add a task…"}
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 px-0 h-7"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<span className="kbd"></span>
</form>
{openItems.length === 0 && doneItems.length === 0 && (
<div className="muted px-[14px] py-8 text-center text-[13px]">Nothing here yet.</div>
)}
{openItems.map((item) => (
<ListItemRow
key={item.id}
item={item}
onToggle={(done) => setItemDone(item, done)}
onEdit={(text) => editItemText(item, text)}
onCommit={() => commitItemText(item)}
onRemove={() => removeItem(item)}
/>
))}
{doneItems.length > 0 && (
<>
<div className="eyebrow px-[14px] pt-3 pb-[6px]">Done · {doneItems.length}</div>
{doneItems.map((item) => (
<ListItemRow
key={item.id}
item={item}
onToggle={(done) => setItemDone(item, done)}
onEdit={(text) => editItemText(item, text)}
onCommit={() => commitItemText(item)}
onRemove={() => removeItem(item)}
/>
))}
</>
)}
</div>
</div>
);
}
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<HTMLDivElement>) {
e.currentTarget.setPointerCapture(e.pointerId);
swipeOrigin.current = { x: e.clientX, y: e.clientY };
isScrolling.current = false;
setSwipeX(0);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
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<HTMLDivElement>) {
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 (
<div
className="relative overflow-hidden border-b-[0.5px] last:border-b-0 group/row"
style={{ borderColor: "var(--hair)", background: swipeBg, touchAction: "pan-y" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
{/* Swipe affordance icons revealed behind the sliding content */}
<div
className="absolute inset-y-0 left-3 flex items-center"
aria-hidden
style={{ opacity: Math.max(0, swipeX - 16) / (MAX_SWIPE - 16), color: "var(--ok)" }}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12l5 5L20 7" />
</svg>
</div>
<div
className="absolute inset-y-0 right-3 flex items-center"
aria-hidden
style={{ opacity: Math.max(0, -swipeX - 16) / (MAX_SWIPE - 16), color: "var(--bad)" }}
>
<Trash2 size={16} />
</div>
{/* Sliding content row */}
<div
className={`flex items-center gap-3 px-[14px] py-[10px] hover:bg-[var(--shade)] ${item.done ? "checked" : ""}`}
style={{
transform: `translateX(${swipeX}px)`,
transition: swipeX === 0 ? "transform 0.22s ease-out" : "none",
}}
>
<button
type="button"
aria-label={`Complete ${item.text}`}
aria-pressed={item.done}
className={`checkbox ${item.done ? "on" : ""}`}
onClick={() => onToggle(!item.done)}
>
{item.done && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12l5 5L20 7" />
</svg>
)}
</button>
<Input
aria-label={`${item.text} text`}
className={`!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px] ${item.done ? "line-through text-[var(--ink-faint)]" : ""}`}
value={item.text}
onChange={(event) => onEdit(event.target.value)}
onBlur={onCommit}
/>
{/* Always visible on touch (list-row-delete makes opacity-0 override to 1 on mobile) */}
<Button
size="icon-sm"
variant="ghost"
aria-label={`Delete ${item.text}`}
onClick={onRemove}
className="list-row-delete opacity-0 group-hover/row:opacity-100 transition-opacity"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
}