From 064bb6ea656180cf5bfd2ba6adf099660b594a7d Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 13:17:33 -0400 Subject: [PATCH 1/2] 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) Signed-off-by: Justin Paul --- backend/app/api/v1/__init__.py | 13 ++- backend/app/api/v1/citations.py | 41 +++++++ backend/app/api/v1/sources.py | 48 ++++++++ backend/app/schemas/source.py | 61 ++++++++++ backend/app/services/citation_service.py | 141 +++++++++++++++++++++++ backend/app/services/source_service.py | 112 ++++++++++++++++++ backend/tests/test_sources.py | 96 +++++++++++++++ 7 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/v1/citations.py create mode 100644 backend/app/api/v1/sources.py create mode 100644 backend/app/schemas/source.py create mode 100644 backend/app/services/citation_service.py create mode 100644 backend/app/services/source_service.py create mode 100644 backend/tests/test_sources.py diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 88926cc..5a67ae3 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/citations.py b/backend/app/api/v1/citations.py new file mode 100644 index 0000000..f48e99d --- /dev/null +++ b/backend/app/api/v1/citations.py @@ -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 + ) diff --git a/backend/app/api/v1/sources.py b/backend/app/api/v1/sources.py new file mode 100644 index 0000000..e7f598a --- /dev/null +++ b/backend/app/api/v1/sources.py @@ -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) diff --git a/backend/app/schemas/source.py b/backend/app/schemas/source.py new file mode 100644 index 0000000..be35cb3 --- /dev/null +++ b/backend/app/schemas/source.py @@ -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 diff --git a/backend/app/services/citation_service.py b/backend/app/services/citation_service.py new file mode 100644 index 0000000..ee076ac --- /dev/null +++ b/backend/app/services/citation_service.py @@ -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() diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py new file mode 100644 index 0000000..927261b --- /dev/null +++ b/backend/app/services/source_service.py @@ -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() diff --git a/backend/tests/test_sources.py b/backend/tests/test_sources.py new file mode 100644 index 0000000..bc4226f --- /dev/null +++ b/backend/tests/test_sources.py @@ -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 -- 2.52.0 From 83f83ab64154fedc5841430b8f4bd1ef271cb47b Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 13:17:33 -0400 Subject: [PATCH 2/2] Add source manager and inline citing with 'sourced' badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Paul --- frontend/app/trees/[id]/page.tsx | 11 +- .../trees/[id]/persons/[personId]/page.tsx | 177 +++- frontend/app/trees/[id]/sources/page.tsx | 131 +++ frontend/lib/api/schema.d.ts | 410 ++++++++++ frontend/openapi.json | 766 ++++++++++++++++++ 5 files changed, 1449 insertions(+), 46 deletions(-) create mode 100644 frontend/app/trees/[id]/sources/page.tsx diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index b7907d7..1d54034 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -56,9 +56,14 @@ export default function TreeDetailPage() { return (
- - ← All trees - +
+ + ← All trees + + + Sources → + +
diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index 5a0729e..cf5ac97 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -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([]); const [events, setEvents] = useState([]); const [rels, setRels] = useState([]); + const [sources, setSources] = useState([]); + const [citations, setCitations] = useState([]); 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("biological"); + // Inline citation form: which fact is being cited ("p" = person, `e:`). + const [citeFor, setCiteFor] = useState(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) { + 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

Loading…

; if (!person) return

Not found.

; + // Inline "cite" control: a badge with count, a toggle, and the picker form. + function citeControl(key: string, target: Partial, cites: Citation[]) { + return ( + + {cites.length > 0 && ( + sourceName(c.source_id)).join(", ")} + > + ✓ {cites.length} sourced + + )} + {citeFor === key ? ( +
{ + e.preventDefault(); + addCitation(target); + }} + className="inline-flex items-center gap-1" + > + + setCitePage(e.target.value)} + /> + + +
+ ) : sources.length === 0 ? ( + + + add a source first + + ) : ( + + )} +
+ ); + } + const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) => items.length > 0 && (
@@ -162,7 +269,10 @@ export default function PersonDetailPage() { ← Back to tree -

{person.primary_name ?? "Unnamed person"}

+
+

{person.primary_name ?? "Unnamed person"}

+ {citeControl("p", { person_id: personId }, personCites)} +
@@ -172,39 +282,32 @@ export default function PersonDetailPage() { {events.length === 0 ? (

No events yet.

) : ( -
    +
      {events.map((ev) => ( -
    • +
    • {ev.event_type} {ev.date_value ? ( — {ev.date_value} ) : null} - + + {citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))} + +
    • ))}
    )}
    - setEvType(e.target.value)} - /> - setEvDate(e.target.value)} - /> + setEvType(e.target.value)} /> + setEvDate(e.target.value)} />
    @@ -235,21 +338,13 @@ export default function PersonDetailPage() { ) : (
    Add - setRelKind(e.target.value as typeof relKind)}> - setRelOther(e.target.value)}> {others.map((p) => (