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";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
type Media = components["schemas"]["MediaRead"];
|
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) {
|
function humanSize(bytes: number) {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -22,6 +25,7 @@ export default function MediaPage() {
|
|||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
|
|
||||||
const [items, setItems] = useState<Media[]>([]);
|
const [items, setItems] = useState<Media[]>([]);
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -34,10 +38,22 @@ export default function MediaPage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const ppl = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
setItems(data ?? []);
|
setItems(data ?? []);
|
||||||
|
setPeople(ppl.data ?? []);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -124,6 +140,19 @@ export default function MediaPage() {
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
@@ -116,6 +116,9 @@ export default function PersonDetailPage() {
|
|||||||
const [rels, setRels] = useState<Relationship[]>([]);
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
const [citations, setCitations] = useState<Citation[]>([]);
|
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 [ready, setReady] = useState(false);
|
||||||
|
|
||||||
const [evType, setEvType] = useState("birth");
|
const [evType, setEvType] = useState("birth");
|
||||||
@@ -174,7 +177,7 @@ export default function PersonDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPerson(p.data ?? null);
|
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", { params: { path: { tree_id: treeId } } }),
|
||||||
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
|
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
|
||||||
params: { path: { tree_id: treeId, person_id: personId } },
|
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}/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}/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}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
|
||||||
]);
|
]);
|
||||||
setPeople(all.data ?? []);
|
setPeople(all.data ?? []);
|
||||||
setNames(nm.data ?? []);
|
setNames(nm.data ?? []);
|
||||||
@@ -197,6 +201,7 @@ export default function PersonDetailPage() {
|
|||||||
setTree(tr.data ?? null);
|
setTree(tr.data ?? null);
|
||||||
setEvents(ev.data ?? []);
|
setEvents(ev.data ?? []);
|
||||||
setAllEvents(evAll.data ?? []);
|
setAllEvents(evAll.data ?? []);
|
||||||
|
setMedia(med.data ?? []);
|
||||||
setRels(rl.data ?? []);
|
setRels(rl.data ?? []);
|
||||||
setSources(src.data ?? []);
|
setSources(src.data ?? []);
|
||||||
setCitations(cit.data ?? []);
|
setCitations(cit.data ?? []);
|
||||||
@@ -461,6 +466,31 @@ export default function PersonDetailPage() {
|
|||||||
load();
|
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) {
|
function startEditPerson(current: Person) {
|
||||||
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
|
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
|
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
|
||||||
@@ -1082,6 +1112,87 @@ export default function PersonDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user