Files
provenance/frontend/app/trees/[id]/page.tsx
T
justin 37ac49767e Make creating a person obvious; inline "create new" when linking relatives
- Family view gets a prominent "+ Add person" button that creates a person and
  opens their page to fill in details (previously you could only add a person
  via the empty-state form or by linking from another person).
- The person page's relationship picker (PersonCombobox) now offers
  "+ Create '<typed name>'" when the person doesn't exist yet: it creates them,
  links them in the chosen role (parent/child/partner/sibling), and jumps to
  their new page to edit — no more create-then-go-back-and-link.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:30:14 -04:00

494 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, 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;
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? homeId ?? 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]);
// 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<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;
}
// 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 <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>
<div className="flex items-center gap-3">
<Button size="sm" onClick={newPersonAndGo}>
+ Add person
</Button>
<Link
href={`/trees/${treeId}/persons/${focus.id}`}
className="text-sm text-bronze hover:underline"
>
Open {focus.primary_name ?? "person"}
</Link>
</div>
</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 &amp; 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>
);
}