Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a14fcc4ca | |||
| fc4cb0273e | |||
| 83f83ab641 | |||
| 064bb6ea65 | |||
| fbb9d0195c |
@@ -2,7 +2,16 @@
|
||||
|
||||
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.include_router(auth.router)
|
||||
@@ -11,3 +20,5 @@ api_router.include_router(trees.router)
|
||||
api_router.include_router(persons.router)
|
||||
api_router.include_router(events.router)
|
||||
api_router.include_router(relationships.router)
|
||||
api_router.include_router(sources.router)
|
||||
api_router.include_router(citations.router)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -1,44 +1,56 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Brand palette (docs/brand): warm ink + bronze + paper. */
|
||||
/* Brand palette + type (docs/brand): warm ink + bronze + paper, serif display. */
|
||||
@theme {
|
||||
--color-bronze: #a06a42;
|
||||
--color-bronze-deep: #8a5836;
|
||||
--color-paper: #f7f3ec;
|
||||
--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 {
|
||||
--background: #f7f3ec; /* paper */
|
||||
--foreground: #1a1a17; /* ink */
|
||||
--background: #f7f3ec;
|
||||
--foreground: #1a1a17;
|
||||
--muted: #6b6862;
|
||||
--surface: #fbf8f2;
|
||||
--border: #e4dccb;
|
||||
--surface: #fffdf9;
|
||||
--border: #e6ddcc;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #1a1a17; /* warm near-black */
|
||||
--foreground: #f2eee6; /* warm off-white */
|
||||
--background: #161410;
|
||||
--foreground: #f2eee6;
|
||||
--muted: #9a968e;
|
||||
--surface: #232019;
|
||||
--border: #3a352c;
|
||||
--surface: #211d17;
|
||||
--border: #353029;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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,
|
||||
h2,
|
||||
h3,
|
||||
.font-serif {
|
||||
font-family: var(--font-serif);
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
|
||||
}
|
||||
|
||||
+29
-12
@@ -1,38 +1,55 @@
|
||||
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",
|
||||
display: "swap",
|
||||
axes: ["opsz"],
|
||||
});
|
||||
const sans = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Provenance",
|
||||
description: "Where it came from matters — family and land, every fact sourced.",
|
||||
title: "Provenance — where it came from matters",
|
||||
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" },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-[var(--border)]">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||
<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 gap-5 text-sm">
|
||||
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-bronze">
|
||||
<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="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
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</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)]">
|
||||
<div className="mx-auto max-w-3xl px-4 py-6 text-sm italic text-[var(--muted)]">
|
||||
where it came from matters
|
||||
<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>
|
||||
|
||||
+68
-18
@@ -1,27 +1,77 @@
|
||||
import { BadgeCheck, MapPin, ShieldCheck, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
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() {
|
||||
return (
|
||||
<div className="space-y-8 py-4">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||
Where it came from matters
|
||||
</h1>
|
||||
<p className="max-w-prose text-lg text-[var(--muted)]">
|
||||
Trace where you come from — your family <span className="text-bronze">and</span> your
|
||||
land — with every fact linked to a source, on infrastructure you control.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/register">
|
||||
<Button>Create an account</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button variant="outline">Sign in</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<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)]">
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,9 +56,14 @@ export default function TreeDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
||||
← All trees
|
||||
</Link>
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -15,10 +15,11 @@ type Event = components["schemas"]["EventRead"];
|
||||
type Relationship = components["schemas"]["RelationshipRead"];
|
||||
type Qualifier = components["schemas"]["ParentChildQualifier"];
|
||||
type RelCreate = components["schemas"]["RelationshipCreate"];
|
||||
type Source = components["schemas"]["SourceRead"];
|
||||
type Citation = components["schemas"]["CitationRead"];
|
||||
type CitationCreate = components["schemas"]["CitationCreate"];
|
||||
|
||||
const fieldCls =
|
||||
"h-10 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
|
||||
|
||||
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
|
||||
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
|
||||
|
||||
export default function PersonDetailPage() {
|
||||
@@ -31,6 +32,8 @@ export default function PersonDetailPage() {
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [rels, setRels] = useState<Relationship[]>([]);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [citations, setCitations] = useState<Citation[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [evType, setEvType] = useState("birth");
|
||||
@@ -40,6 +43,11 @@ export default function PersonDetailPage() {
|
||||
const [relOther, setRelOther] = useState("");
|
||||
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 p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
@@ -49,7 +57,7 @@ export default function PersonDetailPage() {
|
||||
return;
|
||||
}
|
||||
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/{person_id}/events", {
|
||||
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", {
|
||||
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 ?? []);
|
||||
setEvents(ev.data ?? []);
|
||||
setRels(rl.data ?? []);
|
||||
setSources(src.data ?? []);
|
||||
setCitations(cit.data ?? []);
|
||||
setReady(true);
|
||||
}, [router, treeId, personId]);
|
||||
|
||||
@@ -72,12 +84,18 @@ export default function PersonDetailPage() {
|
||||
const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"]));
|
||||
return (id: string) => m.get(id) ?? "Unknown";
|
||||
}, [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 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 partners = rels.filter((r) => r.type === "partnership");
|
||||
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) {
|
||||
e.preventDefault();
|
||||
@@ -91,7 +109,6 @@ export default function PersonDetailPage() {
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEvent(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", {
|
||||
params: { path: { tree_id: treeId, event_id: id } },
|
||||
@@ -121,7 +138,6 @@ export default function PersonDetailPage() {
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRel(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
|
||||
params: { path: { tree_id: treeId, relationship_id: id } },
|
||||
@@ -129,9 +145,100 @@ export default function PersonDetailPage() {
|
||||
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 (!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) =>
|
||||
items.length > 0 && (
|
||||
<div>
|
||||
@@ -162,7 +269,10 @@ export default function PersonDetailPage() {
|
||||
← Back to tree
|
||||
</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>
|
||||
<CardHeader>
|
||||
@@ -172,39 +282,32 @@ export default function PersonDetailPage() {
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
<ul className="space-y-2">
|
||||
{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 className="font-medium capitalize">{ev.event_type}</span>
|
||||
{ev.date_value ? (
|
||||
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeEvent(ev.id)}
|
||||
className="text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="flex items-center gap-3">
|
||||
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
|
||||
<button
|
||||
onClick={() => removeEvent(ev.id)}
|
||||
className="text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</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)}
|
||||
/>
|
||||
<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)} />
|
||||
<Button type="submit">Add event</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -235,21 +338,13 @@ export default function PersonDetailPage() {
|
||||
) : (
|
||||
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-[var(--muted)]">Add</span>
|
||||
<select
|
||||
className={fieldCls}
|
||||
value={relKind}
|
||||
onChange={(e) => setRelKind(e.target.value as typeof relKind)}
|
||||
>
|
||||
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
|
||||
<option value="parent">parent</option>
|
||||
<option value="child">child</option>
|
||||
<option value="partner">partner</option>
|
||||
<option value="sibling">sibling</option>
|
||||
</select>
|
||||
<select
|
||||
className={fieldCls}
|
||||
value={relOther}
|
||||
onChange={(e) => setRelOther(e.target.value)}
|
||||
>
|
||||
<select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}>
|
||||
<option value="">— person —</option>
|
||||
{others.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
@@ -258,11 +353,7 @@ export default function PersonDetailPage() {
|
||||
))}
|
||||
</select>
|
||||
{(relKind === "parent" || relKind === "child") && (
|
||||
<select
|
||||
className={fieldCls}
|
||||
value={relQual}
|
||||
onChange={(e) => setRelQual(e.target.value as Qualifier)}
|
||||
>
|
||||
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
||||
{QUALIFIERS.map((q) => (
|
||||
<option key={q} value={q}>
|
||||
{q}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
// Bronze is the brand accent; paper reads cleanly on it.
|
||||
default: "bg-bronze text-paper hover:bg-bronze-deep",
|
||||
default: "bg-bronze text-paper shadow-sm hover:bg-bronze-deep hover:shadow",
|
||||
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",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3",
|
||||
default: "h-10 px-4 text-sm",
|
||||
sm: "h-9 px-3 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
|
||||
@@ -6,7 +6,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
|
||||
<input
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Vendored
+410
@@ -329,10 +329,143 @@ export interface paths {
|
||||
patch?: 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 interface components {
|
||||
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: {
|
||||
/** Event Type */
|
||||
@@ -552,6 +685,59 @@ export interface components {
|
||||
*/
|
||||
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: {
|
||||
/** 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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"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": {
|
||||
"properties": {
|
||||
"event_type": {
|
||||
@@ -1512,6 +2073,211 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"token": {
|
||||
|
||||
Reference in New Issue
Block a user