58400ffdf7
The person page fetched the entire tree on every open — all persons (to build a
name map + power the relative pickers) and all events (to find partnership
events). On a 2k-person tree that's a ~230KB person list + ~600KB event list per
view. Now it loads only what the page shows:
Frontend:
- The relationship & spouse pickers use the backend's fuzzy pg_trgm search
(debounced, typo-tolerant) instead of substring-filtering a preloaded array —
better search, and no need to preload every person. PersonCombobox gained an
`onSearch` server mode (client `people` mode still works).
- The page drops the all-persons and all-events fetches; it resolves just this
person's relatives' names via GET /persons?ids=..., and reads partnership
events from the per-person events endpoint.
Backend:
- GET /trees/{id}/persons?ids=a,b,c — batch by id (privacy-filtered, names
batched), for relative-name display.
- list_events_for_person (member path) now also returns the person's partnership
events, so the page needn't scan every event in the tree.
Adversarial review (frontend logic + backend/privacy) found no issues. Suite 105
passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
1296 lines
50 KiB
TypeScript
1296 lines
50 KiB
TypeScript
"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 [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);
|
||
// Person-scoped fetches only — the page no longer pulls the whole tree.
|
||
// /persons/{id}/events now includes this person's partnership events too.
|
||
const [nm, mine, tr, ev, rl, src, cit, med] = await Promise.all([
|
||
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}/media", { params: { path: { tree_id: treeId } } }),
|
||
]);
|
||
setNames(nm.data ?? []);
|
||
setMe(mine.data ?? null);
|
||
setTree(tr.data ?? null);
|
||
setEvents(ev.data ?? []);
|
||
setMedia(med.data ?? []);
|
||
setRels(rl.data ?? []);
|
||
setSources(src.data ?? []);
|
||
setCitations(cit.data ?? []);
|
||
// Resolve the names of just this person's relatives (for display), by id —
|
||
// not the whole tree. The relationship/spouse pickers search on demand.
|
||
const relList = rl.data ?? [];
|
||
const relatedIds = Array.from(
|
||
new Set(
|
||
relList
|
||
.flatMap((r) => [r.person_from_id, r.person_to_id])
|
||
.filter((id): id is string => !!id && id !== personId),
|
||
),
|
||
);
|
||
if (relatedIds.length) {
|
||
const rel = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||
params: { path: { tree_id: treeId }, query: { ids: relatedIds.join(",") } },
|
||
});
|
||
setPeople(rel.data ?? []);
|
||
} else {
|
||
setPeople([]);
|
||
}
|
||
setReady(true);
|
||
}, [router, treeId, personId]);
|
||
|
||
// Server-side fuzzy search for the relative/spouse pickers — avoids loading
|
||
// every person just to search.
|
||
const searchPeople = useCallback(
|
||
async (query: string) => {
|
||
const r = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||
params: { path: { tree_id: treeId }, query: { q: query } },
|
||
});
|
||
return (r.data ?? []).filter((pp) => pp.id !== personId);
|
||
},
|
||
[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 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; the
|
||
// /persons/{id}/events endpoint now returns them alongside personal events.
|
||
const myPartnerRels = rels.filter(
|
||
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
|
||
);
|
||
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);
|
||
const shownEvents = events;
|
||
|
||
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);
|
||
}
|
||
|
||
// Quick one-click sex setter — no need to open the full edit form. PATCH is
|
||
// exclude_unset on the backend, so sending only `gender` leaves the rest.
|
||
async function setGender(value: "male" | "female" | null) {
|
||
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||
params: { path: { tree_id: treeId, person_id: personId } },
|
||
body: { gender: value },
|
||
});
|
||
load();
|
||
}
|
||
|
||
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"}
|
||
</span>
|
||
{/* One-click sex setter — no edit mode needed. Active = current; click it again to clear. */}
|
||
<span className="inline-flex items-center overflow-hidden rounded-md border border-[var(--border)] text-base font-normal">
|
||
<button
|
||
type="button"
|
||
onClick={() => setGender(person.gender === "male" ? null : "male")}
|
||
aria-pressed={person.gender === "male"}
|
||
title={person.gender === "male" ? "Male — click to clear" : "Set male"}
|
||
className={`px-3 py-1 leading-none transition-colors ${
|
||
person.gender === "male"
|
||
? "bg-[rgb(120,159,172)] text-white"
|
||
: "text-[var(--muted)] hover:bg-bronze/[0.07]"
|
||
}`}
|
||
>
|
||
♂
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setGender(person.gender === "female" ? null : "female")}
|
||
aria-pressed={person.gender === "female"}
|
||
title={person.gender === "female" ? "Female — click to clear" : "Set female"}
|
||
className={`border-l border-[var(--border)] px-3 py-1 leading-none transition-colors ${
|
||
person.gender === "female"
|
||
? "bg-[rgb(196,138,146)] text-white"
|
||
: "text-[var(--muted)] hover:bg-bronze/[0.07]"
|
||
}`}
|
||
>
|
||
♀
|
||
</button>
|
||
</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
|
||
onSearch={searchPeople}
|
||
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>
|
||
)}
|
||
|
||
<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
|
||
onSearch={searchPeople}
|
||
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>
|
||
);
|
||
}
|