d27cc5dddc
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>
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
"use client";
|
||
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useCallback, useEffect, 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 } 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`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
export default function MediaPage() {
|
||
const router = useRouter();
|
||
const params = useParams<{ id: string }>();
|
||
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);
|
||
|
||
const load = useCallback(async () => {
|
||
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/media", {
|
||
params: { path: { tree_id: treeId } },
|
||
});
|
||
if (response.status === 401) {
|
||
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]);
|
||
|
||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setUploading(true);
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
// Plain fetch for multipart (same origin → cookie auth via Caddy).
|
||
await fetch(`/api/v1/trees/${treeId}/media`, {
|
||
method: "POST",
|
||
body: fd,
|
||
credentials: "include",
|
||
});
|
||
setUploading(false);
|
||
if (fileRef.current) fileRef.current.value = "";
|
||
load();
|
||
}
|
||
|
||
async function remove(id: string) {
|
||
await api.DELETE("/api/v1/trees/{tree_id}/media/{media_id}", {
|
||
params: { path: { tree_id: treeId, media_id: id } },
|
||
});
|
||
load();
|
||
}
|
||
|
||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<h1 className="text-2xl font-semibold">Media</h1>
|
||
<div>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
onChange={onFile}
|
||
className="hidden"
|
||
id="media-upload"
|
||
/>
|
||
<Button onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||
{uploading ? "Uploading…" : "Upload file"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{items.length === 0 ? (
|
||
<p className="text-[var(--muted)]">
|
||
No media yet — upload scans, photos, or documents and attach them to facts.
|
||
</p>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||
{items.map((m) => (
|
||
<Card key={m.id} className="overflow-hidden">
|
||
<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] text-3xl font-serif text-bronze">
|
||
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
|
||
</div>
|
||
)}
|
||
</a>
|
||
<CardContent className="p-3">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-medium" title={m.original_filename}>
|
||
{m.title ?? m.original_filename}
|
||
</div>
|
||
<div className="text-xs text-[var(--muted)]">{humanSize(m.byte_size)}</div>
|
||
</div>
|
||
<button
|
||
onClick={() => remove(m.id)}
|
||
className="text-[var(--muted)] hover:text-bronze"
|
||
aria-label="Remove"
|
||
>
|
||
×
|
||
</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>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|