Files
provenance/frontend/app/trees/[id]/persons/[personId]/page.tsx
T
justin 62513ee22e Person page: make marriage-event spouse picker searchable
Adding a marriage/partnership event used a plain <select> for the spouse,
which is unusable on a large tree — you can't search, only scroll. Swap it
for the existing PersonCombobox (already used by the relationship form), which
filters by name as you type. No onCreate, so it still resolves to an existing
person id, which is what the partnership-event handler requires.

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

1249 lines
48 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, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, 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";
import { PersonCombobox } from "@/components/person-combobox";
type Person = components["schemas"]["PersonRead"];
type Name = components["schemas"]["NameRead"];
type Me = components["schemas"]["UserRead"];
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"];
// Typed name vocabulary. "birth" is the maiden/birth name; "married" etc. are
// alternates. The maiden name stays primary by convention (Ancestry/FamilySearch).
const NAME_TYPES: { value: string; label: string }[] = [
{ value: "birth", label: "Birth / maiden" },
{ value: "married", label: "Married" },
{ value: "alias", label: "Also known as" },
{ value: "nickname", label: "Nickname" },
{ value: "religious", label: "Religious" },
{ value: "immigration", label: "Anglicized" },
];
const nameTypeLabel = (t: string) =>
NAME_TYPES.find((n) => n.value === t)?.label ?? t;
const formatName = (n: Name) =>
[n.given, n.surname].filter(Boolean).join(" ") || "—";
// 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",
];
// These belong to a couple, not a person — they attach to the partnership and
// show on both partners' pages, so they're only entered once.
const PARTNERSHIP_EVENTS = ["marriage", "divorce", "engagement"];
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 searchParams = useSearchParams();
const treeId = params.id;
const personId = params.personId;
// Where we were opened from, so "back" returns there (centered on this
// person) instead of always dumping onto the People view's home person.
const from = searchParams.get("from") === "people" ? "people" : "tree";
const backHref =
from === "people"
? `/trees/${treeId}?focus=${personId}`
: `/trees/${treeId}/tree?focus=${personId}`;
const backLabel = from === "people" ? "← Back to People" : "← Back to Tree";
// Carry the origin through person→person links so the chain keeps its anchor.
const personHref = (id: string) => `/trees/${treeId}/persons/${id}?from=${from}`;
const [person, setPerson] = useState<Person | null>(null);
const [people, setPeople] = useState<Person[]>([]);
const [names, setNames] = useState<Name[]>([]);
const [me, setMe] = useState<Me | null>(null);
const [tree, setTree] = useState<components["schemas"]["TreeRead"] | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [citations, setCitations] = useState<Citation[]>([]);
const [media, setMedia] = useState<components["schemas"]["MediaRead"][]>([]);
const [uploadingMedia, setUploadingMedia] = useState(false);
const mediaFileRef = useRef<HTMLInputElement>(null);
const [ready, setReady] = useState(false);
const [evType, setEvType] = useState("birth");
const [evTypeOther, setEvTypeOther] = useState("");
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
const [allEvents, setAllEvents] = useState<Event[]>([]); // tree-wide, for partnership events
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");
const [relErr, setRelErr] = useState<string | null>(null);
// Add-name form + inline edit.
const [nameType, setNameType] = useState("married");
const [nGiven, setNGiven] = useState("");
const [nSurname, setNSurname] = useState("");
const [editNameId, setEditNameId] = useState<string | null>(null);
const [enType, setEnType] = useState("married");
const [enGiven, setEnGiven] = useState("");
const [enSurname, setEnSurname] = useState("");
// Delete confirmation (with optional cascade to descendants).
const [confirmingDelete, setConfirmingDelete] = useState(false);
// 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, nm, mine, tr, ev, rl, src, cit, evAll, med] = 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}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/users/me"),
api.GET("/api/v1/trees/{tree_id}", { 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 } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
]);
setPeople(all.data ?? []);
setNames(nm.data ?? []);
setMe(mine.data ?? null);
setTree(tr.data ?? null);
setEvents(ev.data ?? []);
setAllEvents(evAll.data ?? []);
setMedia(med.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);
// Partnership events live on the relationship and show on both partners.
const myPartnerRels = rels.filter(
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
);
const myPartnerRelIds = new Set(myPartnerRels.map((r) => r.id));
const relEvents = allEvents.filter(
(e) => e.relationship_id && myPartnerRelIds.has(e.relationship_id),
);
const spouseOfRelEvent = (relId: string | null | undefined) => {
const r = myPartnerRels.find((x) => x.id === relId);
if (!r) return null;
return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
};
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
// Personal events + this person's partnership events, shown together.
const shownEvents = [...events, ...relEvents];
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,
);
let body: components["schemas"]["EventCreate"] = {
event_type,
person_id: personId,
date_value,
date_start,
date_precision,
};
// A partnership event belongs to the couple: attach it to the partnership
// relationship (creating it if needed) so it's entered once and shows on
// both partners.
if (isPartnershipType(event_type)) {
if (!evSpouse) return;
let relId = myPartnerRels.find(
(r) => r.person_from_id === evSpouse || r.person_to_id === evSpouse,
)?.id;
if (!relId) {
const { data, error: relErr } = await api.POST(
"/api/v1/trees/{tree_id}/relationships",
{
params: { path: { tree_id: treeId } },
body: { type: "partnership", person_from_id: personId, person_to_id: evSpouse },
},
);
if (relErr || !data) return;
relId = data.id;
}
body = {
event_type,
relationship_id: relId,
person_id: null,
date_value,
date_start,
date_precision,
};
}
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } },
body,
});
if (!error) {
setDateDay("");
setDateMonth("");
setDateYear("");
setDateQual("exact");
setEvTypeOther("");
setEvSpouse("");
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 linkRelative(otherId: string): Promise<boolean> {
let body: RelCreate;
if (relKind === "parent") {
body = { type: "parent_child", person_from_id: otherId, person_to_id: personId, qualifier: relQual };
} else if (relKind === "child") {
body = { type: "parent_child", person_from_id: personId, person_to_id: otherId, qualifier: relQual };
} else if (relKind === "partner") {
body = { type: "partnership", person_from_id: personId, person_to_id: otherId };
} else {
body = { type: "sibling", person_from_id: personId, person_to_id: otherId };
}
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
return !error;
}
async function addRel(e: React.FormEvent) {
e.preventDefault();
if (!relOther) return;
setRelErr(null);
if (await linkRelative(relOther)) {
setRelOther("");
load();
} else {
setRelErr("They're already linked that way.");
}
}
// Create a brand-new person, link them in the chosen role, then jump to their
// page so the user can fill in details immediately.
async function createRelativeAndGo(name: string) {
const toks = name.trim().split(/\s+/).filter(Boolean);
const given = toks.length > 1 ? toks.slice(0, -1).join(" ") : toks[0] ?? name.trim();
const surname = toks.length > 1 ? toks[toks.length - 1] : null;
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
body: { given, surname },
});
if (!data) return;
await linkRelative(data.id);
router.push(personHref(data.id));
}
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(cascade: boolean) {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId }, query: { cascade } },
});
router.push(`/trees/${treeId}`);
}
async function addName(e: React.FormEvent) {
e.preventDefault();
if (!nGiven.trim() && !nSurname.trim()) return;
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
body: { name_type: nameType, given: nGiven || null, surname: nSurname || null },
});
if (!error) {
setNGiven("");
setNSurname("");
setNameType("married");
load();
}
}
function startEditName(n: Name) {
setEditNameId(n.id);
setEnType(n.name_type);
setEnGiven(n.given ?? "");
setEnSurname(n.surname ?? "");
}
async function saveName() {
if (!editNameId) return;
const { error } = await api.PATCH(
"/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}",
{
params: { path: { tree_id: treeId, person_id: personId, name_id: editNameId } },
body: { name_type: enType, given: enGiven || null, surname: enSurname || null },
},
);
if (!error) {
setEditNameId(null);
load();
}
}
async function makePrimaryName(id: string) {
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
body: { is_primary: true },
});
load();
}
async function removeName(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
});
load();
}
async function setSelf(link: boolean) {
await api.PATCH("/api/v1/users/me/self-person", {
body: { self_person_id: link ? personId : null },
});
load();
}
async function setDefaultPerson(make: boolean) {
await api.PATCH("/api/v1/trees/{tree_id}", {
params: { path: { tree_id: treeId } },
body: { home_person_id: make ? personId : null },
});
load();
}
async function uploadMediaForPerson(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (mediaFileRef.current) mediaFileRef.current.value = "";
if (!file) return;
setUploadingMedia(true);
const fd = new FormData();
fd.append("file", file);
fd.append("person_id", personId); // link on upload
await fetch(`/api/v1/trees/${treeId}/media`, {
method: "POST",
body: fd,
credentials: "include",
});
setUploadingMedia(false);
load();
}
async function linkMedia(mediaId: string, link: boolean) {
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
params: { path: { tree_id: treeId, media_id: mediaId } },
body: { person_id: link ? personId : null },
});
load();
}
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>;
const isSelf = me?.self_person_id === personId;
const isDefault = tree?.home_person_id === personId;
// 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={personHref(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">
<div className="flex items-center justify-between gap-3">
<Link href={backHref} className="text-sm text-[var(--muted)] hover:underline">
{backLabel}
</Link>
<Link
href={`/trees/${treeId}/tree?focus=${personId}`}
className="text-sm text-bronze hover:underline"
>
View in tree
</Link>
</div>
{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)} />
<select className={fieldCls} value={pGender} onChange={(e) => setPGender(e.target.value)}>
<option value="">Gender: </option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
<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="flex flex-wrap items-center gap-3 text-3xl font-semibold">
<span className="inline-flex items-center gap-2">
{person.primary_name ?? "Unnamed person"}
{person.gender === "male" && (
<span title="Male" aria-label="Male" style={{ color: "rgb(120, 159, 172)" }}>
</span>
)}
{person.gender === "female" && (
<span title="Female" aria-label="Female" style={{ color: "rgb(196, 138, 146)" }}>
</span>
)}
</span>
{isSelf && (
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
This is you
</span>
)}
{isDefault && (
<span className="rounded-full border border-bronze/40 px-2.5 py-1 text-xs font-medium text-bronze">
Default person
</span>
)}
</h1>
<div className="flex flex-wrap items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
{isSelf ? (
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
Unlink me
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setSelf(true)}>
This is me
</Button>
)}
{!isDefault && (
<Button variant="outline" size="sm" onClick={() => setDefaultPerson(true)}>
Set as default
</Button>
)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmingDelete(true)}>
Delete
</Button>
</div>
</div>
)}
{confirmingDelete && (
<div className="space-y-3 rounded-lg border border-bronze/40 bg-bronze/[0.05] p-4">
<p className="text-sm">
Delete <strong>{person.primary_name ?? "this person"}</strong>? Their relationships
will be removed too. This can be undone from Recovery.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" onClick={() => removePerson(false)}>
Delete only this person
</Button>
<Button variant="outline" size="sm" onClick={() => removePerson(true)}>
Delete with all descendants
</Button>
<button
type="button"
onClick={() => setConfirmingDelete(false)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</div>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Names</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{names.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No names yet.</p>
) : (
<ul className="space-y-2">
{names.map((n) =>
editNameId === n.id ? (
<li key={n.id}>
<form
onSubmit={(e) => {
e.preventDefault();
saveName();
}}
className="flex flex-wrap items-center gap-2"
>
<select
className={fieldCls}
value={enType}
onChange={(e) => setEnType(e.target.value)}
>
{NAME_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<Input
className="h-9 w-36"
placeholder="Given"
value={enGiven}
onChange={(e) => setEnGiven(e.target.value)}
/>
<Input
className="h-9 w-36"
placeholder="Surname"
value={enSurname}
onChange={(e) => setEnSurname(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditNameId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li
key={n.id}
className="flex flex-wrap items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2">
<span className="font-medium">{formatName(n)}</span>
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
{nameTypeLabel(n.name_type)}
</span>
{n.is_primary && (
<span className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze">
primary
</span>
)}
</span>
<span className="flex items-center gap-3">
{!n.is_primary && (
<button
onClick={() => makePrimaryName(n.id)}
className="text-xs text-bronze hover:underline"
>
make primary
</button>
)}
<button
onClick={() => startEditName(n)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeName(n.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul>
)}
<form onSubmit={addName} className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Type</span>
<select
className={fieldCls}
value={nameType}
onChange={(e) => setNameType(e.target.value)}
>
{NAME_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Given</span>
<Input
className="h-9 w-36"
placeholder="Given"
value={nGiven}
onChange={(e) => setNGiven(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Surname</span>
<Input
className="h-9 w-36"
placeholder="Surname"
value={nSurname}
onChange={(e) => setNSurname(e.target.value)}
/>
</label>
<Button type="submit">Add name</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Life events</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{shownEvents.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No events yet.</p>
) : (
<ul className="space-y-2">
{shownEvents.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.relationship_id ? (
<span className="text-[var(--muted)]">
{" "}
· with {nameOf(spouseOfRelEvent(ev.relationship_id) ?? "")}
</span>
) : null}
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
{ev.detail ? (
<span className="text-[var(--muted)]"> {ev.detail}</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>
)}
{isPartnershipType(evType) && (
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
<PersonCombobox
people={others}
value={evSpouse}
onChange={setEvSpouse}
placeholder="Search for a spouse…"
/>
</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>
<PersonCombobox
people={others}
value={relOther}
onChange={setRelOther}
onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/>
{(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>
)}
{relErr && <p className="text-sm text-red-600">{relErr}</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Media</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{(() => {
const personMedia = media.filter((m) => m.person_id === personId);
const unlinked = media.filter((m) => !m.person_id);
return (
<>
{personMedia.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No media linked to this person yet.</p>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{personMedia.map((m) => (
<div key={m.id} className="overflow-hidden rounded-lg border border-[var(--border)]">
<a href={m.url ?? "#"} target="_blank" rel="noreferrer" className="block">
{m.content_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.url ?? ""}
alt={m.title ?? m.original_filename}
className="aspect-square w-full object-cover"
/>
) : (
<div className="grid aspect-square w-full place-items-center bg-bronze/[0.06] font-serif text-2xl text-bronze">
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
</div>
)}
</a>
<div className="flex items-center justify-between gap-2 p-2">
<span className="truncate text-xs" title={m.original_filename}>
{m.title ?? m.original_filename}
</span>
<button
onClick={() => linkMedia(m.id, false)}
className="shrink-0 text-xs text-[var(--muted)] hover:text-bronze"
>
unlink
</button>
</div>
</div>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<input
ref={mediaFileRef}
type="file"
onChange={uploadMediaForPerson}
className="hidden"
/>
<Button
size="sm"
onClick={() => mediaFileRef.current?.click()}
disabled={uploadingMedia}
>
{uploadingMedia ? "Uploading…" : "Upload & link"}
</Button>
{unlinked.length > 0 && (
<select
className={fieldCls}
defaultValue=""
onChange={(e) => e.target.value && linkMedia(e.target.value, true)}
>
<option value="">Link existing media</option>
{unlinked.map((m) => (
<option key={m.id} value={m.id}>
{m.title ?? m.original_filename}
</option>
))}
</select>
)}
</div>
</>
);
})()}
</CardContent>
</Card>
</div>
);
}