22bc536978
The People page is no longer a flat list: it's a focus-person family view with a pedigree of ancestors (parents + grandparents), a spouse/partner panel, and a children panel — with inline 'add parent/child/spouse' (creates the person + the relationship), click-to-refocus, birth–death years, and a searchable people index. Modeled on how real genealogy tools center on a person and let you walk the graph. Adds delete/restore UI: a Delete on the person page, per-tree delete + a 'Recently deleted' restore section on the trees list, and a Recovery page (sidebar) for deleted people. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useCallback, useEffect, useMemo, 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 Person = components["schemas"]["PersonRead"];
|
||
type Relationship = components["schemas"]["RelationshipRead"];
|
||
type Event = components["schemas"]["EventRead"];
|
||
|
||
function splitName(full: string): { given: string | null; surname: string | null } {
|
||
const t = full.trim().split(/\s+/).filter(Boolean);
|
||
if (t.length === 0) return { given: null, surname: null };
|
||
if (t.length === 1) return { given: t[0], surname: null };
|
||
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
|
||
}
|
||
|
||
type AddKind = "parent" | "child" | "partner";
|
||
|
||
export default function FamilyViewPage() {
|
||
const router = useRouter();
|
||
const params = useParams<{ id: string }>();
|
||
const treeId = params.id;
|
||
|
||
const [people, setPeople] = useState<Person[]>([]);
|
||
const [rels, setRels] = useState<Relationship[]>([]);
|
||
const [events, setEvents] = useState<Event[]>([]);
|
||
const [ready, setReady] = useState(false);
|
||
const [focusId, setFocusId] = useState<string | null>(null);
|
||
const [search, setSearch] = useState("");
|
||
const [firstName, setFirstName] = useState("");
|
||
// Inline add-relative form: which anchor + kind is open, and the typed name.
|
||
const [adding, setAdding] = useState<{ kind: AddKind; anchor: string } | null>(null);
|
||
const [addName, setAddName] = useState("");
|
||
|
||
const load = useCallback(async () => {
|
||
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||
params: { path: { tree_id: treeId } },
|
||
});
|
||
if (p.response.status === 401) {
|
||
router.push("/login");
|
||
return;
|
||
}
|
||
const [r, e] = await Promise.all([
|
||
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||
]);
|
||
const ppl = p.data ?? [];
|
||
setPeople(ppl);
|
||
setRels(r.data ?? []);
|
||
setEvents(e.data ?? []);
|
||
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
||
setReady(true);
|
||
}, [router, treeId]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||
const parentsOf = (id: string) =>
|
||
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
|
||
const childrenOf = (id: string) =>
|
||
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||
const partnersOf = (id: string) =>
|
||
rels
|
||
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
|
||
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
|
||
|
||
const years = useMemo(() => {
|
||
const m = new Map<string, string>();
|
||
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
|
||
for (const p of people) {
|
||
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
|
||
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
|
||
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
|
||
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, ""));
|
||
}
|
||
return m;
|
||
}, [people, events]);
|
||
|
||
async function addPerson(name: string): Promise<string | null> {
|
||
const { given, surname } = splitName(name);
|
||
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||
params: { path: { tree_id: treeId } },
|
||
body: { given, surname },
|
||
});
|
||
return data?.id ?? null;
|
||
}
|
||
|
||
async function createFirst(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!firstName.trim()) return;
|
||
const id = await addPerson(firstName);
|
||
setFirstName("");
|
||
if (id) setFocusId(id);
|
||
load();
|
||
}
|
||
|
||
async function submitAdd(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!adding || !addName.trim()) return;
|
||
const newId = await addPerson(addName);
|
||
if (newId) {
|
||
const { kind, anchor } = adding;
|
||
const body =
|
||
kind === "parent"
|
||
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
|
||
: kind === "child"
|
||
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
|
||
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
|
||
await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||
params: { path: { tree_id: treeId } },
|
||
body,
|
||
});
|
||
}
|
||
setAdding(null);
|
||
setAddName("");
|
||
load();
|
||
}
|
||
|
||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||
|
||
if (people.length === 0) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<h1 className="text-2xl font-semibold">Start your tree</h1>
|
||
<Card>
|
||
<CardContent className="p-6">
|
||
<form onSubmit={createFirst} className="flex flex-wrap gap-2">
|
||
<Input
|
||
className="w-64"
|
||
placeholder="First person's full name"
|
||
value={firstName}
|
||
onChange={(e) => setFirstName(e.target.value)}
|
||
/>
|
||
<Button type="submit">Add person</Button>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const focus = focusId ? byId.get(focusId) : undefined;
|
||
if (!focus) {
|
||
setFocusId(people[0].id);
|
||
return null;
|
||
}
|
||
|
||
const PersonBox = ({
|
||
id,
|
||
muted,
|
||
}: {
|
||
id: string;
|
||
muted?: boolean;
|
||
}) => {
|
||
const p = byId.get(id);
|
||
if (!p) return null;
|
||
const isFocus = id === focusId;
|
||
return (
|
||
<button
|
||
onClick={() => setFocusId(id)}
|
||
className={`w-44 rounded-lg border px-3 py-2 text-left transition-colors ${
|
||
isFocus
|
||
? "border-bronze bg-bronze/[0.08]"
|
||
: "border-[var(--border)] bg-[var(--surface)] hover:border-bronze/60"
|
||
} ${muted ? "opacity-90" : ""}`}
|
||
>
|
||
<div className="truncate text-sm font-medium">{p.primary_name ?? "Unnamed"}</div>
|
||
<div className="text-xs text-[var(--muted)]">{years.get(id) ?? "—"}</div>
|
||
</button>
|
||
);
|
||
};
|
||
|
||
const AddSlot = ({ kind, anchor, label }: { kind: AddKind; anchor: string; label: string }) =>
|
||
adding && adding.kind === kind && adding.anchor === anchor ? (
|
||
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1">
|
||
<Input
|
||
autoFocus
|
||
className="h-9"
|
||
placeholder="Full name"
|
||
value={addName}
|
||
onChange={(e) => setAddName(e.target.value)}
|
||
/>
|
||
<div className="flex gap-1">
|
||
<Button type="submit" size="sm">
|
||
Add
|
||
</Button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setAdding(null)}
|
||
className="text-xs text-[var(--muted)]"
|
||
>
|
||
cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<button
|
||
onClick={() => {
|
||
setAdding({ kind, anchor });
|
||
setAddName("");
|
||
}}
|
||
className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
|
||
>
|
||
+ {label}
|
||
</button>
|
||
);
|
||
|
||
const parents = parentsOf(focus.id);
|
||
const partners = partnersOf(focus.id);
|
||
const children = childrenOf(focus.id);
|
||
|
||
const sorted = [...people].sort((a, b) =>
|
||
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||
);
|
||
const matches = search
|
||
? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(search.toLowerCase()))
|
||
: sorted;
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<h1 className="text-2xl font-semibold">Family view</h1>
|
||
<Link
|
||
href={`/trees/${treeId}/persons/${focus.id}`}
|
||
className="text-sm text-bronze hover:underline"
|
||
>
|
||
Open {focus.primary_name ?? "person"} →
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Pedigree: focus → parents → grandparents */}
|
||
<Card>
|
||
<CardContent className="overflow-x-auto p-6">
|
||
<div className="flex min-w-[40rem] items-stretch gap-8">
|
||
<div className="flex flex-1 flex-col justify-center gap-3">
|
||
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||
Focus
|
||
</div>
|
||
<PersonBox id={focus.id} />
|
||
</div>
|
||
|
||
<div className="flex flex-1 flex-col justify-center gap-4">
|
||
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||
Parents
|
||
</div>
|
||
{parents.map((pid) => (
|
||
<PersonBox key={pid} id={pid} muted />
|
||
))}
|
||
{parents.length < 2 && <AddSlot kind="parent" anchor={focus.id} label="add parent" />}
|
||
</div>
|
||
|
||
<div className="flex flex-1 flex-col justify-center gap-4">
|
||
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||
Grandparents
|
||
</div>
|
||
{parents.length === 0 && (
|
||
<div className="text-sm text-[var(--muted)]">Add parents first.</div>
|
||
)}
|
||
{parents.map((pid) => (
|
||
<div key={pid} className="flex flex-col gap-2">
|
||
{parentsOf(pid).map((gp) => (
|
||
<PersonBox key={gp} id={gp} muted />
|
||
))}
|
||
{parentsOf(pid).length < 2 && (
|
||
<AddSlot kind="parent" anchor={pid} label="add parent" />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Family group: partners + children of the focus */}
|
||
<div className="grid gap-5 sm:grid-cols-2">
|
||
<Card>
|
||
<CardContent className="space-y-3 p-6">
|
||
<h2 className="font-serif text-base font-semibold">Spouses & partners</h2>
|
||
<div className="flex flex-wrap gap-3">
|
||
{partners.map((id) => (
|
||
<PersonBox key={id} id={id} muted />
|
||
))}
|
||
<AddSlot kind="partner" anchor={focus.id} label="add spouse" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardContent className="space-y-3 p-6">
|
||
<h2 className="font-serif text-base font-semibold">Children</h2>
|
||
<div className="flex flex-wrap gap-3">
|
||
{children.map((id) => (
|
||
<PersonBox key={id} id={id} muted />
|
||
))}
|
||
<AddSlot kind="child" anchor={focus.id} label="add child" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Searchable index of everyone in the tree */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h2 className="font-serif text-base font-semibold">All people ({people.length})</h2>
|
||
<Input
|
||
className="w-56"
|
||
placeholder="Search…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{matches.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => setFocusId(p.id)}
|
||
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
|
||
p.id === focusId
|
||
? "border-bronze bg-bronze/[0.08] text-bronze"
|
||
: "border-[var(--border)] hover:border-bronze/60"
|
||
}`}
|
||
>
|
||
{p.primary_name ?? "Unnamed"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|