Implement lists module and dev login setup
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Archive, GripVertical, 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 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);
|
||||
const swipeStart = useRef<Record<string, number>>({});
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-3xl gap-5 p-4">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
aria-label="List name"
|
||||
className="h-auto border-transparent px-0 text-2xl font-semibold shadow-none focus-visible:border-transparent focus-visible:ring-0"
|
||||
value={list.name}
|
||||
onChange={(event) => setList({ ...list, name: event.target.value })}
|
||||
onBlur={commitListName}
|
||||
/>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{list.type} / {list.openCount} open / {list.doneCount} done
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={archiveCurrentList} disabled={isPending}>
|
||||
<Archive />
|
||||
Archive list
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitItem();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
aria-label="Add item"
|
||||
autoFocus
|
||||
placeholder={list.type === "shopping" ? "Add milk, eggs, coffee..." : "Add a task..."}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={!draft.trim() || isPending}>
|
||||
<Plus />
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
{list.items.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">Nothing here yet.</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{list.items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="grid grid-cols-[auto_1fr_auto] items-center gap-3 p-3"
|
||||
onPointerDown={(event) => {
|
||||
swipeStart.current[item.id] = event.clientX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = swipeStart.current[item.id];
|
||||
if (start !== undefined && event.clientX - start < -60) removeItem(item);
|
||||
delete swipeStart.current[item.id];
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={`Complete ${item.text}`}
|
||||
type="checkbox"
|
||||
className="size-5 accent-primary"
|
||||
checked={item.done}
|
||||
onChange={(event) => setItemDone(item, event.target.checked)}
|
||||
/>
|
||||
<Input
|
||||
aria-label={`${item.text} text`}
|
||||
className={item.done ? "text-muted-foreground line-through" : ""}
|
||||
value={item.text}
|
||||
onChange={(event) => editItemText(item, event.target.value)}
|
||||
onBlur={() => commitItemText(item)}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<GripVertical className="size-4 text-muted-foreground" />
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Delete ${item.text}`}
|
||||
onClick={() => removeItem(item)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user