5 Commits

Author SHA1 Message Date
justin 3a14fcc4ca Redesign the frontend: real type, hero landing, depth
Lifts the UI from wireframe to a finished heritage look: Fraunces (display serif) + Inter (sans) via next/font; a proper hero landing with a feature triad and the Origin mark; a warm bronze-tinted background gradient for depth; a sticky branded header and refined footer. Polished button (sizes + bronze focus ring + shadow), card (rounded-xl, soft layered shadow), and input (bronze focus) primitives that carry across every page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:34:47 -04:00
justin fc4cb0273e Merge pull request 'Phase 1: sources-first spine (sources + citations)' (#6) from phase1-sources into main
build-backend / build (push) Successful in 24s
build-frontend / build (push) Successful in 1m18s
2026-06-06 13:17:34 -04:00
justin 83f83ab641 Add source manager and inline citing with 'sourced' badges
New /trees/[id]/sources page (list + create sources). Person-detail page now loads tree sources + citations and shows a '✓ N sourced' badge with an inline cite picker (source + page) on each event and on the person. Tree view links to Sources. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin 064bb6ea65 Add sources and citations API (Phase 1: sources-first spine)
Source CRUD (reusable, tree-scoped) and Citation create/list/soft-delete linking one source to exactly one fact (person/event/name/relationship). Editor-gated writes, privacy-filtered reads, audit throughout; tenant + existence validation on source and target. list_citations returns all tree citations so the UI can render 'sourced' indicators in one round-trip. 22 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin fbb9d0195c Merge pull request 'Phase 1: events + relationships + person detail' (#5) from phase1-graph into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m16s
2026-06-06 12:11:11 -04:00
18 changed files with 2091 additions and 99 deletions
+12 -1
View File
@@ -2,7 +2,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, events, persons, relationships, trees, users from app.api.v1 import (
auth,
citations,
events,
persons,
relationships,
sources,
trees,
users,
)
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router) api_router.include_router(auth.router)
@@ -11,3 +20,5 @@ api_router.include_router(trees.router)
api_router.include_router(persons.router) api_router.include_router(persons.router)
api_router.include_router(events.router) api_router.include_router(events.router)
api_router.include_router(relationships.router) api_router.include_router(relationships.router)
api_router.include_router(sources.router)
api_router.include_router(citations.router)
+41
View File
@@ -0,0 +1,41 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead
from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"])
@router.post(
"/{tree_id}/citations", response_model=CitationRead, status_code=status.HTTP_201_CREATED
)
async def create_citation(
tree_id: uuid.UUID, data: CitationCreate, session: SessionDep, current: CurrentUser
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.create_citation(
session, actor=current, tree=tree, **data.model_dump()
)
return CitationRead.model_validate(citation)
@router.get("/{tree_id}/citations", response_model=list[CitationRead])
async def list_citations(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[CitationRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citations = await citation_service.list_citations(session, viewer_id=current.id, tree=tree)
return [CitationRead.model_validate(c) for c in citations]
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await citation_service.delete_citation(
session, actor=current, tree=tree, citation_id=citation_id
)
+48
View File
@@ -0,0 +1,48 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead
from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"])
@router.post("/{tree_id}/sources", response_model=SourceRead, status_code=status.HTTP_201_CREATED)
async def create_source(
tree_id: uuid.UUID, data: SourceCreate, session: SessionDep, current: CurrentUser
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.create_source(
session, actor=current, tree=tree, **data.model_dump()
)
return SourceRead.model_validate(source)
@router.get("/{tree_id}/sources", response_model=list[SourceRead])
async def list_sources(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[SourceRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
sources = await source_service.list_sources(session, viewer_id=current.id, tree=tree)
return [SourceRead.model_validate(s) for s in sources]
@router.get("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def get_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.get_source(
session, viewer_id=current.id, tree=tree, source_id=source_id
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await source_service.delete_source(session, actor=current, tree=tree, source_id=source_id)
+61
View File
@@ -0,0 +1,61 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import CitationConfidence
class SourceCreate(BaseModel):
title: str
author: str | None = None
source_type: str | None = None
repository: str | None = None
url: str | None = None
citation_text: str | None = None
publication_info: str | None = None
quality_note: str | None = None
class SourceRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
title: str
author: str | None
source_type: str | None
repository: str | None
url: str | None
citation_text: str | None
publication_info: str | None
quality_note: str | None
created_at: datetime
class CitationCreate(BaseModel):
source_id: uuid.UUID
# Exactly one target fact.
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
name_id: uuid.UUID | None = None
relationship_id: uuid.UUID | None = None
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
source_id: uuid.UUID
person_id: uuid.UUID | None
event_id: uuid.UUID | None
name_id: uuid.UUID | None
relationship_id: uuid.UUID | None
page: str | None
detail: str | None
confidence: CitationConfidence | None
created_at: datetime
+141
View File
@@ -0,0 +1,141 @@
"""Citation service. A citation links one Source to exactly one fact (person,
event, name, or relationship) within a tree — the provenance spine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import CitationConfidence
from app.models.event import Event
from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Conflict, Forbidden, NotFound
# Citation target column -> model, for tenant/existence validation.
_TARGET_MODELS = {
"person_id": Person,
"event_id": Event,
"name_id": Name,
"relationship_id": Relationship,
}
async def _in_tree(session: AsyncSession, model: type, id_: uuid.UUID, tree_id: uuid.UUID) -> bool:
row = (
await session.execute(
select(model.id).where(
model.id == id_, model.tree_id == tree_id, model.deleted_at.is_(None)
)
)
).scalar_one_or_none()
return row is not None
async def create_citation(
session: AsyncSession,
*,
actor: User,
tree: Tree,
source_id: uuid.UUID,
person_id: uuid.UUID | None = None,
event_id: uuid.UUID | None = None,
name_id: uuid.UUID | None = None,
relationship_id: uuid.UUID | None = None,
page: str | None = None,
detail: str | None = None,
confidence: CitationConfidence | None = None,
) -> Citation:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
targets = {
"person_id": person_id,
"event_id": event_id,
"name_id": name_id,
"relationship_id": relationship_id,
}
set_targets = {k: v for k, v in targets.items() if v is not None}
if len(set_targets) != 1:
raise Conflict("a citation must reference exactly one fact")
if not await _in_tree(session, Source, source_id, tree.id):
raise NotFound("source not found in this tree")
(target_col, target_id), = set_targets.items()
if not await _in_tree(session, _TARGET_MODELS[target_col], target_id, tree.id):
raise NotFound("cited fact not found in this tree")
citation = Citation(
tree_id=tree.id,
source_id=source_id,
person_id=person_id,
event_id=event_id,
name_id=name_id,
relationship_id=relationship_id,
page=page,
detail=detail,
confidence=confidence,
)
session.add(citation)
await session.flush()
record_audit(
session,
action="create",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"source_id": str(source_id), target_col: str(target_id)},
)
await session.commit()
await session.refresh(citation)
return citation
async def list_citations(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Citation]:
"""All citations in the tree — the UI maps them to facts to show 'sourced'
indicators in a single round-trip."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Citation)
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
.order_by(Citation.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
citation = (
await session.execute(
select(Citation).where(
Citation.id == citation_id,
Citation.tree_id == tree.id,
Citation.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if citation is None:
raise NotFound("citation not found")
citation.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+112
View File
@@ -0,0 +1,112 @@
"""Source service. Sources are reusable, tree-scoped records of an origin.
Writes require editor rights; reads go through the privacy engine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.source import Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
async def create_source(
session: AsyncSession,
*,
actor: User,
tree: Tree,
title: str,
author: str | None = None,
source_type: str | None = None,
repository: str | None = None,
url: str | None = None,
citation_text: str | None = None,
publication_info: str | None = None,
quality_note: str | None = None,
) -> Source:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = Source(
tree_id=tree.id,
title=title,
author=author,
source_type=source_type,
repository=repository,
url=url,
citation_text=citation_text,
publication_info=publication_info,
quality_note=quality_note,
)
session.add(source)
await session.flush()
record_audit(
session,
action="create",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"title": title},
)
await session.commit()
await session.refresh(source)
return source
async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Source]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Source)
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
.order_by(Source.title)
)
return list((await session.execute(stmt)).scalars().all())
async def get_source(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, source_id: uuid.UUID
) -> Source:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
return source
async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
source.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+96
View File
@@ -0,0 +1,96 @@
"""Sources and citations (the provenance spine)."""
import uuid
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tree_id = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
person = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Cy"}, headers=h
)
).json()["id"]
event = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": person},
headers=h,
)
).json()["id"]
return h, tree_id, person, event
async def test_source_and_citation_flow(client):
h, tree_id, person, event = await _setup(client, "src1@example.com")
src = await client.post(
f"/api/v1/trees/{tree_id}/sources",
json={"title": "1880 Census", "repository": "NARA"},
headers=h,
)
assert src.status_code == 201, src.text
source_id = src.json()["id"]
assert len((await client.get(f"/api/v1/trees/{tree_id}/sources", headers=h)).json()) == 1
# Cite the source on the birth event.
cite = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": source_id, "event_id": event, "page": "p. 4"},
headers=h,
)
assert cite.status_code == 201, cite.text
citation_id = cite.json()["id"]
citations = (await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()
assert len(citations) == 1
assert citations[0]["event_id"] == event
assert citations[0]["source_id"] == source_id
resp = await client.delete(f"/api/v1/trees/{tree_id}/citations/{citation_id}", headers=h)
assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()) == 0
async def test_citation_needs_exactly_one_target(client):
h, tree_id, person, event = await _setup(client, "src2@example.com")
source_id = (
await client.post(
f"/api/v1/trees/{tree_id}/sources", json={"title": "X"}, headers=h
)
).json()["id"]
# No target.
r = await client.post(
f"/api/v1/trees/{tree_id}/citations", json={"source_id": source_id}, headers=h
)
assert r.status_code == 409
# Two targets.
r = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": source_id, "person_id": person, "event_id": event},
headers=h,
)
assert r.status_code == 409
async def test_citation_unknown_source_404(client):
h, tree_id, person, _ = await _setup(client, "src3@example.com")
r = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": str(uuid.uuid4()), "person_id": person},
headers=h,
)
assert r.status_code == 404
async def test_non_member_cannot_create_source(client):
h, tree_id, _, _ = await _setup(client, "src4@example.com")
other = auth(await register(client, "src-intruder@example.com"))
r = await client.post(
f"/api/v1/trees/{tree_id}/sources", json={"title": "nope"}, headers=other
)
assert r.status_code == 403
+26 -14
View File
@@ -1,44 +1,56 @@
@import "tailwindcss"; @import "tailwindcss";
/* Brand palette (docs/brand): warm ink + bronze + paper. */ /* Brand palette + type (docs/brand): warm ink + bronze + paper, serif display. */
@theme { @theme {
--color-bronze: #a06a42; --color-bronze: #a06a42;
--color-bronze-deep: #8a5836; --color-bronze-deep: #8a5836;
--color-paper: #f7f3ec; --color-paper: #f7f3ec;
--color-ink: #1a1a17; --color-ink: #1a1a17;
--font-serif: Georgia, "Times New Roman", "Liberation Serif", ui-serif, serif; --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
} }
/* Adaptive tokens (ink/paper flip for light/dark; bronze + paper are constant). */ /* Adaptive tokens ink/paper flip for light/dark; bronze + paper are constant. */
:root { :root {
--background: #f7f3ec; /* paper */ --background: #f7f3ec;
--foreground: #1a1a17; /* ink */ --foreground: #1a1a17;
--muted: #6b6862; --muted: #6b6862;
--surface: #fbf8f2; --surface: #fffdf9;
--border: #e4dccb; --border: #e6ddcc;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #1a1a17; /* warm near-black */ --background: #161410;
--foreground: #f2eee6; /* warm off-white */ --foreground: #f2eee6;
--muted: #9a968e; --muted: #9a968e;
--surface: #232019; --surface: #211d17;
--border: #3a352c; --border: #353029;
} }
} }
body { body {
background: var(--background); /* A faint bronze warmth pooled at the top gives the flat paper some depth. */
background:
radial-gradient(
1100px 520px at 50% -8%,
color-mix(in srgb, var(--color-bronze) 9%, var(--background)),
var(--background) 60%
);
background-attachment: fixed;
color: var(--foreground); color: var(--foreground);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: var(--font-sans);
} }
/* Headings use the heritage serif register. */
h1, h1,
h2, h2,
h3, h3,
.font-serif { .font-serif {
font-family: var(--font-serif); font-family: var(--font-serif);
letter-spacing: -0.015em;
}
::selection {
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
} }
+29 -12
View File
@@ -1,38 +1,55 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Fraunces, Inter } from "next/font/google";
import Link from "next/link"; import Link from "next/link";
import "./globals.css"; import "./globals.css";
// Heritage display serif + clean humanist sans (per docs/brand typography).
const serif = Fraunces({
subsets: ["latin"],
variable: "--font-fraunces",
display: "swap",
axes: ["opsz"],
});
const sans = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Provenance", title: "Provenance — where it came from matters",
description: "Where it came from matters — family and land, every fact sourced.", description:
"Trace your family and your land in one place — every fact linked to the record it came from. Self-hosted, sourced, and yours to keep.",
icons: { icon: "/favicon.svg" }, icons: { icon: "/favicon.svg" },
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en" className={`${serif.variable} ${sans.variable}`}>
<body className="flex min-h-screen flex-col"> <body className="flex min-h-screen flex-col antialiased">
<header className="border-b border-[var(--border)]"> <header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3"> <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"> <Link href="/" className="flex items-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" /> <img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link> </Link>
<nav className="flex gap-5 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-bronze"> <Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-[var(--foreground)]">
Trees Trees
</Link> </Link>
<Link href="/login" className="text-[var(--muted)] transition-colors hover:text-bronze"> <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 Sign in
</Link> </Link>
</nav> </nav>
</div> </div>
</header> </header>
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-10">{children}</main>
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-10">{children}</main>
<footer className="border-t border-[var(--border)]"> <footer className="border-t border-[var(--border)]">
<div className="mx-auto max-w-3xl px-4 py-6 text-sm italic text-[var(--muted)]"> <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)]">
where it came from matters <span className="font-serif text-base italic">where it came from matters</span>
<span>Self-hosted · source-available · your data, your infrastructure</span>
</div> </div>
</footer> </footer>
</body> </body>
+68 -18
View File
@@ -1,27 +1,77 @@
import { BadgeCheck, MapPin, ShieldCheck, Users } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const features = [
{
icon: Users,
title: "Family and land, together",
body: "People, relationships, and life events alongside property and chain-of-title — one documented story of where you come from.",
},
{
icon: BadgeCheck,
title: "Sourced or it didn't happen",
body: "Every fact can carry a citation back to the record it came from. Sources are first-class, reusable, and visible.",
},
{
icon: ShieldCheck,
title: "Yours to keep",
body: "Self-hosted and source-available. Living people protected by default. Open formats — export anytime, run it anywhere.",
},
];
export default function Home() { export default function Home() {
return ( return (
<div className="space-y-8 py-4"> <div className="space-y-20 py-6 sm:py-12">
<div className="space-y-4"> <section className="grid items-center gap-10 sm:grid-cols-[1.3fr_1fr]">
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl"> <div>
Where it came from matters <p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
</h1> Family · Land · Provenance
<p className="max-w-prose text-lg text-[var(--muted)]"> </p>
Trace where you come from your family <span className="text-bronze">and</span> your <h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
land with every fact linked to a source, on infrastructure you control. Where it came from{" "}
</p> <span className="italic text-bronze">matters</span>.
</div> </h1>
<div className="flex flex-wrap gap-3"> <p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
<Link href="/register"> Trace your family and your land in one place every name, every parcel, every claim
<Button>Create an account</Button> linked to the record it came from. Self-hosted, sourced, and yours to keep.
</Link> </p>
<Link href="/login"> <div className="mt-8 flex flex-wrap gap-3">
<Button variant="outline">Sign in</Button> <Link href="/register">
</Link> <Button size="lg">Create your account</Button>
</div> </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 */}
<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 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>
</div> </div>
); );
} }
+8 -3
View File
@@ -56,9 +56,14 @@ export default function TreeDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline"> <div className="flex items-center justify-between">
All trees <Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
</Link> All trees
</Link>
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
Sources
</Link>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -15,10 +15,11 @@ type Event = components["schemas"]["EventRead"];
type Relationship = components["schemas"]["RelationshipRead"]; type Relationship = components["schemas"]["RelationshipRead"];
type Qualifier = components["schemas"]["ParentChildQualifier"]; type Qualifier = components["schemas"]["ParentChildQualifier"];
type RelCreate = components["schemas"]["RelationshipCreate"]; type RelCreate = components["schemas"]["RelationshipCreate"];
type Source = components["schemas"]["SourceRead"];
type Citation = components["schemas"]["CitationRead"];
type CitationCreate = components["schemas"]["CitationCreate"];
const fieldCls = const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
"h-10 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"];
export default function PersonDetailPage() { export default function PersonDetailPage() {
@@ -31,6 +32,8 @@ export default function PersonDetailPage() {
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]); const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [citations, setCitations] = useState<Citation[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [evType, setEvType] = useState("birth"); const [evType, setEvType] = useState("birth");
@@ -40,6 +43,11 @@ export default function PersonDetailPage() {
const [relOther, setRelOther] = useState(""); const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological"); const [relQual, setRelQual] = useState<Qualifier>("biological");
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
const [citeFor, setCiteFor] = useState<string | null>(null);
const [citeSource, setCiteSource] = useState("");
const [citePage, setCitePage] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
const p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", { const p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
@@ -49,7 +57,7 @@ export default function PersonDetailPage() {
return; return;
} }
setPerson(p.data ?? null); setPerson(p.data ?? null);
const [all, ev, rl] = await Promise.all([ const [all, ev, rl, src, cit] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", { api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
@@ -57,10 +65,14 @@ export default function PersonDetailPage() {
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/relationships", { api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/relationships", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
}), }),
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
]); ]);
setPeople(all.data ?? []); setPeople(all.data ?? []);
setEvents(ev.data ?? []); setEvents(ev.data ?? []);
setRels(rl.data ?? []); setRels(rl.data ?? []);
setSources(src.data ?? []);
setCitations(cit.data ?? []);
setReady(true); setReady(true);
}, [router, treeId, personId]); }, [router, treeId, personId]);
@@ -72,12 +84,18 @@ export default function PersonDetailPage() {
const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])); const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"]));
return (id: string) => m.get(id) ?? "Unknown"; return (id: string) => m.get(id) ?? "Unknown";
}, [people]); }, [people]);
const sourceName = useMemo(() => {
const m = new Map(sources.map((s) => [s.id, s.title]));
return (id: string) => m.get(id) ?? "source";
}, [sources]);
const others = people.filter((p) => p.id !== personId); const others = people.filter((p) => p.id !== personId);
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId); const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId); const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
const partners = rels.filter((r) => r.type === "partnership"); const partners = rels.filter((r) => r.type === "partnership");
const siblings = rels.filter((r) => r.type === "sibling"); const siblings = rels.filter((r) => r.type === "sibling");
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
async function addEvent(e: React.FormEvent) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -91,7 +109,6 @@ export default function PersonDetailPage() {
load(); load();
} }
} }
async function removeEvent(id: string) { async function removeEvent(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", { await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: id } }, params: { path: { tree_id: treeId, event_id: id } },
@@ -121,7 +138,6 @@ export default function PersonDetailPage() {
load(); load();
} }
} }
async function removeRel(id: string) { async function removeRel(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", { await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
params: { path: { tree_id: treeId, relationship_id: id } }, params: { path: { tree_id: treeId, relationship_id: id } },
@@ -129,9 +145,100 @@ export default function PersonDetailPage() {
load(); load();
} }
async function addCitation(target: Partial<CitationCreate>) {
if (!citeSource) return;
const body: CitationCreate = { source_id: citeSource, page: citePage || null, ...target };
const { error } = await api.POST("/api/v1/trees/{tree_id}/citations", {
params: { path: { tree_id: treeId } },
body,
});
if (!error) {
setCiteFor(null);
setCiteSource("");
setCitePage("");
load();
}
}
async function removeCitation(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/citations/{citation_id}", {
params: { path: { tree_id: treeId, citation_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>; if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
// Inline "cite" control: a badge with count, a toggle, and the picker form.
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
return (
<span className="inline-flex items-center gap-2">
{cites.length > 0 && (
<span
className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze"
title={cites.map((c) => sourceName(c.source_id)).join(", ")}
>
{cites.length} sourced
</span>
)}
{citeFor === key ? (
<form
onSubmit={(e) => {
e.preventDefault();
addCitation(target);
}}
className="inline-flex items-center gap-1"
>
<select
className={fieldCls}
value={citeSource}
onChange={(e) => setCiteSource(e.target.value)}
>
<option value=""> source </option>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
<input
className={`${fieldCls} w-24`}
placeholder="page"
value={citePage}
onChange={(e) => setCitePage(e.target.value)}
/>
<Button type="submit" size="sm">
cite
</Button>
<button
type="button"
onClick={() => setCiteFor(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
) : sources.length === 0 ? (
<Link href={`/trees/${treeId}/sources`} className="text-xs text-[var(--muted)] hover:underline">
+ add a source first
</Link>
) : (
<button
type="button"
onClick={() => {
setCiteFor(key);
setCiteSource("");
setCitePage("");
}}
className="text-xs text-bronze hover:underline"
>
+ cite
</button>
)}
</span>
);
}
const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) => const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) =>
items.length > 0 && ( items.length > 0 && (
<div> <div>
@@ -162,7 +269,10 @@ export default function PersonDetailPage() {
Back to tree Back to tree
</Link> </Link>
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1> <div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
{citeControl("p", { person_id: personId }, personCites)}
</div>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -172,39 +282,32 @@ export default function PersonDetailPage() {
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No events yet.</p> <p className="text-sm text-[var(--muted)]">No events yet.</p>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-2">
{events.map((ev) => ( {events.map((ev) => (
<li key={ev.id} className="flex items-center justify-between text-sm"> <li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span> <span>
<span className="font-medium capitalize">{ev.event_type}</span> <span className="font-medium capitalize">{ev.event_type}</span>
{ev.date_value ? ( {ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span> <span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null} ) : null}
</span> </span>
<button <span className="flex items-center gap-3">
onClick={() => removeEvent(ev.id)} {citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
className="text-[var(--muted)] hover:text-bronze" <button
aria-label="Remove" onClick={() => removeEvent(ev.id)}
> className="text-[var(--muted)] hover:text-bronze"
× aria-label="Remove"
</button> >
×
</button>
</span>
</li> </li>
))} ))}
</ul> </ul>
)} )}
<form onSubmit={addEvent} className="flex flex-wrap gap-2"> <form onSubmit={addEvent} className="flex flex-wrap gap-2">
<Input <Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} />
className="w-36" <Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} />
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)}
/>
<Button type="submit">Add event</Button> <Button type="submit">Add event</Button>
</form> </form>
</CardContent> </CardContent>
@@ -235,21 +338,13 @@ export default function PersonDetailPage() {
) : ( ) : (
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2"> <form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--muted)]">Add</span> <span className="text-sm text-[var(--muted)]">Add</span>
<select <select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
className={fieldCls}
value={relKind}
onChange={(e) => setRelKind(e.target.value as typeof relKind)}
>
<option value="parent">parent</option> <option value="parent">parent</option>
<option value="child">child</option> <option value="child">child</option>
<option value="partner">partner</option> <option value="partner">partner</option>
<option value="sibling">sibling</option> <option value="sibling">sibling</option>
</select> </select>
<select <select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}>
className={fieldCls}
value={relOther}
onChange={(e) => setRelOther(e.target.value)}
>
<option value=""> person </option> <option value=""> person </option>
{others.map((p) => ( {others.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
@@ -258,11 +353,7 @@ export default function PersonDetailPage() {
))} ))}
</select> </select>
{(relKind === "parent" || relKind === "child") && ( {(relKind === "parent" || relKind === "child") && (
<select <select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
className={fieldCls}
value={relQual}
onChange={(e) => setRelQual(e.target.value as Qualifier)}
>
{QUALIFIERS.map((q) => ( {QUALIFIERS.map((q) => (
<option key={q} value={q}> <option key={q} value={q}>
{q} {q}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, 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, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Source = components["schemas"]["SourceRead"];
export default function SourcesPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [sources, setSources] = useState<Source[]>([]);
const [ready, setReady] = useState(false);
const [title, setTitle] = useState("");
const [repository, setRepository] = useState("");
const [url, setUrl] = useState("");
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/sources", {
params: { path: { tree_id: treeId } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setSources(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function add(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
const { error } = await api.POST("/api/v1/trees/{tree_id}/sources", {
params: { path: { tree_id: treeId } },
body: { title, repository: repository || null, url: url || null },
});
if (!error) {
setTitle("");
setRepository("");
setUrl("");
load();
}
}
async function remove(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/sources/{source_id}", {
params: { path: { tree_id: treeId, source_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
Back to tree
</Link>
<h1 className="text-2xl font-bold">Sources</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">New source</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={add} className="flex flex-wrap gap-2">
<Input
className="w-56"
placeholder="Title (e.g. 1880 US Census)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
className="w-40"
placeholder="Repository"
value={repository}
onChange={(e) => setRepository(e.target.value)}
/>
<Input
className="w-48"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Button type="submit">Add source</Button>
</form>
</CardContent>
</Card>
{sources.length === 0 ? (
<p className="text-[var(--muted)]">No sources yet add one above, then cite it on facts.</p>
) : (
<ul className="space-y-2">
{sources.map((s) => (
<li key={s.id}>
<Card>
<CardContent className="flex items-start justify-between gap-3 p-4">
<div>
<div className="font-medium">{s.title}</div>
<div className="text-sm text-[var(--muted)]">
{[s.repository, s.url].filter(Boolean).join(" · ")}
</div>
</div>
<button
onClick={() => remove(s.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
+6 -6
View File
@@ -4,19 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
// Bronze is the brand accent; paper reads cleanly on it. default: "bg-bronze text-paper shadow-sm hover:bg-bronze-deep hover:shadow",
default: "bg-bronze text-paper hover:bg-bronze-deep",
outline: outline:
"border border-bronze text-bronze bg-transparent hover:bg-bronze hover:text-paper", "border border-[var(--border)] bg-[var(--surface)] hover:border-bronze hover:text-bronze",
ghost: "text-[var(--foreground)] hover:bg-bronze/10", ghost: "text-[var(--foreground)] hover:bg-bronze/10",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 px-4 text-sm",
sm: "h-9 px-3", sm: "h-9 px-3 text-sm",
lg: "h-12 px-6 text-base",
}, },
}, },
defaultVariants: { variant: "default", size: "default" }, defaultVariants: { variant: "default", size: "default" },
+1 -1
View File
@@ -6,7 +6,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
return ( return (
<div <div
className={cn( className={cn(
"rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-sm", "rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-[0_1px_2px_rgba(26,26,23,0.04),0_8px_24px_-12px_rgba(26,26,23,0.10)]",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -7,7 +7,7 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
<input <input
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm placeholder:text-[var(--muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze disabled:opacity-50", "flex h-10 w-full rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm placeholder:text-[var(--muted)] transition-colors focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40 disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
+410
View File
@@ -329,10 +329,143 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/sources": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Sources */
get: operations["list_sources_api_v1_trees__tree_id__sources_get"];
put?: never;
/** Create Source */
post: operations["create_source_api_v1_trees__tree_id__sources_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/sources/{source_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Source */
get: operations["get_source_api_v1_trees__tree_id__sources__source_id__get"];
put?: never;
post?: never;
/** Delete Source */
delete: operations["delete_source_api_v1_trees__tree_id__sources__source_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/citations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Citations */
get: operations["list_citations_api_v1_trees__tree_id__citations_get"];
put?: never;
/** Create Citation */
post: operations["create_citation_api_v1_trees__tree_id__citations_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/citations/{citation_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Citation */
delete: operations["delete_citation_api_v1_trees__tree_id__citations__citation_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: {
/**
* CitationConfidence
* @enum {string}
*/
CitationConfidence: "high" | "medium" | "low";
/** CitationCreate */
CitationCreate: {
/**
* Source Id
* Format: uuid
*/
source_id: string;
/** Person Id */
person_id?: string | null;
/** Event Id */
event_id?: string | null;
/** Name Id */
name_id?: string | null;
/** Relationship Id */
relationship_id?: string | null;
/** Page */
page?: string | null;
/** Detail */
detail?: string | null;
confidence?: components["schemas"]["CitationConfidence"] | null;
};
/** CitationRead */
CitationRead: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Tree Id
* Format: uuid
*/
tree_id: string;
/**
* Source Id
* Format: uuid
*/
source_id: string;
/** Person Id */
person_id: string | null;
/** Event Id */
event_id: string | null;
/** Name Id */
name_id: string | null;
/** Relationship Id */
relationship_id: string | null;
/** Page */
page: string | null;
/** Detail */
detail: string | null;
confidence: components["schemas"]["CitationConfidence"] | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** EventCreate */ /** EventCreate */
EventCreate: { EventCreate: {
/** Event Type */ /** Event Type */
@@ -552,6 +685,59 @@ export interface components {
*/ */
expires_at: string; expires_at: string;
}; };
/** SourceCreate */
SourceCreate: {
/** Title */
title: string;
/** Author */
author?: string | null;
/** Source Type */
source_type?: string | null;
/** Repository */
repository?: string | null;
/** Url */
url?: string | null;
/** Citation Text */
citation_text?: string | null;
/** Publication Info */
publication_info?: string | null;
/** Quality Note */
quality_note?: string | null;
};
/** SourceRead */
SourceRead: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Tree Id
* Format: uuid
*/
tree_id: string;
/** Title */
title: string;
/** Author */
author: string | null;
/** Source Type */
source_type: string | null;
/** Repository */
repository: string | null;
/** Url */
url: string | null;
/** Citation Text */
citation_text: string | null;
/** Publication Info */
publication_info: string | null;
/** Quality Note */
quality_note: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** TokenRequest */ /** TokenRequest */
TokenRequest: { TokenRequest: {
/** Token */ /** Token */
@@ -1256,4 +1442,228 @@ export interface operations {
}; };
}; };
}; };
list_sources_api_v1_trees__tree_id__sources_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"]["SourceRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_source_api_v1_trees__tree_id__sources_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SourceCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SourceRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_source_api_v1_trees__tree_id__sources__source_id__get: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
source_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SourceRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_source_api_v1_trees__tree_id__sources__source_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
source_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"];
};
};
};
};
list_citations_api_v1_trees__tree_id__citations_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"]["CitationRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_citation_api_v1_trees__tree_id__citations_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CitationCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CitationRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_citation_api_v1_trees__tree_id__citations__citation_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
citation_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"];
};
};
};
};
} }
+766
View File
@@ -849,10 +849,571 @@
} }
} }
} }
},
"/api/v1/trees/{tree_id}/sources": {
"post": {
"tags": [
"sources"
],
"summary": "Create Source",
"operationId": "create_source_api_v1_trees__tree_id__sources_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SourceCreate"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SourceRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"get": {
"tags": [
"sources"
],
"summary": "List Sources",
"operationId": "list_sources_api_v1_trees__tree_id__sources_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/SourceRead"
},
"title": "Response List Sources Api V1 Trees Tree Id Sources Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/sources/{source_id}": {
"get": {
"tags": [
"sources"
],
"summary": "Get Source",
"operationId": "get_source_api_v1_trees__tree_id__sources__source_id__get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "source_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Source Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SourceRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"sources"
],
"summary": "Delete Source",
"operationId": "delete_source_api_v1_trees__tree_id__sources__source_id__delete",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "source_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Source Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/citations": {
"post": {
"tags": [
"citations"
],
"summary": "Create Citation",
"operationId": "create_citation_api_v1_trees__tree_id__citations_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CitationCreate"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CitationRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"get": {
"tags": [
"citations"
],
"summary": "List Citations",
"operationId": "list_citations_api_v1_trees__tree_id__citations_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/CitationRead"
},
"title": "Response List Citations Api V1 Trees Tree Id Citations Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/citations/{citation_id}": {
"delete": {
"tags": [
"citations"
],
"summary": "Delete Citation",
"operationId": "delete_citation_api_v1_trees__tree_id__citations__citation_id__delete",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "citation_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Citation Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
"schemas": { "schemas": {
"CitationConfidence": {
"type": "string",
"enum": [
"high",
"medium",
"low"
],
"title": "CitationConfidence"
},
"CitationCreate": {
"properties": {
"source_id": {
"type": "string",
"format": "uuid",
"title": "Source Id"
},
"person_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Person Id"
},
"event_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Event Id"
},
"name_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Name Id"
},
"relationship_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Relationship Id"
},
"page": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Page"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"confidence": {
"anyOf": [
{
"$ref": "#/components/schemas/CitationConfidence"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"source_id"
],
"title": "CitationCreate"
},
"CitationRead": {
"properties": {
"id": {
"type": "string",
"format": "uuid",
"title": "Id"
},
"tree_id": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
},
"source_id": {
"type": "string",
"format": "uuid",
"title": "Source Id"
},
"person_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Person Id"
},
"event_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Event Id"
},
"name_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Name Id"
},
"relationship_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Relationship Id"
},
"page": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Page"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"confidence": {
"anyOf": [
{
"$ref": "#/components/schemas/CitationConfidence"
},
{
"type": "null"
}
]
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"id",
"tree_id",
"source_id",
"person_id",
"event_id",
"name_id",
"relationship_id",
"page",
"detail",
"confidence",
"created_at"
],
"title": "CitationRead"
},
"EventCreate": { "EventCreate": {
"properties": { "properties": {
"event_type": { "event_type": {
@@ -1512,6 +2073,211 @@
], ],
"title": "SessionRead" "title": "SessionRead"
}, },
"SourceCreate": {
"properties": {
"title": {
"type": "string",
"title": "Title"
},
"author": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Author"
},
"source_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Type"
},
"repository": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Repository"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Url"
},
"citation_text": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Citation Text"
},
"publication_info": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Publication Info"
},
"quality_note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Quality Note"
}
},
"type": "object",
"required": [
"title"
],
"title": "SourceCreate"
},
"SourceRead": {
"properties": {
"id": {
"type": "string",
"format": "uuid",
"title": "Id"
},
"tree_id": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
},
"title": {
"type": "string",
"title": "Title"
},
"author": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Author"
},
"source_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Type"
},
"repository": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Repository"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Url"
},
"citation_text": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Citation Text"
},
"publication_info": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Publication Info"
},
"quality_note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Quality Note"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"id",
"tree_id",
"title",
"author",
"source_type",
"repository",
"url",
"citation_text",
"publication_info",
"quality_note",
"created_at"
],
"title": "SourceRead"
},
"TokenRequest": { "TokenRequest": {
"properties": { "properties": {
"token": { "token": {