Files
provenance/frontend/app/trees/page.tsx
T
justin dc1b6aac01 Trees list: inline visibility selector (private/unlisted/public)
Tree visibility was set to private with no UI to change it — the trees list
only displayed the value as text. Add a private/unlisted/public dropdown on
each tree card that PATCHes visibility immediately (optimistic), pulled out of
the card's navigation Link so it doesn't trigger a page change. Honors the
"everything configurable / full CRUD in the UI" invariants. Living people stay
protected by the privacy engine regardless of tree visibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 08:36:20 -04:00

143 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Tree = components["schemas"]["TreeRead"];
export default function TreesPage() {
const router = useRouter();
const [trees, setTrees] = useState<Tree[]>([]);
const [deleted, setDeleted] = useState<Tree[]>([]);
const [name, setName] = useState("");
const [ready, setReady] = useState(false);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees");
if (response.status === 401) {
router.push("/login");
return;
}
setTrees(data ?? []);
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
setDeleted(del.data ?? []);
setReady(true);
}, [router]);
useEffect(() => {
load();
}, [load]);
async function createTree(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
const { error } = await api.POST("/api/v1/trees", { body: { name } });
if (!error) {
setName("");
load();
}
}
async function remove(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } });
load();
}
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
load();
}
// Optimistic visibility change so the dropdown reflects the pick immediately.
async function setVisibility(id: string, visibility: NonNullable<Tree["visibility"]>) {
setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t)));
await api.PATCH("/api/v1/trees/{tree_id}", {
params: { path: { tree_id: id } },
body: { visibility },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-8">
<h1 className="text-2xl font-semibold">Your trees</h1>
<Card>
<CardContent className="p-5">
<form onSubmit={createTree} className="flex gap-2">
<Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
<Button type="submit">Create tree</Button>
</form>
</CardContent>
</Card>
{trees.length === 0 ? (
<p className="text-[var(--muted)]">No trees yet create your first one above.</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{trees.map((tree) => (
<li key={tree.id}>
<Card className="transition-colors hover:border-bronze/50">
<CardContent className="flex items-center justify-between gap-3 p-4">
<Link href={`/trees/${tree.id}/tree`} className="min-w-0 flex-1">
<div className="truncate font-medium">{tree.name}</div>
</Link>
<select
value={tree.visibility ?? "private"}
onChange={(e) =>
setVisibility(tree.id, e.target.value as NonNullable<Tree["visibility"]>)
}
aria-label="Tree visibility"
title="Who can see this tree. Living people stay protected even when public."
className="rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-xs uppercase tracking-wide text-bronze focus-visible:border-bronze focus-visible:outline-none"
>
<option value="private">Private</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</select>
<button
onClick={() => remove(tree.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Delete tree"
>
×
</button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
{deleted.length > 0 && (
<div className="space-y-3">
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
Recently deleted
</h2>
<ul className="space-y-2">
{deleted.map((tree) => (
<li key={tree.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{tree.name}</span>
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</div>
)}
</div>
);
}