Implement lists module and dev login setup

This commit is contained in:
ginnoir
2026-05-06 03:49:03 -05:00
parent 744c1119a9
commit 7e3ae6eb04
31 changed files with 1525 additions and 63 deletions
@@ -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>
);
}
@@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import { Plus } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ListDto } from "../server/queries";
import { createList } from "../server/actions";
export function ListsIndex({ lists }: { lists: ListDto[] }) {
const [listRows, setListRows] = useState(lists);
const [type, setType] = useState("shopping");
const [name, setName] = useState("");
const [, startTransition] = useTransition();
const grouped = useMemo(() => {
const groups = new Map<string, ListDto[]>();
for (const list of listRows) {
groups.set(list.type, [...(groups.get(list.type) ?? []), list]);
}
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [listRows]);
function addList() {
startTransition(async () => {
const created = await createList({ type, name });
setListRows((current) => [
...current,
{
id: created.id,
type: created.type,
name: created.name,
archived: created.archived,
openCount: 0,
doneCount: 0,
createdAt: created.createdAt.toISOString(),
},
]);
setName("");
});
}
return (
<div className="mx-auto grid w-full max-w-5xl gap-6 p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Lists</h1>
<p className="text-sm text-muted-foreground">Shopping, tasks, and whatever comes next.</p>
</div>
<div className="grid gap-2 rounded-lg border bg-background p-3 sm:grid-cols-[140px_220px_auto]">
<div className="space-y-1">
<Label htmlFor="new-list-type">Type</Label>
<Input
id="new-list-type"
value={type}
onChange={(event) => setType(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="new-list-name">Name</Label>
<Input
id="new-list-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<Button className="self-end" onClick={addList} disabled={!type || !name}>
<Plus />
New list
</Button>
</div>
</div>
<div className="grid gap-6">
{grouped.map(([groupType, groupLists]) => (
<section key={groupType} className="grid gap-3">
<h2 className="text-sm font-medium uppercase tracking-normal text-muted-foreground">
{groupType}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groupLists.map((list) => (
<Link
key={list.id}
href={`/lists/${list.id}`}
className="rounded-lg border bg-card p-4 text-card-foreground transition-colors hover:bg-muted"
>
<div className="font-medium">{list.name}</div>
<div className="mt-2 text-sm text-muted-foreground">
{list.openCount} open / {list.doneCount} done
</div>
</Link>
))}
</div>
</section>
))}
</div>
</div>
);
}