Merge pull request 'App-shell UI overhaul + media stream endpoint' (#9) from ui-shell into main
This commit was merged in pull request #9.
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+229
@@ -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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
Reference in New Issue
Block a user