"use client"; import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, 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 searchParams = useSearchParams(); const treeId = params.id; // ?focus=… lets another view (or a person page) hand us who to center on. // Read once at mount so the focus→URL sync below doesn't trigger a refetch. const initialFocus = useRef(searchParams.get("focus")); 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 [results, setResults] = useState(null); // server fuzzy search 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, t] = 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 } } }), api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }), ]); const ppl = p.data ?? []; const home = t.data?.home_person_id ?? null; const homeId = home && ppl.some((x) => x.id === home) ? home : null; const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current) ? initialFocus.current : null; setPeople(ppl); setRels(r.data ?? []); setEvents(e.data ?? []); setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null); setReady(true); }, [router, treeId]); useEffect(() => { load(); }, [load]); // Keep the focused person in the URL (?focus=…) so leaving and returning — // e.g. opening a person then coming back — lands on the same person rather // than resetting to the home person. `replace` keeps history clean. useEffect(() => { if (!focusId || searchParams.get("focus") === focusId) return; const sp = new URLSearchParams(searchParams.toString()); sp.set("focus", focusId); router.replace(`/trees/${treeId}?${sp.toString()}`, { scroll: false }); }, [focusId, searchParams, router, treeId]); // Debounced server-side fuzzy search (pg_trgm) across the whole tree. useEffect(() => { const q = search.trim(); if (!q) { setResults(null); return; } const t = setTimeout(async () => { const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId }, query: { q } }, }); setResults(data ?? []); }, 250); return () => clearTimeout(t); }, [search, treeId]); const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]); // Order parents deterministically: father (male) on top, mother below, with a // stable fallback when gender is unknown (so it doesn't depend on which link // happened to be created first). const parentRank = (id: string) => { const g = byId.get(id)?.gender; return g === "male" ? 0 : g === "female" ? 1 : 2; }; const parentsOf = (id: string) => rels .filter((r) => r.type === "parent_child" && r.person_to_id === id) .map((r) => r.person_from_id) .sort((a, b) => parentRank(a) - parentRank(b)); 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; } // Create a new (blank) person and open their page to fill in details. async function newPersonAndGo() { const id = await addPerson(""); if (id) router.push(`/trees/${treeId}/persons/${id}`); } 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 postRel(body: components["schemas"]["RelationshipCreate"]) { await api.POST("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } }, body, }); } // Create the relationship(s) connecting an (existing or new) person to anchor. async function createLink(kind: AddKind, anchor: string, personId: string) { if (kind === "parent") { await postRel({ type: "parent_child", person_from_id: personId, person_to_id: anchor, qualifier: "biological" }); } else if (kind === "partner") { await postRel({ type: "partnership", person_from_id: anchor, person_to_id: personId }); } else { // child: link to anchor, and to anchor's spouse too (so both parents show) await postRel({ type: "parent_child", person_from_id: anchor, person_to_id: personId, qualifier: "biological" }); const partners = partnersOf(anchor); if (partners.length === 1) { await postRel({ type: "parent_child", person_from_id: partners[0], person_to_id: personId, qualifier: "biological" }); } } } async function linkExisting(personId: string) { if (!adding) return; await createLink(adding.kind, adding.anchor, personId); setAdding(null); setAddName(""); load(); } async function submitAdd(e: React.FormEvent) { e.preventDefault(); if (!adding || !addName.trim()) return; const newId = await addPerson(addName); if (newId) await createLink(adding.kind, adding.anchor, newId); 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)} /> {addName.trim() && (
{people .filter( (p) => p.id !== anchor && (p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()), ) .slice(0, 6) .map((p) => ( ))}
)}
) : ( ); // 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); // "Dangling" people: not linked to anyone. Common after a GEDCOM import or a // mistaken delete — surface them so they're not lost in the directory. const connected = new Set(); for (const r of rels) { connected.add(r.person_from_id); connected.add(r.person_to_id); } const unconnected = people .filter((p) => !connected.has(p.id)) .sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? "")); const sorted = [...people].sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""), ); // Server fuzzy results when searching; otherwise the loaded set. const directory = results ?? sorted; const shown = directory.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) => ( ))}
{/* Unconnected people — not linked to anyone in the tree */} {unconnected.length > 0 && (

Not connected to anyone ({unconnected.length})

Open one and add a relationship, or delete it.
{unconnected.slice(0, 60).map((p) => (
open
))}
{unconnected.length > 60 && (

Showing 60 of {unconnected.length}.

)}
)} {/* Scrollable, searchable people directory (scales to large trees) */}

People ({people.length})

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