From d27cc5dddc963baaff220df85547ccb0ce6b3aaf Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sun, 7 Jun 2026 11:19:25 -0400 Subject: [PATCH] Link media to people (person page + media page) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Media model already carried person_id/event_id/source_id and the upload route already accepted person_id — this surfaces it in the UI: - Person page: a Media card lists media linked to that person, uploads new files already linked ("Upload & link"), links existing unlinked media, and unlinks. - Media page: each item gets a person picker to link/unlink. Frontend only — no migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/app/trees/[id]/media/page.tsx | 29 +++++ .../trees/[id]/persons/[personId]/page.tsx | 115 +++++++++++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/frontend/app/trees/[id]/media/page.tsx b/frontend/app/trees/[id]/media/page.tsx index 30db481..e6466cd 100644 --- a/frontend/app/trees/[id]/media/page.tsx +++ b/frontend/app/trees/[id]/media/page.tsx @@ -9,6 +9,9 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; type Media = components["schemas"]["MediaRead"]; +type Person = components["schemas"]["PersonRead"]; + +const fieldCls = "h-8 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-xs"; function humanSize(bytes: number) { if (bytes < 1024) return `${bytes} B`; @@ -22,6 +25,7 @@ export default function MediaPage() { const treeId = params.id; const [items, setItems] = useState([]); + const [people, setPeople] = useState([]); const [ready, setReady] = useState(false); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); @@ -34,10 +38,22 @@ export default function MediaPage() { router.push("/login"); return; } + const ppl = await api.GET("/api/v1/trees/{tree_id}/persons", { + params: { path: { tree_id: treeId } }, + }); setItems(data ?? []); + setPeople(ppl.data ?? []); setReady(true); }, [router, treeId]); + async function linkPerson(mediaId: string, personId: string) { + await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", { + params: { path: { tree_id: treeId, media_id: mediaId } }, + body: { person_id: personId || null }, + }); + load(); + } + useEffect(() => { load(); }, [load]); @@ -124,6 +140,19 @@ export default function MediaPage() { × + ))} diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index 242cccf..6dc665f 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "@/lib/api/client"; import type { components } from "@/lib/api/schema"; @@ -116,6 +116,9 @@ export default function PersonDetailPage() { const [rels, setRels] = useState([]); const [sources, setSources] = useState([]); const [citations, setCitations] = useState([]); + const [media, setMedia] = useState([]); + const [uploadingMedia, setUploadingMedia] = useState(false); + const mediaFileRef = useRef(null); const [ready, setReady] = useState(false); const [evType, setEvType] = useState("birth"); @@ -174,7 +177,7 @@ export default function PersonDetailPage() { return; } setPerson(p.data ?? null); - const [all, nm, mine, tr, ev, rl, src, cit, evAll] = await Promise.all([ + 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 } }, @@ -190,6 +193,7 @@ export default function PersonDetailPage() { 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 ?? []); @@ -197,6 +201,7 @@ export default function PersonDetailPage() { setTree(tr.data ?? null); setEvents(ev.data ?? []); setAllEvents(evAll.data ?? []); + setMedia(med.data ?? []); setRels(rl.data ?? []); setSources(src.data ?? []); setCitations(cit.data ?? []); @@ -461,6 +466,31 @@ export default function PersonDetailPage() { load(); } + async function uploadMediaForPerson(e: React.ChangeEvent) { + 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] ?? "")); @@ -1082,6 +1112,87 @@ export default function PersonDetailPage() { )} + + + + Media + + + {(() => { + const personMedia = media.filter((m) => m.person_id === personId); + const unlinked = media.filter((m) => !m.person_id); + return ( + <> + {personMedia.length === 0 ? ( +

No media linked to this person yet.

+ ) : ( +
+ {personMedia.map((m) => ( +
+ + {m.content_type.startsWith("image/") ? ( + // eslint-disable-next-line @next/next/no-img-element + {m.title + ) : ( +
+ {(m.original_filename.split(".").pop() ?? "file").toUpperCase()} +
+ )} +
+
+ + {m.title ?? m.original_filename} + + +
+
+ ))} +
+ )} + +
+ + + {unlinked.length > 0 && ( + + )} +
+ + ); + })()} +
+
); }