Link media to people (person page + media page)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Media[]>([]);
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(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() {
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
className={`${fieldCls} mt-2`}
|
||||
value={m.person_id ?? ""}
|
||||
onChange={(e) => linkPerson(m.id, e.target.value)}
|
||||
title="Link to a person"
|
||||
>
|
||||
<option value="">— link to person —</option>
|
||||
{people.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.primary_name ?? "Unnamed"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -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<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");
|
||||
@@ -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<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] ?? ""));
|
||||
@@ -1082,6 +1112,87 @@ export default function PersonDetailPage() {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user