99a660485e
Rebuilds the family view's pedigree as a recursive bracket chart with CSS connector lines — focus links to its two parents (2 lines), and each parent links to its two parents (4 lines to grandparents). Fixes the prior ambiguity where grandparent slots weren't tied to a specific parent: now every parent shows its own two parent slots, so a person clearly has up to four grandparents grouped by lineage. Height-robust connectors (each leaf draws its own spine half + stub). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
358 lines
12 KiB
TypeScript
358 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.
|
||
// `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<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 = ({
|
||
formKey,
|
||
kind,
|
||
anchor,
|
||
label,
|
||
}: {
|
||
formKey: string;
|
||
kind: AddKind;
|
||
anchor: string;
|
||
label: string;
|
||
}) =>
|
||
adding?.key === formKey ? (
|
||
<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({ key: formKey, 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>
|
||
);
|
||
|
||
// 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 ? (
|
||
<PersonBox id={slotPersonId} muted={depth > 0} />
|
||
) : (
|
||
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
|
||
);
|
||
if (!slotPersonId || depth >= 2) {
|
||
return <div className="ped-person">{box}</div>;
|
||
}
|
||
const ps = parentsOf(slotPersonId);
|
||
return (
|
||
<div className="ped-person">
|
||
<div className="ped-self">{box}</div>
|
||
<div className="ped-branch">
|
||
<div className="ped-leaf">
|
||
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
|
||
</div>
|
||
<div className="ped-leaf">
|
||
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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, with bracket connectors */}
|
||
<Card>
|
||
<CardContent className="overflow-x-auto p-6">
|
||
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</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
|
||
formKey={`partner-${focus.id}`}
|
||
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
|
||
formKey={`child-${focus.id}`}
|
||
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>
|
||
);
|
||
}
|