04ccdbf96a
Names (the genealogy standard: maiden name primary, married/alias as typed
alternates):
- Name model already supported multiple typed names; expose full CRUD —
NameCreate/Read/Update schemas, name_service (one-primary invariant,
promote-on-delete), nested /persons/{id}/names routes.
- Person page gains a Names card: add/edit/delete + "make primary", with a
curated name_type dropdown (birth/maiden, married, alias, nickname, …).
Self-person ("who am I"):
- users.self_person_id FK (use_alter for the users<->persons<->trees cycle)
+ migration; PATCH /users/me/self-person; "This is me" / "This is you"
on the person page. Soft-deleting the linked person clears it.
Deletion integrity (fixes the broken tree view):
- delete_person now soft-deletes the relationships touching the person, so no
dangling edges remain; family-chart also filters links to missing people.
- Optional cascade=true recursively deletes descendants (GEDCOM cleanup);
the person page asks "only this person" vs "with all descendants".
- DELETE returns {deleted: n}.
Family view surfaces "Not connected to anyone" so dangling people aren't lost.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
470 lines
17 KiB
TypeScript
470 lines
17 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 [results, setResults] = useState<Person[] | null>(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] = 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]);
|
||
|
||
// 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]);
|
||
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 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 <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-56 flex-col gap-1">
|
||
<Input
|
||
autoFocus
|
||
className="h-9"
|
||
placeholder="Search existing or type a new name"
|
||
value={addName}
|
||
onChange={(e) => setAddName(e.target.value)}
|
||
/>
|
||
{addName.trim() && (
|
||
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
|
||
{people
|
||
.filter(
|
||
(p) =>
|
||
p.id !== anchor &&
|
||
(p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
|
||
)
|
||
.slice(0, 6)
|
||
.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
onClick={() => linkExisting(p.id)}
|
||
className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-bronze/[0.07]"
|
||
>
|
||
<span className="truncate">{p.primary_name ?? "Unnamed"}</span>
|
||
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
|
||
</button>
|
||
))}
|
||
<button
|
||
type="submit"
|
||
className="flex w-full items-center gap-1 border-t border-[var(--border)] px-2 py-1.5 text-left text-bronze hover:bg-bronze/[0.07]"
|
||
>
|
||
+ Create new “{addName.trim()}”
|
||
</button>
|
||
</div>
|
||
)}
|
||
<button type="button" onClick={() => setAdding(null)} className="text-xs text-[var(--muted)]">
|
||
cancel
|
||
</button>
|
||
</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);
|
||
|
||
// "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<string>();
|
||
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 (
|
||
<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>
|
||
|
||
{/* Unconnected people — not linked to anyone in the tree */}
|
||
{unconnected.length > 0 && (
|
||
<Card className="border-bronze/40">
|
||
<CardContent className="space-y-3 p-6">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="font-serif text-base font-semibold">
|
||
Not connected to anyone ({unconnected.length})
|
||
</h2>
|
||
<span className="text-xs text-[var(--muted)]">
|
||
Open one and add a relationship, or delete it.
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-3">
|
||
{unconnected.slice(0, 60).map((p) => (
|
||
<div key={p.id} className="flex items-center gap-1">
|
||
<PersonBox id={p.id} muted />
|
||
<Link
|
||
href={`/trees/${treeId}/persons/${p.id}`}
|
||
className="text-xs text-bronze hover:underline"
|
||
>
|
||
open
|
||
</Link>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{unconnected.length > 60 && (
|
||
<p className="text-xs text-[var(--muted)]">
|
||
Showing 60 of {unconnected.length}.
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Scrollable, searchable people directory (scales to large trees) */}
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<h2 className="font-serif text-base font-semibold">People ({people.length})</h2>
|
||
<Input
|
||
className="w-64"
|
||
placeholder="Search by name…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
<Card className="overflow-hidden">
|
||
<div className="max-h-96 overflow-y-auto">
|
||
{shown.length === 0 ? (
|
||
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
|
||
) : (
|
||
shown.map((p, i) => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => setFocusId(p.id)}
|
||
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
|
||
i > 0 ? "border-t border-[var(--border)]" : ""
|
||
} ${p.id === focusId ? "bg-bronze/[0.08]" : "hover:bg-bronze/[0.05]"}`}
|
||
>
|
||
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
|
||
<span className="shrink-0 text-xs text-[var(--muted)]">
|
||
{years.get(p.id) ?? ""}
|
||
</span>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
{directory.length > shown.length && (
|
||
<div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]">
|
||
Showing {shown.length} of {directory.length} — refine your search to narrow.
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|