62513ee22e
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>
1249 lines
48 KiB
TypeScript
1249 lines
48 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 [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>
|
||
);
|
||
}
|