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>
This commit is contained in:
@@ -122,23 +122,42 @@ export default function FamilyViewPage() {
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
if (newId) await createLink(adding.kind, adding.anchor, newId);
|
||||
setAdding(null);
|
||||
setAddName("");
|
||||
load();
|
||||
@@ -210,26 +229,45 @@ export default function FamilyViewPage() {
|
||||
label: string;
|
||||
}) =>
|
||||
adding?.key === formKey ? (
|
||||
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1">
|
||||
<form onSubmit={submitAdd} className="flex w-56 flex-col gap-1">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-9"
|
||||
placeholder="Full name"
|
||||
placeholder="Search existing or type a new 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>
|
||||
{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
|
||||
|
||||
@@ -33,6 +33,53 @@ const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SE
|
||||
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 }>();
|
||||
@@ -54,6 +101,23 @@ export default function PersonDetailPage() {
|
||||
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");
|
||||
@@ -112,30 +176,16 @@ export default function PersonDetailPage() {
|
||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||
const personCites = citations.filter((c) => c.person_id === personId);
|
||||
|
||||
function buildDate() {
|
||||
const year = dateYear.trim();
|
||||
if (!year || Number.isNaN(Number(year))) {
|
||||
return { date_value: null, date_start: null, date_precision: null };
|
||||
}
|
||||
const m = dateMonth ? Number(dateMonth) : null;
|
||||
const d = dateDay.trim() ? Number(dateDay) : null;
|
||||
const parts: string[] = [];
|
||||
if (d && m) parts.push(String(d));
|
||||
if (m) parts.push(GED_MON[m]);
|
||||
parts.push(year);
|
||||
const prefix = DATE_QUALS[dateQual];
|
||||
return {
|
||||
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
|
||||
date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
|
||||
date_precision: dateQual,
|
||||
};
|
||||
}
|
||||
|
||||
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 } = buildDate();
|
||||
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 },
|
||||
@@ -156,6 +206,33 @@ export default function PersonDetailPage() {
|
||||
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;
|
||||
@@ -213,6 +290,33 @@ export default function PersonDetailPage() {
|
||||
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>;
|
||||
|
||||
@@ -316,15 +420,56 @@ export default function PersonDetailPage() {
|
||||
← Back to tree
|
||||
</Link>
|
||||
|
||||
<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="ghost" size="sm" onClick={removePerson}>
|
||||
Delete
|
||||
</Button>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -335,26 +480,98 @@ export default function PersonDetailPage() {
|
||||
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{events.map((ev) => (
|
||||
<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={() => removeEvent(ev.id)}
|
||||
className="text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Remove"
|
||||
{events.map((ev) =>
|
||||
editId === ev.id ? (
|
||||
<li key={ev.id}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveEdit();
|
||||
}}
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user