336 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|