3 Commits

Author SHA1 Message Date
justin fe9a95c60d 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>
2026-06-06 21:56:05 -04:00
justin bd8ee9b647 Stream media through the backend (browser-reachable, privacy-checked)
Presigned URLs point at the internal minio:9000 host a browser can't reach. Add ObjectStore.get_object and a GET /media/{id}/content endpoint that resolves visibility and streams the bytes; MediaRead.url now points there. Keeps the object store private and downloads behind the privacy engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:56:04 -04:00
justin 660130f007 Merge pull request 'Phase 1: media (object storage) + background worker' (#8) from phase1-media into main
build-backend / build (push) Successful in 30s
2026-06-06 21:46:35 -04:00
18 changed files with 1126 additions and 112 deletions
+34 -7
View File
@@ -1,20 +1,27 @@
import uuid import uuid
from fastapi import APIRouter, File, Form, UploadFile, status from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead from app.schemas.media import MediaRead
from app.services import media_service, tree_service from app.services import media_service, tree_service
router = APIRouter(prefix="/trees", tags=["media"])
def _content_url(media) -> str:
return f"/api/v1/trees/{media.tree_id}/media/{media.id}/content"
def _with_url(media, url: str) -> MediaRead: def _read(media) -> MediaRead:
out = MediaRead.model_validate(media) out = MediaRead.model_validate(media)
out.url = url # Stream through the backend (privacy-checked, browser-reachable) rather
# than expose the internal object store directly.
out.url = _content_url(media)
return out return out
router = APIRouter(prefix="/trees", tags=["media"])
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED) @router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
async def upload_media( async def upload_media(
tree_id: uuid.UUID, tree_id: uuid.UUID,
@@ -42,16 +49,36 @@ async def upload_media(
event_id=event_id, event_id=event_id,
source_id=source_id, source_id=source_id,
) )
return _with_url(media, await store.presigned_get_url(key=media.storage_key)) return _read(media)
@router.get("/{tree_id}/media", response_model=list[MediaRead]) @router.get("/{tree_id}/media", response_model=list[MediaRead])
async def list_media( async def list_media(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, store: ObjectStoreDep tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[MediaRead]: ) -> list[MediaRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
items = await media_service.list_media(session, viewer_id=current.id, tree=tree) items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
return [_with_url(m, await store.presigned_get_url(key=m.storage_key)) for m in items] return [_read(m) for m in items]
@router.get("/{tree_id}/media/{media_id}/content")
async def media_content(
tree_id: uuid.UUID,
media_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.get_media(
session, viewer_id=current.id, tree=tree, media_id=media_id
)
data = await store.get_object(key=media.storage_key)
return Response(
content=data,
media_type=media.content_type,
headers={"Content-Disposition": f'inline; filename="{media.original_filename}"'},
)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -15,6 +15,9 @@ class ObjectStore(ABC):
@abstractmethod @abstractmethod
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ... async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
@abstractmethod
async def get_object(self, *, key: str) -> bytes: ...
@abstractmethod @abstractmethod
async def presigned_get_url(self, *, key: str) -> str: ... async def presigned_get_url(self, *, key: str) -> str: ...
@@ -44,6 +44,13 @@ class S3ObjectStore(ObjectStore):
ContentType=content_type, ContentType=content_type,
) )
async def get_object(self, *, key: str) -> bytes:
def _get() -> bytes:
obj = self._client.get_object(Bucket=self.bucket, Key=key)
return obj["Body"].read()
return await asyncio.to_thread(_get)
async def presigned_get_url(self, *, key: str) -> str: async def presigned_get_url(self, *, key: str) -> str:
return await asyncio.to_thread( return await asyncio.to_thread(
self._client.generate_presigned_url, self._client.generate_presigned_url,
+17
View File
@@ -80,6 +80,23 @@ async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree)
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def get_media(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, media_id: uuid.UUID
) -> Media:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
return media
async def delete_media( async def delete_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
) -> None: ) -> None:
+3
View File
@@ -46,6 +46,9 @@ class FakeObjectStore(ObjectStore):
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
self.objects[key] = (data, content_type) self.objects[key] = (data, content_type)
async def get_object(self, *, key: str) -> bytes:
return self.objects[key][0]
async def presigned_get_url(self, *, key: str) -> str: async def presigned_get_url(self, *, key: str) -> str:
return f"https://objects.test/{key}" return f"https://objects.test/{key}"
+6 -1
View File
@@ -22,13 +22,18 @@ async def test_media_upload_list_delete(client):
body = resp.json() body = resp.json()
assert body["original_filename"] == "scan.txt" assert body["original_filename"] == "scan.txt"
assert body["byte_size"] == 11 assert body["byte_size"] == 11
assert body["url"].startswith("https://objects.test/") assert body["url"] == f"/api/v1/trees/{tree_id}/media/{body['id']}/content"
media_id = body["id"] media_id = body["id"]
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h) listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
assert listed.status_code == 200 assert listed.status_code == 200
assert len(listed.json()) == 1 assert len(listed.json()) == 1
# The content endpoint streams the bytes back.
content = await client.get(f"/api/v1/trees/{tree_id}/media/{media_id}/content", headers=h)
assert content.status_code == 200
assert content.content == b"hello world"
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h) resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
assert resp.status_code == 204 assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0 assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
+1 -32
View File
@@ -1,10 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Fraunces, Inter } from "next/font/google"; import { Fraunces, Inter } from "next/font/google";
import Link from "next/link";
import "./globals.css"; import "./globals.css";
// Heritage display serif + clean humanist sans (per docs/brand typography).
const serif = Fraunces({ const serif = Fraunces({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-fraunces", variable: "--font-fraunces",
@@ -23,36 +21,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className={`${serif.variable} ${sans.variable}`}> <html lang="en" className={`${serif.variable} ${sans.variable}`}>
<body className="flex min-h-screen flex-col antialiased"> <body className="min-h-screen antialiased">{children}</body>
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]">
<div className="mx-auto flex max-w-5xl items-center justify-between px-5 py-3.5">
<Link href="/" className="flex items-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-[var(--foreground)]">
Trees
</Link>
<Link
href="/login"
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium transition-colors hover:border-bronze hover:text-bronze"
>
Sign in
</Link>
</nav>
</div>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-10">{children}</main>
<footer className="border-t border-[var(--border)]">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-5 py-6 text-sm text-[var(--muted)]">
<span className="font-serif text-base italic">where it came from matters</span>
<span>Self-hosted · source-available · your data, your infrastructure</span>
</div>
</footer>
</body>
</html> </html>
); );
} }
+9 -1
View File
@@ -31,7 +31,13 @@ export default function LoginPage() {
} }
return ( return (
<Card className="mx-auto max-w-md"> <div className="grid min-h-screen place-items-center px-4 py-10">
<div className="w-full max-w-md space-y-6">
<Link href="/" className="flex justify-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-8 w-auto" />
</Link>
<Card>
<CardHeader> <CardHeader>
<CardTitle>Sign in</CardTitle> <CardTitle>Sign in</CardTitle>
</CardHeader> </CardHeader>
@@ -70,5 +76,7 @@ export default function LoginPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
); );
} }
+72 -44
View File
@@ -23,55 +23,83 @@ const features = [
export default function Home() { export default function Home() {
return ( return (
<div className="space-y-20 py-6 sm:py-12"> <div className="flex min-h-screen flex-col">
<section className="grid items-center gap-10 sm:grid-cols-[1.3fr_1fr]"> <header className="border-b border-[var(--border)]">
<div> <div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze"> <Link href="/" aria-label="Provenance — home">
Family · Land · Provenance
</p>
<h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
Where it came from{" "}
<span className="italic text-bronze">matters</span>.
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
Trace your family and your land in one place every name, every parcel, every claim
linked to the record it came from. Self-hosted, sourced, and yours to keep.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/register">
<Button size="lg">Create your account</Button>
</Link>
<Link href="/login">
<Button size="lg" variant="outline">
Sign in
</Button>
</Link>
</div>
</div>
<div className="hidden justify-self-end sm:block">
<div className="relative grid h-64 w-64 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] shadow-[0_24px_60px_-24px_rgba(160,106,66,0.35)]">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-mark.svg" alt="" className="h-36 w-36" /> <img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
<MapPin className="absolute -right-2 top-10 h-7 w-7 text-bronze" /> </Link>
</div> <nav className="flex items-center gap-5 text-sm">
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
Trees
</Link>
<Link
href="/login"
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium hover:border-bronze hover:text-bronze"
>
Sign in
</Link>
</nav>
</div> </div>
</section> </header>
<section className="grid gap-5 sm:grid-cols-3"> <main className="mx-auto w-full max-w-5xl flex-1 px-6">
{features.map((f) => ( <section className="grid items-center gap-10 py-16 sm:grid-cols-[1.3fr_1fr] sm:py-24">
<div <div>
key={f.title} <p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-[0_1px_2px_rgba(26,26,23,0.04)]" Family · Land · Provenance
> </p>
<div className="grid h-10 w-10 place-items-center rounded-lg bg-bronze/12 text-bronze"> <h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
<f.icon className="h-5 w-5" /> Where it came from <span className="italic text-bronze">matters</span>.
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
Trace your family and your land in one place every name, every parcel, every claim
linked to the record it came from. Self-hosted, sourced, and yours to keep.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/register">
<Button size="lg">Create your account</Button>
</Link>
<Link href="/login">
<Button size="lg" variant="outline">
Sign in
</Button>
</Link>
</div> </div>
<h2 className="mt-4 text-lg font-semibold">{f.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">{f.body}</p>
</div> </div>
))}
</section> <div className="hidden justify-self-end sm:block">
<div className="relative grid h-64 w-64 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] shadow-[0_24px_60px_-24px_rgba(160,106,66,0.35)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-mark.svg" alt="" className="h-36 w-36" />
<MapPin className="absolute -right-2 top-10 h-7 w-7 text-bronze" />
</div>
</div>
</section>
<section className="grid gap-5 pb-20 sm:grid-cols-3">
{features.map((f) => (
<div
key={f.title}
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-[0_1px_2px_rgba(26,26,23,0.04)]"
>
<div className="grid h-10 w-10 place-items-center rounded-lg bg-bronze/12 text-bronze">
<f.icon className="h-5 w-5" />
</div>
<h2 className="mt-4 text-lg font-semibold">{f.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">{f.body}</p>
</div>
))}
</section>
</main>
<footer className="border-t border-[var(--border)]">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-6 py-6 text-sm text-[var(--muted)]">
<span className="font-serif text-base italic">where it came from matters</span>
<span>Self-hosted · source-available · your data, your infrastructure</span>
</div>
</footer>
</div> </div>
); );
} }
+9 -1
View File
@@ -34,7 +34,13 @@ export default function RegisterPage() {
} }
return ( return (
<Card className="mx-auto max-w-md"> <div className="grid min-h-screen place-items-center px-4 py-10">
<div className="w-full max-w-md space-y-6">
<Link href="/" className="flex justify-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-8 w-auto" />
</Link>
<Card>
<CardHeader> <CardHeader>
<CardTitle>Create your account</CardTitle> <CardTitle>Create your account</CardTitle>
</CardHeader> </CardHeader>
@@ -78,5 +84,7 @@ export default function RegisterPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
); );
} }
+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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <h1 className="text-2xl font-semibold">People</h1>
<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>
<Card> <Card>
<CardHeader> <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 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"]; 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() { export default function PersonDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string; personId: string }>(); const params = useParams<{ id: string; personId: string }>();
@@ -37,7 +48,11 @@ export default function PersonDetailPage() {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [evType, setEvType] = useState("birth"); 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 [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState(""); const [relOther, setRelOther] = useState("");
@@ -97,15 +112,40 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id); const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId); 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) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); 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", { const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } }, 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) { if (!error) {
setEvDate(""); setDateDay("");
setDateMonth("");
setDateYear("");
setDateQual("exact");
setEvTypeOther("");
load(); load();
} }
} }
@@ -305,9 +345,68 @@ export default function PersonDetailPage() {
))} ))}
</ul> </ul>
)} )}
<form onSubmit={addEvent} className="flex flex-wrap gap-2"> <form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
<Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} /> <label className="flex flex-col gap-1">
<Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} /> <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> <Button type="submit">Add event</Button>
</form> </form>
</CardContent> </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>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <h1 className="text-2xl font-semibold">Your trees</h1>
<h1 className="text-2xl font-bold">Your trees</h1>
<Button variant="ghost" size="sm" onClick={logout}>
Sign out
</Button>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { BookText, FolderTree, Image as ImageIcon, LogOut, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import { cn } from "@/lib/utils";
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
const [treeName, setTreeName] = useState<string | null>(null);
useEffect(() => {
if (!treeId) {
setTreeName(null);
return;
}
api
.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } })
.then((r) => setTreeName(r.data?.name ?? null));
}, [treeId]);
async function logout() {
await api.POST("/api/v1/auth/logout");
router.push("/login");
}
const Item = ({
href,
label,
icon: Icon,
active,
}: {
href: string;
label: string;
icon: typeof Users;
active: boolean;
}) => (
<Link
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-bronze/12 font-medium text-bronze"
: "text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-[var(--foreground)]",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
return (
<nav className="flex h-full flex-col gap-1 p-4">
<Link href="/" className="mb-5 flex items-center px-2" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link>
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
{treeId && (
<div className="mt-5 flex flex-col gap-1">
<div className="truncate px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
{treeName ?? "Tree"}
</div>
<Item
href={`/trees/${treeId}`}
label="People"
icon={Users}
active={pathname === `/trees/${treeId}` || pathname.startsWith(`/trees/${treeId}/persons`)}
/>
<Item
href={`/trees/${treeId}/sources`}
label="Sources"
icon={BookText}
active={pathname.startsWith(`/trees/${treeId}/sources`)}
/>
<Item
href={`/trees/${treeId}/media`}
label="Media"
icon={ImageIcon}
active={pathname.startsWith(`/trees/${treeId}/media`)}
/>
</div>
)}
<button
onClick={logout}
className="mt-auto flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-bronze"
>
<LogOut className="h-4 w-4 shrink-0" />
Sign out
</button>
</nav>
);
}
+229
View File
@@ -400,10 +400,75 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/media": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Media */
get: operations["list_media_api_v1_trees__tree_id__media_get"];
put?: never;
/** Upload Media */
post: operations["upload_media_api_v1_trees__tree_id__media_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/media/{media_id}/content": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Media Content */
get: operations["media_content_api_v1_trees__tree_id__media__media_id__content_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/media/{media_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Media */
delete: operations["delete_media_api_v1_trees__tree_id__media__media_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/** Body_upload_media_api_v1_trees__tree_id__media_post */
Body_upload_media_api_v1_trees__tree_id__media_post: {
/** File */
file: string;
/** Title */
title?: string | null;
/** Person Id */
person_id?: string | null;
/** Event Id */
event_id?: string | null;
/** Source Id */
source_id?: string | null;
};
/** /**
* CitationConfidence * CitationConfidence
* @enum {string} * @enum {string}
@@ -546,6 +611,42 @@ export interface components {
/** Password */ /** Password */
password: string; password: string;
}; };
/** MediaRead */
MediaRead: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Tree Id
* Format: uuid
*/
tree_id: string;
/** Original Filename */
original_filename: string;
/** Content Type */
content_type: string;
/** Byte Size */
byte_size: number;
/** Checksum Sha256 */
checksum_sha256: string;
/** Title */
title: string | null;
/** Person Id */
person_id: string | null;
/** Event Id */
event_id: string | null;
/** Source Id */
source_id: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
/** Url */
url?: string | null;
};
/** /**
* ParentChildQualifier * ParentChildQualifier
* @description Qualifies a parent_child edge so adoption/donor/blended families are * @description Qualifies a parent_child edge so adoption/donor/blended families are
@@ -1666,4 +1767,132 @@ export interface operations {
}; };
}; };
}; };
list_media_api_v1_trees__tree_id__media_get: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MediaRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_media_api_v1_trees__tree_id__media_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_media_api_v1_trees__tree_id__media_post"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MediaRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
media_content_api_v1_trees__tree_id__media__media_id__content_get: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
media_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_media_api_v1_trees__tree_id__media__media_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
media_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
} }
+364
View File
@@ -1188,10 +1188,266 @@
} }
} }
} }
},
"/api/v1/trees/{tree_id}/media": {
"post": {
"tags": [
"media"
],
"summary": "Upload Media",
"operationId": "upload_media_api_v1_trees__tree_id__media_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_upload_media_api_v1_trees__tree_id__media_post"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"get": {
"tags": [
"media"
],
"summary": "List Media",
"operationId": "list_media_api_v1_trees__tree_id__media_get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MediaRead"
},
"title": "Response List Media Api V1 Trees Tree Id Media Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/media/{media_id}/content": {
"get": {
"tags": [
"media"
],
"summary": "Media Content",
"operationId": "media_content_api_v1_trees__tree_id__media__media_id__content_get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "media_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Media Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/media/{media_id}": {
"delete": {
"tags": [
"media"
],
"summary": "Delete Media",
"operationId": "delete_media_api_v1_trees__tree_id__media__media_id__delete",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "media_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Media Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
"schemas": { "schemas": {
"Body_upload_media_api_v1_trees__tree_id__media_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
},
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Title"
},
"person_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Person Id"
},
"event_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Event Id"
},
"source_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Source Id"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_upload_media_api_v1_trees__tree_id__media_post"
},
"CitationConfidence": { "CitationConfidence": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -1716,6 +1972,114 @@
], ],
"title": "LoginRequest" "title": "LoginRequest"
}, },
"MediaRead": {
"properties": {
"id": {
"type": "string",
"format": "uuid",
"title": "Id"
},
"tree_id": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
},
"original_filename": {
"type": "string",
"title": "Original Filename"
},
"content_type": {
"type": "string",
"title": "Content Type"
},
"byte_size": {
"type": "integer",
"title": "Byte Size"
},
"checksum_sha256": {
"type": "string",
"title": "Checksum Sha256"
},
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Title"
},
"person_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Person Id"
},
"event_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Event Id"
},
"source_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Source Id"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Url"
}
},
"type": "object",
"required": [
"id",
"tree_id",
"original_filename",
"content_type",
"byte_size",
"checksum_sha256",
"title",
"person_id",
"event_id",
"source_id",
"created_at"
],
"title": "MediaRead"
},
"ParentChildQualifier": { "ParentChildQualifier": {
"type": "string", "type": "string",
"enum": [ "enum": [