Rebuild the UI as an app shell: left sidebar, media gallery, structured events

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>
This commit is contained in:
2026-06-06 21:56:05 -04:00
parent bd8ee9b647
commit fe9a95c60d
12 changed files with 1056 additions and 104 deletions
+134
View File
@@ -0,0 +1,134 @@
"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>
);
}
+1 -8
View File
@@ -56,14 +56,7 @@ export default function TreeDetailPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
All trees
</Link>
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
Sources
</Link>
</div>
<h1 className="text-2xl font-semibold">People</h1>
<Card>
<CardHeader>
@@ -22,6 +22,17 @@ type CitationCreate = components["schemas"]["CitationCreate"];
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
// Curated genealogical event vocabulary (with an escape hatch).
const EVENT_TYPES = [
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
"residence", "census", "immigration", "emigration", "occupation", "education",
"military service", "naturalization", "other",
];
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
const pad = (n: number, len: number) => String(n).padStart(len, "0");
export default function PersonDetailPage() {
const router = useRouter();
const params = useParams<{ id: string; personId: string }>();
@@ -37,7 +48,11 @@ export default function PersonDetailPage() {
const [ready, setReady] = useState(false);
const [evType, setEvType] = useState("birth");
const [evDate, setEvDate] = useState("");
const [evTypeOther, setEvTypeOther] = useState("");
const [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState("");
const [dateYear, setDateYear] = useState("");
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState("");
@@ -97,15 +112,40 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
function buildDate() {
const year = dateYear.trim();
if (!year || Number.isNaN(Number(year))) {
return { date_value: null, date_start: null, date_precision: null };
}
const m = dateMonth ? Number(dateMonth) : null;
const d = dateDay.trim() ? Number(dateDay) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(year);
const prefix = DATE_QUALS[dateQual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: dateQual,
};
}
async function addEvent(e: React.FormEvent) {
e.preventDefault();
if (!evType.trim()) return;
const event_type = evType === "other" ? evTypeOther.trim() : evType;
if (!event_type) return;
const { date_value, date_start, date_precision } = buildDate();
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } },
body: { event_type: evType, person_id: personId, date_value: evDate || null },
body: { event_type, person_id: personId, date_value, date_start, date_precision },
});
if (!error) {
setEvDate("");
setDateDay("");
setDateMonth("");
setDateYear("");
setDateQual("exact");
setEvTypeOther("");
load();
}
}
@@ -305,9 +345,68 @@ export default function PersonDetailPage() {
))}
</ul>
)}
<form onSubmit={addEvent} className="flex flex-wrap gap-2">
<Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} />
<Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} />
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Event</span>
<select
className={`${fieldCls} capitalize`}
value={evType}
onChange={(e) => setEvType(e.target.value)}
>
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
</label>
{evType === "other" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Type</span>
<Input
className="h-9 w-36"
placeholder="Custom"
value={evTypeOther}
onChange={(e) => setEvTypeOther(e.target.value)}
/>
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">When</span>
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Day</span>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="—"
value={dateDay}
onChange={(e) => setDateDay(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Month</span>
<select className={fieldCls} value={dateMonth} onChange={(e) => setDateMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Year</span>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="YYYY"
value={dateYear}
onChange={(e) => setDateYear(e.target.value)}
/>
</label>
<Button type="submit">Add event</Button>
</form>
</CardContent>
+28
View File
@@ -0,0 +1,28 @@
import Link from "next/link";
import { AppSidebar } from "@/components/app-sidebar";
export default function TreesLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] md:flex md:flex-col">
<AppSidebar />
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* Compact bar for small screens (full sidebar is md+). */}
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 md:hidden">
<Link href="/" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
</Link>
<Link href="/trees" className="text-sm text-bronze">
Trees
</Link>
</div>
<div className="mx-auto w-full max-w-4xl px-6 py-10 md:px-10">{children}</div>
</div>
</div>
);
}
+1 -11
View File
@@ -42,21 +42,11 @@ export default function TreesPage() {
}
}
async function logout() {
await api.POST("/api/v1/auth/logout");
router.push("/login");
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Your trees</h1>
<Button variant="ghost" size="sm" onClick={logout}>
Sign out
</Button>
</div>
<h1 className="text-2xl font-semibold">Your trees</h1>
<Card>
<CardHeader>