Files
justin ab064bce6e Edit UI for people and life events; existing-person picker in family view
Person detail: an Edit form for name + gender + living status + privacy, and inline edit of each life event (type + structured date). Family view: the add-relative buttons now search existing people (link the real person) or create new — preventing duplicate spouses/parents — and adding a child to someone with one spouse links both parents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00

700 lines
27 KiB
TypeScript
Raw Permalink 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, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Person = components["schemas"]["PersonRead"];
type Event = components["schemas"]["EventRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Qualifier = components["schemas"]["ParentChildQualifier"];
type RelCreate = components["schemas"]["RelationshipCreate"];
type Source = components["schemas"]["SourceRead"];
type Citation = components["schemas"]["CitationRead"];
type CitationCreate = components["schemas"]["CitationCreate"];
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
// Curated genealogical event vocabulary (with an escape hatch).
const EVENT_TYPES = [
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
"residence", "census", "immigration", "emigration", "occupation", "education",
"military service", "naturalization", "other",
];
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
const pad = (n: number, len: number) => String(n).padStart(len, "0");
function composeDate(qual: string, day: string, month: string, year: string) {
const y = year.trim();
if (!y || Number.isNaN(Number(y))) {
return { date_value: null as string | null, date_start: null as string | null, date_precision: null as string | null };
}
const m = month ? Number(month) : null;
const d = day.trim() ? Number(day) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(y);
const prefix = DATE_QUALS[qual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(y), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: qual,
};
}
// Parse a stored date_value (e.g. "ABT 12 MAR 1900") back into form fields.
function parseDateValue(v: string | null | undefined) {
let qual = "exact";
let day = "";
let month = "";
let year = "";
if (v) {
let s = v.trim();
const up = s.toUpperCase();
for (const [q, pre] of Object.entries(DATE_QUALS)) {
if (pre && up.startsWith(`${pre} `)) {
qual = q;
s = s.slice(pre.length + 1).trim();
break;
}
}
for (const t of s.toUpperCase().split(/\s+/).filter(Boolean)) {
if (/^\d{3,4}$/.test(t) && !year) year = t;
else if (/^\d{1,2}$/.test(t)) day = String(Number(t));
else {
const mi = GED_MON.indexOf(t);
if (mi > 0) month = String(mi);
}
}
}
return { qual, day, month, year };
}
export default function PersonDetailPage() {
const router = useRouter();
const params = useParams<{ id: string; personId: string }>();
const treeId = params.id;
const personId = params.personId;
const [person, setPerson] = useState<Person | null>(null);
const [people, setPeople] = useState<Person[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [citations, setCitations] = useState<Citation[]>([]);
const [ready, setReady] = useState(false);
const [evType, setEvType] = useState("birth");
const [evTypeOther, setEvTypeOther] = useState("");
const [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState("");
const [dateYear, setDateYear] = useState("");
// Inline edit-event form.
const [editId, setEditId] = useState<string | null>(null);
const [edType, setEdType] = useState("birth");
const [edTypeOther, setEdTypeOther] = useState("");
const [edQual, setEdQual] = useState("exact");
const [edDay, setEdDay] = useState("");
const [edMonth, setEdMonth] = useState("");
const [edYear, setEdYear] = useState("");
// Inline edit-person form (name + vitals).
const [editingPerson, setEditingPerson] = useState(false);
const [pGiven, setPGiven] = useState("");
const [pSurname, setPSurname] = useState("");
const [pGender, setPGender] = useState("");
const [pLiving, setPLiving] = useState("unknown");
const [pPrivacy, setPPrivacy] = useState<"inherit" | "private" | "public">("inherit");
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological");
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
const [citeFor, setCiteFor] = useState<string | null>(null);
const [citeSource, setCiteSource] = useState("");
const [citePage, setCitePage] = useState("");
const load = useCallback(async () => {
const p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
});
if (p.response.status === 401) {
router.push("/login");
return;
}
setPerson(p.data ?? null);
const [all, ev, rl, src, cit] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/relationships", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
]);
setPeople(all.data ?? []);
setEvents(ev.data ?? []);
setRels(rl.data ?? []);
setSources(src.data ?? []);
setCitations(cit.data ?? []);
setReady(true);
}, [router, treeId, personId]);
useEffect(() => {
load();
}, [load]);
const nameOf = useMemo(() => {
const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"]));
return (id: string) => m.get(id) ?? "Unknown";
}, [people]);
const sourceName = useMemo(() => {
const m = new Map(sources.map((s) => [s.id, s.title]));
return (id: string) => m.get(id) ?? "source";
}, [sources]);
const others = people.filter((p) => p.id !== personId);
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
const partners = rels.filter((r) => r.type === "partnership");
const siblings = rels.filter((r) => r.type === "sibling");
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
async function addEvent(e: React.FormEvent) {
e.preventDefault();
const event_type = evType === "other" ? evTypeOther.trim() : evType;
if (!event_type) return;
const { date_value, date_start, date_precision } = composeDate(
dateQual,
dateDay,
dateMonth,
dateYear,
);
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } },
body: { event_type, person_id: personId, date_value, date_start, date_precision },
});
if (!error) {
setDateDay("");
setDateMonth("");
setDateYear("");
setDateQual("exact");
setEvTypeOther("");
load();
}
}
async function removeEvent(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: id } },
});
load();
}
function startEdit(ev: Event) {
setEditId(ev.id);
const known = EVENT_TYPES.includes(ev.event_type);
setEdType(known ? ev.event_type : "other");
setEdTypeOther(known ? "" : ev.event_type);
const parsed = parseDateValue(ev.date_value);
setEdQual(parsed.qual);
setEdDay(parsed.day);
setEdMonth(parsed.month);
setEdYear(parsed.year);
}
async function saveEdit() {
if (!editId) return;
const event_type = edType === "other" ? edTypeOther.trim() : edType;
if (!event_type) return;
const { date_value, date_start, date_precision } = composeDate(edQual, edDay, edMonth, edYear);
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: editId } },
body: { event_type, date_value, date_start, date_precision },
});
if (!error) {
setEditId(null);
load();
}
}
async function addRel(e: React.FormEvent) {
e.preventDefault();
if (!relOther) return;
let body: RelCreate;
if (relKind === "parent") {
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual };
} else if (relKind === "child") {
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual };
} else if (relKind === "partner") {
body = { type: "partnership", person_from_id: personId, person_to_id: relOther };
} else {
body = { type: "sibling", person_from_id: personId, person_to_id: relOther };
}
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
if (!error) {
setRelOther("");
load();
}
}
async function removeRel(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
params: { path: { tree_id: treeId, relationship_id: id } },
});
load();
}
async function addCitation(target: Partial<CitationCreate>) {
if (!citeSource) return;
const body: CitationCreate = { source_id: citeSource, page: citePage || null, ...target };
const { error } = await api.POST("/api/v1/trees/{tree_id}/citations", {
params: { path: { tree_id: treeId } },
body,
});
if (!error) {
setCiteFor(null);
setCiteSource("");
setCitePage("");
load();
}
}
async function removeCitation(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/citations/{citation_id}", {
params: { path: { tree_id: treeId, citation_id: id } },
});
load();
}
async function removePerson() {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
});
router.push(`/trees/${treeId}`);
}
function startEditPerson(current: Person) {
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
setPSurname(t.length > 1 ? t[t.length - 1] : "");
setPGender(current.gender ?? "");
setPLiving(current.is_living === true ? "living" : current.is_living === false ? "deceased" : "unknown");
setPPrivacy((current.privacy as "inherit" | "private" | "public") ?? "inherit");
setEditingPerson(true);
}
async function savePerson() {
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: {
given: pGiven || null,
surname: pSurname || null,
gender: pGender || null,
is_living: pLiving === "living" ? true : pLiving === "deceased" ? false : null,
privacy: pPrivacy,
},
});
if (!error) {
setEditingPerson(false);
load();
}
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
// Inline "cite" control: a badge with count, a toggle, and the picker form.
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
return (
<span className="inline-flex items-center gap-2">
{cites.length > 0 && (
<span
className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze"
title={cites.map((c) => sourceName(c.source_id)).join(", ")}
>
{cites.length} sourced
</span>
)}
{citeFor === key ? (
<form
onSubmit={(e) => {
e.preventDefault();
addCitation(target);
}}
className="inline-flex items-center gap-1"
>
<select
className={fieldCls}
value={citeSource}
onChange={(e) => setCiteSource(e.target.value)}
>
<option value=""> source </option>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
<input
className={`${fieldCls} w-24`}
placeholder="page"
value={citePage}
onChange={(e) => setCitePage(e.target.value)}
/>
<Button type="submit" size="sm">
cite
</Button>
<button
type="button"
onClick={() => setCiteFor(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
) : sources.length === 0 ? (
<Link href={`/trees/${treeId}/sources`} className="text-xs text-[var(--muted)] hover:underline">
+ add a source first
</Link>
) : (
<button
type="button"
onClick={() => {
setCiteFor(key);
setCiteSource("");
setCitePage("");
}}
className="text-xs text-bronze hover:underline"
>
+ cite
</button>
)}
</span>
);
}
const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) =>
items.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
<ul className="mt-1 space-y-1">
{items.map((r) => (
<li key={r.id} className="flex items-center justify-between text-sm">
<Link href={`/trees/${treeId}/persons/${otherId(r)}`} className="hover:underline">
{nameOf(otherId(r))}
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
</Link>
<button
onClick={() => removeRel(r.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</li>
))}
</ul>
</div>
);
return (
<div className="space-y-6">
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
Back to tree
</Link>
{editingPerson ? (
<form
onSubmit={(e) => {
e.preventDefault();
savePerson();
}}
className="space-y-3 rounded-lg border border-[var(--border)] p-4"
>
<div className="flex flex-wrap gap-2">
<Input className="w-40" placeholder="Given name" value={pGiven} onChange={(e) => setPGiven(e.target.value)} />
<Input className="w-40" placeholder="Surname" value={pSurname} onChange={(e) => setPSurname(e.target.value)} />
<Input className="w-32" placeholder="Gender" value={pGender} onChange={(e) => setPGender(e.target.value)} />
<select className={fieldCls} value={pLiving} onChange={(e) => setPLiving(e.target.value)}>
<option value="unknown">Status: unknown</option>
<option value="living">Living</option>
<option value="deceased">Deceased</option>
</select>
<select
className={fieldCls}
value={pPrivacy}
onChange={(e) => setPPrivacy(e.target.value as "inherit" | "private" | "public")}
>
<option value="inherit">Privacy: default</option>
<option value="private">Private</option>
<option value="public">Public</option>
</select>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button type="button" onClick={() => setEditingPerson(false)} className="text-xs text-[var(--muted)]">
cancel
</button>
</div>
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
</div>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Life events</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{events.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No events yet.</p>
) : (
<ul className="space-y-2">
{events.map((ev) =>
editId === ev.id ? (
<li key={ev.id}>
<form
onSubmit={(e) => {
e.preventDefault();
saveEdit();
}}
className="flex flex-wrap items-end gap-2"
>
<select
className={`${fieldCls} capitalize`}
value={edType}
onChange={(e) => setEdType(e.target.value)}
>
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
{edType === "other" && (
<Input
className="h-9 w-32"
placeholder="Custom"
value={edTypeOther}
onChange={(e) => setEdTypeOther(e.target.value)}
/>
)}
<select className={fieldCls} value={edQual} onChange={(e) => setEdQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="Day"
value={edDay}
onChange={(e) => setEdDay(e.target.value)}
/>
<select className={fieldCls} value={edMonth} onChange={(e) => setEdMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="Year"
value={edYear}
onChange={(e) => setEdYear(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
<span className="font-medium capitalize">{ev.event_type}</span>
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
</span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => startEdit(ev)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul>
)}
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Event</span>
<select
className={`${fieldCls} capitalize`}
value={evType}
onChange={(e) => setEvType(e.target.value)}
>
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
</label>
{evType === "other" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Type</span>
<Input
className="h-9 w-36"
placeholder="Custom"
value={evTypeOther}
onChange={(e) => setEvTypeOther(e.target.value)}
/>
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">When</span>
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Day</span>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="—"
value={dateDay}
onChange={(e) => setDateDay(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Month</span>
<select className={fieldCls} value={dateMonth} onChange={(e) => setDateMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Year</span>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="YYYY"
value={dateYear}
onChange={(e) => setDateYear(e.target.value)}
/>
</label>
<Button type="submit">Add event</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Relationships</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rels.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No relationships yet.</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{relGroup("Parents", parents, (r) => r.person_from_id)}
{relGroup("Children", children, (r) => r.person_to_id)}
{relGroup("Partners", partners, (r) =>
r.person_from_id === personId ? r.person_to_id : r.person_from_id,
)}
{relGroup("Siblings", siblings, (r) =>
r.person_from_id === personId ? r.person_to_id : r.person_from_id,
)}
</div>
)}
{others.length === 0 ? (
<p className="text-sm text-[var(--muted)]">Add more people to the tree to link them.</p>
) : (
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--muted)]">Add</span>
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
<option value="parent">parent</option>
<option value="child">child</option>
<option value="partner">partner</option>
<option value="sibling">sibling</option>
</select>
<select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}>
<option value=""> person </option>
{others.map((p) => (
<option key={p.id} value={p.id}>
{p.primary_name ?? "Unnamed"}
</option>
))}
</select>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
)}
<Button type="submit">Link</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}