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();
|
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) {
|
async function submitAdd(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!adding || !addName.trim()) return;
|
if (!adding || !addName.trim()) return;
|
||||||
const newId = await addPerson(addName);
|
const newId = await addPerson(addName);
|
||||||
if (newId) {
|
if (newId) await createLink(adding.kind, adding.anchor, 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setAdding(null);
|
setAdding(null);
|
||||||
setAddName("");
|
setAddName("");
|
||||||
load();
|
load();
|
||||||
@@ -210,26 +229,45 @@ export default function FamilyViewPage() {
|
|||||||
label: string;
|
label: string;
|
||||||
}) =>
|
}) =>
|
||||||
adding?.key === formKey ? (
|
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
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-9"
|
className="h-9"
|
||||||
placeholder="Full name"
|
placeholder="Search existing or type a new name"
|
||||||
value={addName}
|
value={addName}
|
||||||
onChange={(e) => setAddName(e.target.value)}
|
onChange={(e) => setAddName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1">
|
{addName.trim() && (
|
||||||
<Button type="submit" size="sm">
|
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
|
||||||
Add
|
{people
|
||||||
</Button>
|
.filter(
|
||||||
<button
|
(p) =>
|
||||||
type="button"
|
p.id !== anchor &&
|
||||||
onClick={() => setAdding(null)}
|
(p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
|
||||||
className="text-xs text-[var(--muted)]"
|
)
|
||||||
>
|
.slice(0, 6)
|
||||||
cancel
|
.map((p) => (
|
||||||
</button>
|
<button
|
||||||
</div>
|
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>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<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 DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
||||||
const pad = (n: number, len: number) => String(n).padStart(len, "0");
|
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() {
|
export default function PersonDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string; personId: string }>();
|
const params = useParams<{ id: string; personId: string }>();
|
||||||
@@ -54,6 +101,23 @@ export default function PersonDetailPage() {
|
|||||||
const [dateMonth, setDateMonth] = useState("");
|
const [dateMonth, setDateMonth] = useState("");
|
||||||
const [dateYear, setDateYear] = 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 [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
||||||
const [relOther, setRelOther] = useState("");
|
const [relOther, setRelOther] = useState("");
|
||||||
const [relQual, setRelQual] = useState<Qualifier>("biological");
|
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 eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||||
const personCites = citations.filter((c) => c.person_id === personId);
|
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) {
|
async function addEvent(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
||||||
if (!event_type) return;
|
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", {
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
||||||
@@ -156,6 +206,33 @@ export default function PersonDetailPage() {
|
|||||||
load();
|
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) {
|
async function addRel(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!relOther) return;
|
if (!relOther) return;
|
||||||
@@ -213,6 +290,33 @@ export default function PersonDetailPage() {
|
|||||||
router.push(`/trees/${treeId}`);
|
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 (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
||||||
|
|
||||||
@@ -316,15 +420,56 @@ export default function PersonDetailPage() {
|
|||||||
← Back to tree
|
← Back to tree
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
{editingPerson ? (
|
||||||
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
<form
|
||||||
<div className="flex items-center gap-3">
|
onSubmit={(e) => {
|
||||||
{citeControl("p", { person_id: personId }, personCites)}
|
e.preventDefault();
|
||||||
<Button variant="ghost" size="sm" onClick={removePerson}>
|
savePerson();
|
||||||
Delete
|
}}
|
||||||
</Button>
|
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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -335,26 +480,98 @@ export default function PersonDetailPage() {
|
|||||||
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{events.map((ev) => (
|
{events.map((ev) =>
|
||||||
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
editId === ev.id ? (
|
||||||
<span>
|
<li key={ev.id}>
|
||||||
<span className="font-medium capitalize">{ev.event_type}</span>
|
<form
|
||||||
{ev.date_value ? (
|
onSubmit={(e) => {
|
||||||
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
e.preventDefault();
|
||||||
) : null}
|
saveEdit();
|
||||||
</span>
|
}}
|
||||||
<span className="flex items-center gap-3">
|
className="flex flex-wrap items-end gap-2"
|
||||||
{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"
|
|
||||||
>
|
>
|
||||||
×
|
<select
|
||||||
</button>
|
className={`${fieldCls} capitalize`}
|
||||||
</span>
|
value={edType}
|
||||||
</li>
|
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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
|
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
|
||||||
|
|||||||
Vendored
+111
-2
@@ -243,7 +243,8 @@ export interface paths {
|
|||||||
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
|
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
/** Update Person */
|
||||||
|
patch: operations["update_person_api_v1_trees__tree_id__persons__person_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
|
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
|
||||||
@@ -312,7 +313,8 @@ export interface paths {
|
|||||||
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
|
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
/** Update Event */
|
||||||
|
patch: operations["update_event_api_v1_trees__tree_id__events__event_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/trees/{tree_id}/relationships": {
|
"/api/v1/trees/{tree_id}/relationships": {
|
||||||
@@ -676,6 +678,27 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
/** EventUpdate */
|
||||||
|
EventUpdate: {
|
||||||
|
/** Event Type */
|
||||||
|
event_type?: string | null;
|
||||||
|
/** Place Id */
|
||||||
|
place_id?: string | null;
|
||||||
|
/** Date Value */
|
||||||
|
date_value?: string | null;
|
||||||
|
/** Date Start */
|
||||||
|
date_start?: string | null;
|
||||||
|
/** Date End */
|
||||||
|
date_end?: string | null;
|
||||||
|
/** Date Precision */
|
||||||
|
date_precision?: string | null;
|
||||||
|
/** Calendar */
|
||||||
|
calendar?: string | null;
|
||||||
|
/** Detail */
|
||||||
|
detail?: string | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
/** HTTPValidationError */
|
/** HTTPValidationError */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
@@ -798,6 +821,20 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
/** PersonUpdate */
|
||||||
|
PersonUpdate: {
|
||||||
|
/** Given */
|
||||||
|
given?: string | null;
|
||||||
|
/** Surname */
|
||||||
|
surname?: string | null;
|
||||||
|
/** Gender */
|
||||||
|
gender?: string | null;
|
||||||
|
/** Is Living */
|
||||||
|
is_living?: boolean | null;
|
||||||
|
privacy?: components["schemas"]["PersonPrivacy"] | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
/** RegisterRequest */
|
/** RegisterRequest */
|
||||||
RegisterRequest: {
|
RegisterRequest: {
|
||||||
/** Email */
|
/** Email */
|
||||||
@@ -1539,6 +1576,42 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
update_person_api_v1_trees__tree_id__persons__person_id__patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
person_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PersonUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PersonRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: {
|
restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1699,6 +1772,42 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
update_event_api_v1_trees__tree_id__events__event_id__patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
event_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EventUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EventRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_relationships_api_v1_trees__tree_id__relationships_get: {
|
list_relationships_api_v1_trees__tree_id__relationships_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -611,6 +611,67 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
||||||
|
"patch": {
|
||||||
|
"tags": [
|
||||||
|
"persons"
|
||||||
|
],
|
||||||
|
"summary": "Update Person",
|
||||||
|
"operationId": "update_person_api_v1_trees__tree_id__persons__person_id__patch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "person_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Person Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PersonUpdate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PersonRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"persons"
|
"persons"
|
||||||
@@ -916,6 +977,67 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/events/{event_id}": {
|
"/api/v1/trees/{tree_id}/events/{event_id}": {
|
||||||
|
"patch": {
|
||||||
|
"tags": [
|
||||||
|
"events"
|
||||||
|
],
|
||||||
|
"summary": "Update Event",
|
||||||
|
"operationId": "update_event_api_v1_trees__tree_id__events__event_id__patch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "event_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Event Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EventUpdate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EventRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"events"
|
"events"
|
||||||
@@ -2361,6 +2483,114 @@
|
|||||||
],
|
],
|
||||||
"title": "EventRead"
|
"title": "EventRead"
|
||||||
},
|
},
|
||||||
|
"EventUpdate": {
|
||||||
|
"properties": {
|
||||||
|
"event_type": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Event Type"
|
||||||
|
},
|
||||||
|
"place_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Place Id"
|
||||||
|
},
|
||||||
|
"date_value": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Date Value"
|
||||||
|
},
|
||||||
|
"date_start": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Date Start"
|
||||||
|
},
|
||||||
|
"date_end": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Date End"
|
||||||
|
},
|
||||||
|
"date_precision": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Date Precision"
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Calendar"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Detail"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Notes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "EventUpdate"
|
||||||
|
},
|
||||||
"HTTPValidationError": {
|
"HTTPValidationError": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": {
|
"detail": {
|
||||||
@@ -2709,6 +2939,77 @@
|
|||||||
],
|
],
|
||||||
"title": "PersonRead"
|
"title": "PersonRead"
|
||||||
},
|
},
|
||||||
|
"PersonUpdate": {
|
||||||
|
"properties": {
|
||||||
|
"given": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Given"
|
||||||
|
},
|
||||||
|
"surname": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Surname"
|
||||||
|
},
|
||||||
|
"gender": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Gender"
|
||||||
|
},
|
||||||
|
"is_living": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Is Living"
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PersonPrivacy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Notes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "PersonUpdate"
|
||||||
|
},
|
||||||
"RegisterRequest": {
|
"RegisterRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"email": {
|
"email": {
|
||||||
|
|||||||
Reference in New Issue
Block a user