fe9a95c60d
Replaces the centered single-column of full-width cards with a proper application layout: a persistent left sidebar (Trees, and per-tree People/Sources/Media, with the tree name and sign-out) and a constrained content column. Marketing landing and auth pages are split out (own header/footer; centered auth with the logo). Adds a Media gallery (upload + image thumbnails / file tiles, served via the backend content endpoint). Events are no longer free-text: a curated event-type list (+ custom) and a structured date (qualifier + day/month/year) that composes a proper genealogical date. Regenerated the OpenAPI client. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
135 lines
4.4 KiB
TypeScript
135 lines
4.4 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"];
|
||
|
||
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 [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;
|
||
}
|
||
setItems(data ?? []);
|
||
setReady(true);
|
||
}, [router, treeId]);
|
||
|
||
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>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|