"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([]); const [rels, setRels] = useState([]); const [events, setEvents] = useState([]); const [ready, setReady] = useState(false); const [focusId, setFocusId] = useState(null); const [search, setSearch] = useState(""); const [firstName, setFirstName] = useState(""); // Inline add-relative form: which anchor + kind is open, and the typed name. // `key` keeps each empty slot's inline form independent (a person has 2 // parents, 4 grandparents — many same-kind/anchor slots can coexist). const [adding, setAdding] = useState<{ key: string; 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(); 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 { 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

Loading…

; if (people.length === 0) { return (

Start your tree

setFirstName(e.target.value)} />
); } 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 ( ); }; const AddSlot = ({ formKey, kind, anchor, label, }: { formKey: string; kind: AddKind; anchor: string; label: string; }) => adding?.key === formKey ? (
setAddName(e.target.value)} />
) : ( ); // Recursive ancestor chart (grows rightward): a node is its box plus a // two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 = // focus, capped at grandparents (depth 2). const renderNode = ( slotPersonId: string | null, childId: string, keyPrefix: string, depth: number, ): React.ReactNode => { const box = slotPersonId ? ( 0} /> ) : ( ); if (!slotPersonId || depth >= 2) { return
{box}
; } const ps = parentsOf(slotPersonId); return (
{box}
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
); }; 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; const shown = matches.slice(0, 200); // cap DOM nodes; refine search to narrow return (

Family view

Open {focus.primary_name ?? "person"} →
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
{renderNode(focus.id, focus.id, "ped", 0)}
{/* Family group: partners + children of the focus */}

Spouses & partners

{partners.map((id) => ( ))}

Children

{children.map((id) => ( ))}
{/* Scrollable, searchable people directory (scales to large trees) */}

People ({people.length})

setSearch(e.target.value)} />
{shown.length === 0 ? (
No matches.
) : ( shown.map((p, i) => ( )) )}
{matches.length > shown.length && (
Showing {shown.length} of {matches.length} — refine your search to narrow.
)}
); }