App-shell UI overhaul + media stream endpoint #9
+1
-32
@@ -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 (
|
||||
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
||||
<body className="flex min-h-screen flex-col antialiased">
|
||||
<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>
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,13 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<CardTitle>Sign in</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -70,5 +76,7 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-44
@@ -23,55 +23,83 @@ const features = [
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="space-y-20 py-6 sm:py-12">
|
||||
<section className="grid items-center gap-10 sm:grid-cols-[1.3fr_1fr]">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
|
||||
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)]">
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-[var(--border)]">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" aria-label="Provenance — home">
|
||||
{/* 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>
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||
</Link>
|
||||
<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>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-5 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" />
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6">
|
||||
<section className="grid items-center gap-10 py-16 sm:grid-cols-[1.3fr_1fr] sm:py-24">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,13 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<CardTitle>Create your account</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -78,5 +84,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
||||
← All trees
|
||||
</Link>
|
||||
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
|
||||
Sources →
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">People</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -22,6 +22,17 @@ type CitationCreate = components["schemas"]["CitationCreate"];
|
||||
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
|
||||
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
|
||||
|
||||
// Curated genealogical event vocabulary (with an escape hatch).
|
||||
const EVENT_TYPES = [
|
||||
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
|
||||
"residence", "census", "immigration", "emigration", "occupation", "education",
|
||||
"military service", "naturalization", "other",
|
||||
];
|
||||
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
||||
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
||||
const pad = (n: number, len: number) => String(n).padStart(len, "0");
|
||||
|
||||
export default function PersonDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string; personId: string }>();
|
||||
@@ -37,7 +48,11 @@ export default function PersonDetailPage() {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [evType, setEvType] = useState("birth");
|
||||
const [evDate, setEvDate] = useState("");
|
||||
const [evTypeOther, setEvTypeOther] = useState("");
|
||||
const [dateQual, setDateQual] = useState("exact");
|
||||
const [dateDay, setDateDay] = useState("");
|
||||
const [dateMonth, setDateMonth] = useState("");
|
||||
const [dateYear, setDateYear] = useState("");
|
||||
|
||||
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
||||
const [relOther, setRelOther] = useState("");
|
||||
@@ -97,15 +112,40 @@ export default function PersonDetailPage() {
|
||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||
const personCites = citations.filter((c) => c.person_id === personId);
|
||||
|
||||
function buildDate() {
|
||||
const year = dateYear.trim();
|
||||
if (!year || Number.isNaN(Number(year))) {
|
||||
return { date_value: null, date_start: null, date_precision: null };
|
||||
}
|
||||
const m = dateMonth ? Number(dateMonth) : null;
|
||||
const d = dateDay.trim() ? Number(dateDay) : null;
|
||||
const parts: string[] = [];
|
||||
if (d && m) parts.push(String(d));
|
||||
if (m) parts.push(GED_MON[m]);
|
||||
parts.push(year);
|
||||
const prefix = DATE_QUALS[dateQual];
|
||||
return {
|
||||
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
|
||||
date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
|
||||
date_precision: dateQual,
|
||||
};
|
||||
}
|
||||
|
||||
async function addEvent(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!evType.trim()) return;
|
||||
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
||||
if (!event_type) return;
|
||||
const { date_value, date_start, date_precision } = buildDate();
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { event_type: evType, person_id: personId, date_value: evDate || null },
|
||||
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
||||
});
|
||||
if (!error) {
|
||||
setEvDate("");
|
||||
setDateDay("");
|
||||
setDateMonth("");
|
||||
setDateYear("");
|
||||
setDateQual("exact");
|
||||
setEvTypeOther("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
@@ -305,9 +345,68 @@ export default function PersonDetailPage() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={addEvent} className="flex flex-wrap gap-2">
|
||||
<Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} />
|
||||
<Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} />
|
||||
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-[var(--muted)]">Event</span>
|
||||
<select
|
||||
className={`${fieldCls} capitalize`}
|
||||
value={evType}
|
||||
onChange={(e) => setEvType(e.target.value)}
|
||||
>
|
||||
{EVENT_TYPES.map((t) => (
|
||||
<option key={t} value={t} className="capitalize">
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{evType === "other" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-[var(--muted)]">Type</span>
|
||||
<Input
|
||||
className="h-9 w-36"
|
||||
placeholder="Custom"
|
||||
value={evTypeOther}
|
||||
onChange={(e) => setEvTypeOther(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-[var(--muted)]">When</span>
|
||||
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
|
||||
<option value="exact">on</option>
|
||||
<option value="about">about</option>
|
||||
<option value="before">before</option>
|
||||
<option value="after">after</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-[var(--muted)]">Day</span>
|
||||
<input
|
||||
className={`${fieldCls} w-14`}
|
||||
inputMode="numeric"
|
||||
placeholder="—"
|
||||
value={dateDay}
|
||||
onChange={(e) => setDateDay(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-[var(--muted)]">Month</span>
|
||||
<select className={fieldCls} value={dateMonth} onChange={(e) => setDateMonth(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-[var(--muted)]">Year</span>
|
||||
<input
|
||||
className={`${fieldCls} w-20`}
|
||||
inputMode="numeric"
|
||||
placeholder="YYYY"
|
||||
value={dateYear}
|
||||
onChange={(e) => setDateYear(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">Add event</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Your trees</h1>
|
||||
<Button variant="ghost" size="sm" onClick={logout}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">Your trees</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -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;
|
||||
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 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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user