Link media to people (person page + media page) #26

Merged
justin merged 1 commits from media-person-linking into main 2026-06-07 11:19:27 -04:00
2 changed files with 142 additions and 2 deletions
+29
View File
@@ -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>
);
}