1f25eb2f21
New /trees/[id]/persons/[personId] view: life-events timeline with add/remove, and relationships grouped into parents/children/partners/siblings with an add form (kind + person picker + qualifier). People in the tree list now link here. Regenerated the OpenAPI client for the new endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useParams, 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, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
type Person = components["schemas"]["PersonRead"];
|
|
|
|
export default function TreeDetailPage() {
|
|
const router = useRouter();
|
|
const params = useParams<{ id: string }>();
|
|
const treeId = params.id;
|
|
|
|
const [persons, setPersons] = useState<Person[]>([]);
|
|
const [given, setGiven] = useState("");
|
|
const [surname, setSurname] = useState("");
|
|
const [ready, setReady] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
|
params: { path: { tree_id: treeId } },
|
|
});
|
|
if (response.status === 401) {
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
setPersons(data ?? []);
|
|
setReady(true);
|
|
}, [router, treeId]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
async function addPerson(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!given.trim() && !surname.trim()) return;
|
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
|
params: { path: { tree_id: treeId } },
|
|
body: { given: given || null, surname: surname || null },
|
|
});
|
|
if (!error) {
|
|
setGiven("");
|
|
setSurname("");
|
|
load();
|
|
}
|
|
}
|
|
|
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
|
← All trees
|
|
</Link>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Add a person</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={addPerson} className="flex gap-2">
|
|
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
|
|
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
|
|
<Button type="submit">Add</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div>
|
|
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
|
{persons.length === 0 ? (
|
|
<p className="text-[var(--muted)]">No people yet.</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{persons.map((person) => (
|
|
<li key={person.id}>
|
|
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
|
<Card className="transition-colors hover:border-bronze/50">
|
|
<CardContent className="p-4">
|
|
{person.primary_name ?? (
|
|
<span className="text-[var(--muted)]">Unnamed</span>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|