diff --git a/backend/app/api/v1/media.py b/backend/app/api/v1/media.py index 614be77..4de355d 100644 --- a/backend/app/api/v1/media.py +++ b/backend/app/api/v1/media.py @@ -1,20 +1,27 @@ 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.schemas.media import MediaRead 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.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 +router = APIRouter(prefix="/trees", tags=["media"]) + + @router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED) async def upload_media( tree_id: uuid.UUID, @@ -42,16 +49,36 @@ async def upload_media( event_id=event_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]) async def list_media( - tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, store: ObjectStoreDep + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser ) -> list[MediaRead]: 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) - 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) diff --git a/backend/app/integrations/objectstore/base.py b/backend/app/integrations/objectstore/base.py index 806d3aa..6bd0e1e 100644 --- a/backend/app/integrations/objectstore/base.py +++ b/backend/app/integrations/objectstore/base.py @@ -15,6 +15,9 @@ class ObjectStore(ABC): @abstractmethod async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ... + @abstractmethod + async def get_object(self, *, key: str) -> bytes: ... + @abstractmethod async def presigned_get_url(self, *, key: str) -> str: ... diff --git a/backend/app/integrations/objectstore/s3.py b/backend/app/integrations/objectstore/s3.py index 033bb02..039e163 100644 --- a/backend/app/integrations/objectstore/s3.py +++ b/backend/app/integrations/objectstore/s3.py @@ -44,6 +44,13 @@ class S3ObjectStore(ObjectStore): 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: return await asyncio.to_thread( self._client.generate_presigned_url, diff --git a/backend/app/services/media_service.py b/backend/app/services/media_service.py index 6b03a9c..013f8f7 100644 --- a/backend/app/services/media_service.py +++ b/backend/app/services/media_service.py @@ -80,6 +80,23 @@ async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) 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( session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID ) -> None: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 78712d7..7a61802 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -46,6 +46,9 @@ class FakeObjectStore(ObjectStore): async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: 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: return f"https://objects.test/{key}" diff --git a/backend/tests/test_media.py b/backend/tests/test_media.py index 0b488dd..224d5de 100644 --- a/backend/tests/test_media.py +++ b/backend/tests/test_media.py @@ -22,13 +22,18 @@ async def test_media_upload_list_delete(client): body = resp.json() assert body["original_filename"] == "scan.txt" 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"] listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h) assert listed.status_code == 200 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) assert resp.status_code == 204 assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0 diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f3ddafd..9c8ad77 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,10 +1,8 @@ import type { Metadata } from "next"; import { Fraunces, Inter } from "next/font/google"; -import Link from "next/link"; import "./globals.css"; -// Heritage display serif + clean humanist sans (per docs/brand typography). const serif = Fraunces({ subsets: ["latin"], variable: "--font-fraunces", @@ -23,36 +21,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - -
-
- - {/* eslint-disable-next-line @next/next/no-img-element */} - Provenance - - -
-
- -
{children}
- - - + {children} ); } diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index b79f6fb..5985211 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -31,7 +31,13 @@ export default function LoginPage() { } return ( - +
+
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + Provenance + + Sign in @@ -70,5 +76,7 @@ export default function LoginPage() {

+
+
); } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 9a7a7b2..f05ad9b 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -23,55 +23,83 @@ const features = [ export default function Home() { return ( -
-
-
-

- Family · Land · Provenance -

-

- Where it came from{" "} - matters. -

-

- 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. -

-
- - - - - - -
-
- -
-
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} - - -
+ Provenance + +
-
+ -
- {features.map((f) => ( -
-
- +
+
+
+

+ Family · Land · Provenance +

+

+ Where it came from matters. +

+

+ 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. +

+
+ + + + + +
-

{f.title}

-

{f.body}

- ))} -
+ +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + + +
+
+
+ +
+ {features.map((f) => ( +
+
+ +
+

{f.title}

+

{f.body}

+
+ ))} +
+ + +
+
+ where it came from matters + Self-hosted · source-available · your data, your infrastructure +
+
); } diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index fe67991..fb0083a 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -34,7 +34,13 @@ export default function RegisterPage() { } return ( - +
+
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + Provenance + + Create your account @@ -78,5 +84,7 @@ export default function RegisterPage() {

+
+
); } diff --git a/frontend/app/trees/[id]/media/page.tsx b/frontend/app/trees/[id]/media/page.tsx new file mode 100644 index 0000000..30db481 --- /dev/null +++ b/frontend/app/trees/[id]/media/page.tsx @@ -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([]); + const [ready, setReady] = useState(false); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(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) { + 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

Loading…

; + + return ( +
+
+

Media

+
+ + +
+
+ + {items.length === 0 ? ( +

+ No media yet — upload scans, photos, or documents and attach them to facts. +

+ ) : ( +
+ {items.map((m) => ( + + + {m.content_type.startsWith("image/") ? ( + // eslint-disable-next-line @next/next/no-img-element + {m.title + ) : ( +
+ {(m.original_filename.split(".").pop() ?? "file").toUpperCase()} +
+ )} +
+ +
+
+
+ {m.title ?? m.original_filename} +
+
{humanSize(m.byte_size)}
+
+ +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index 1d54034..2a46cc2 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -56,14 +56,7 @@ export default function TreeDetailPage() { return (
-
- - ← All trees - - - Sources → - -
+

People

diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index cf5ac97..b615063 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -22,6 +22,17 @@ type CitationCreate = components["schemas"]["CitationCreate"]; const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"; const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"]; +// Curated genealogical event vocabulary (with an escape hatch). +const EVENT_TYPES = [ + "birth", "death", "marriage", "divorce", "engagement", "baptism", "burial", + "residence", "census", "immigration", "emigration", "occupation", "education", + "military service", "naturalization", "other", +]; +const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; +const DATE_QUALS: Record = { exact: "", about: "ABT", before: "BEF", after: "AFT" }; +const pad = (n: number, len: number) => String(n).padStart(len, "0"); + export default function PersonDetailPage() { const router = useRouter(); const params = useParams<{ id: string; personId: string }>(); @@ -37,7 +48,11 @@ export default function PersonDetailPage() { const [ready, setReady] = useState(false); const [evType, setEvType] = useState("birth"); - const [evDate, setEvDate] = useState(""); + const [evTypeOther, setEvTypeOther] = useState(""); + const [dateQual, setDateQual] = useState("exact"); + const [dateDay, setDateDay] = useState(""); + const [dateMonth, setDateMonth] = useState(""); + const [dateYear, setDateYear] = useState(""); const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent"); const [relOther, setRelOther] = useState(""); @@ -97,15 +112,40 @@ export default function PersonDetailPage() { const eventCites = (id: string) => citations.filter((c) => c.event_id === id); const personCites = citations.filter((c) => c.person_id === personId); + function buildDate() { + const year = dateYear.trim(); + if (!year || Number.isNaN(Number(year))) { + return { date_value: null, date_start: null, date_precision: null }; + } + const m = dateMonth ? Number(dateMonth) : null; + const d = dateDay.trim() ? Number(dateDay) : null; + const parts: string[] = []; + if (d && m) parts.push(String(d)); + if (m) parts.push(GED_MON[m]); + parts.push(year); + const prefix = DATE_QUALS[dateQual]; + return { + date_value: (prefix ? `${prefix} ` : "") + parts.join(" "), + date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`, + date_precision: dateQual, + }; + } + async function addEvent(e: React.FormEvent) { e.preventDefault(); - if (!evType.trim()) return; + const event_type = evType === "other" ? evTypeOther.trim() : evType; + if (!event_type) return; + const { date_value, date_start, date_precision } = buildDate(); const { error } = await api.POST("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } }, - body: { event_type: evType, person_id: personId, date_value: evDate || null }, + body: { event_type, person_id: personId, date_value, date_start, date_precision }, }); if (!error) { - setEvDate(""); + setDateDay(""); + setDateMonth(""); + setDateYear(""); + setDateQual("exact"); + setEvTypeOther(""); load(); } } @@ -305,9 +345,68 @@ export default function PersonDetailPage() { ))} )} -
- setEvType(e.target.value)} /> - setEvDate(e.target.value)} /> + + + {evType === "other" && ( + + )} + + + +
diff --git a/frontend/app/trees/layout.tsx b/frontend/app/trees/layout.tsx new file mode 100644 index 0000000..c1956f1 --- /dev/null +++ b/frontend/app/trees/layout.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; + +import { AppSidebar } from "@/components/app-sidebar"; + +export default function TreesLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + +
+ {/* Compact bar for small screens (full sidebar is md+). */} +
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + Provenance + + + Trees + +
+ +
{children}
+
+
+ ); +} diff --git a/frontend/app/trees/page.tsx b/frontend/app/trees/page.tsx index 691ad23..b2d9e52 100644 --- a/frontend/app/trees/page.tsx +++ b/frontend/app/trees/page.tsx @@ -42,21 +42,11 @@ export default function TreesPage() { } } - async function logout() { - await api.POST("/api/v1/auth/logout"); - router.push("/login"); - } - if (!ready) return

Loading…

; return (
-
-

Your trees

- -
+

Your trees

diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx new file mode 100644 index 0000000..4631227 --- /dev/null +++ b/frontend/components/app-sidebar.tsx @@ -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", "", ...] + const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null; + const [treeName, setTreeName] = useState(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; + }) => ( + + + {label} + + ); + + return ( + + ); +} diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index 0234c29..22d8765 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -400,10 +400,75 @@ export interface paths { patch?: 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; export interface components { 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 * @enum {string} @@ -546,6 +611,42 @@ export interface components { /** Password */ 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 * @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"]; + }; + }; + }; + }; } diff --git a/frontend/openapi.json b/frontend/openapi.json index 76534b9..2cbfbcc 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -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": { "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": { "type": "string", "enum": [ @@ -1716,6 +1972,114 @@ ], "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": { "type": "string", "enum": [