Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83f83ab641 | |||
| 064bb6ea65 | |||
| fbb9d0195c | |||
| 1f25eb2f21 | |||
| d6e2df4a61 | |||
| a799d101b5 | |||
| 0b9d72c878 | |||
| 2d0635e710 | |||
| 768d1b23d4 | |||
| 11f0f79866 | |||
| b8f5c35045 | |||
| 9e6cf6e5b7 | |||
| 4e115086e6 |
@@ -72,6 +72,17 @@ Don't get ahead of the phases. GEDCOM lands before the assistant (so AI writes t
|
|||||||
|
|
||||||
Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution.
|
Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution.
|
||||||
|
|
||||||
|
## Brand
|
||||||
|
|
||||||
|
Visual identity lives in [docs/brand/](docs/brand/) (see its README for full guidance). Use these as the frontend's design tokens:
|
||||||
|
|
||||||
|
- **Ink** (primary text/marks): `#1A1A17` light / `#F2EEE6` dark
|
||||||
|
- **Bronze** (accent, constant): `#A06A42`
|
||||||
|
- **Paper** (knockout on bronze, constant): `#F7F3EC`
|
||||||
|
- **Muted** (secondary text): `#6B6862` light / `#9A968E` dark
|
||||||
|
|
||||||
|
Wordmark is a serif (heritage register); UI body/secondary text is a humanist sans. Logo lockup: `docs/brand/provenance-logo.svg`; app icon/favicon: `docs/brand/provenance-icon.svg` and `favicon.svg`. Don't recolor outside the palette or add gradients/shadows — the look is flat and warm.
|
||||||
|
|
||||||
## Owner & contact
|
## Owner & contact
|
||||||
|
|
||||||
Maintainer: **Justin Paul** (`justin@jpaul.io`). This deployment targets a home lab: Authentik at `auth.jpaul.io` for auth, `mail.jpaul.io` for SMTP, behind Caddy + Cloudflare Tunnel.
|
Maintainer: **Justin Paul** (`justin@jpaul.io`). This deployment targets a home lab: Authentik at `auth.jpaul.io` for auth, `mail.jpaul.io` for SMTP, behind Caddy + Cloudflare Tunnel.
|
||||||
|
|||||||
@@ -2,10 +2,23 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, persons, trees, users
|
from app.api.v1 import (
|
||||||
|
auth,
|
||||||
|
citations,
|
||||||
|
events,
|
||||||
|
persons,
|
||||||
|
relationships,
|
||||||
|
sources,
|
||||||
|
trees,
|
||||||
|
users,
|
||||||
|
)
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(trees.router)
|
api_router.include_router(trees.router)
|
||||||
api_router.include_router(persons.router)
|
api_router.include_router(persons.router)
|
||||||
|
api_router.include_router(events.router)
|
||||||
|
api_router.include_router(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,39 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.event import EventCreate, EventRead
|
||||||
|
from app.services import event_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/events", response_model=EventRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_event(
|
||||||
|
tree_id: uuid.UUID, data: EventCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> EventRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
event = await event_service.create_event(
|
||||||
|
session, actor=current, tree=tree, **data.model_dump()
|
||||||
|
)
|
||||||
|
return EventRead.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||||
|
async def list_person_events(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
events = await event_service.list_events_for_person(
|
||||||
|
session, viewer_id=current.id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_event(
|
||||||
|
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await event_service.delete_event(session, actor=current, tree=tree, event_id=event_id)
|
||||||
@@ -41,3 +41,14 @@ async def list_persons(
|
|||||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
||||||
return [PersonRead.model_validate(p) for p in persons]
|
return [PersonRead.model_validate(p) for p in persons]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
|
async def get_person(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.get_person(
|
||||||
|
session, viewer_id=current.id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.relationship import RelationshipCreate, RelationshipRead
|
||||||
|
from app.services import relationship_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["relationships"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/relationships",
|
||||||
|
response_model=RelationshipRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_relationship(
|
||||||
|
tree_id: uuid.UUID, data: RelationshipCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> RelationshipRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
relationship = await relationship_service.create_relationship(
|
||||||
|
session, actor=current, tree=tree, **data.model_dump()
|
||||||
|
)
|
||||||
|
return RelationshipRead.model_validate(relationship)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tree_id}/persons/{person_id}/relationships",
|
||||||
|
response_model=list[RelationshipRead],
|
||||||
|
)
|
||||||
|
async def list_person_relationships(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[RelationshipRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rels = await relationship_service.list_relationships_for_person(
|
||||||
|
session, viewer_id=current.id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||||
|
)
|
||||||
|
async def delete_relationship(
|
||||||
|
tree_id: uuid.UUID, relationship_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await relationship_service.delete_relationship(
|
||||||
|
session, actor=current, tree=tree, relationship_id=relationship_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,39 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class EventCreate(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
# Exactly one subject: a person or a partnership (relationship).
|
||||||
|
person_id: uuid.UUID | None = None
|
||||||
|
relationship_id: uuid.UUID | None = None
|
||||||
|
place_id: uuid.UUID | None = None
|
||||||
|
# Verbatim date string (e.g. "ABT 1850") and/or a normalized range.
|
||||||
|
date_value: str | None = None
|
||||||
|
date_start: date | None = None
|
||||||
|
date_end: date | None = None
|
||||||
|
date_precision: str | None = None
|
||||||
|
calendar: str = "gregorian"
|
||||||
|
detail: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
event_type: str
|
||||||
|
person_id: uuid.UUID | None
|
||||||
|
relationship_id: uuid.UUID | None
|
||||||
|
place_id: uuid.UUID | None
|
||||||
|
date_value: str | None
|
||||||
|
date_start: date | None
|
||||||
|
date_end: date | None
|
||||||
|
date_precision: str | None
|
||||||
|
calendar: str
|
||||||
|
detail: str | None
|
||||||
|
notes: str | None
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipCreate(BaseModel):
|
||||||
|
type: RelationshipType
|
||||||
|
person_from_id: uuid.UUID
|
||||||
|
person_to_id: uuid.UUID
|
||||||
|
# Only meaningful for parent_child edges (from = parent, to = child).
|
||||||
|
qualifier: ParentChildQualifier | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
type: RelationshipType
|
||||||
|
person_from_id: uuid.UUID
|
||||||
|
person_to_id: uuid.UUID
|
||||||
|
qualifier: ParentChildQualifier | None
|
||||||
|
notes: str | None
|
||||||
|
created_at: datetime
|
||||||
@@ -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,136 @@
|
|||||||
|
"""Event service. Writes require editor rights; reads go through the privacy
|
||||||
|
engine. Every event has exactly one subject — a Person or a partnership."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.place import Place
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _belongs_to_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_event(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
event_type: str,
|
||||||
|
person_id: uuid.UUID | None = None,
|
||||||
|
relationship_id: uuid.UUID | None = None,
|
||||||
|
place_id: uuid.UUID | None = None,
|
||||||
|
date_value: str | None = None,
|
||||||
|
date_start: date | None = None,
|
||||||
|
date_end: date | None = None,
|
||||||
|
date_precision: str | None = None,
|
||||||
|
calendar: str = "gregorian",
|
||||||
|
detail: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> Event:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
if bool(person_id) == bool(relationship_id):
|
||||||
|
raise Conflict("an event needs exactly one subject: person_id or relationship_id")
|
||||||
|
if person_id and not await _belongs_to_tree(session, Person, person_id, tree.id):
|
||||||
|
raise NotFound("person not found in this tree")
|
||||||
|
if relationship_id and not await _belongs_to_tree(
|
||||||
|
session, Relationship, relationship_id, tree.id
|
||||||
|
):
|
||||||
|
raise NotFound("relationship not found in this tree")
|
||||||
|
if place_id and not await _belongs_to_tree(session, Place, place_id, tree.id):
|
||||||
|
raise NotFound("place not found in this tree")
|
||||||
|
|
||||||
|
event = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
event_type=event_type,
|
||||||
|
person_id=person_id,
|
||||||
|
relationship_id=relationship_id,
|
||||||
|
place_id=place_id,
|
||||||
|
date_value=date_value,
|
||||||
|
date_start=date_start,
|
||||||
|
date_end=date_end,
|
||||||
|
date_precision=date_precision,
|
||||||
|
calendar=calendar,
|
||||||
|
detail=detail,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
session.add(event)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Event",
|
||||||
|
entity_id=event.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"event_type": event_type, "person_id": str(person_id) if person_id else None},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def list_events_for_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Event]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Event)
|
||||||
|
.where(
|
||||||
|
Event.tree_id == tree.id,
|
||||||
|
Event.person_id == person_id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_event(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, event_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")
|
||||||
|
event = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(
|
||||||
|
Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if event is None:
|
||||||
|
raise NotFound("event not found")
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
event.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Event",
|
||||||
|
entity_id=event.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -14,7 +14,7 @@ from app.models.tree import Tree
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import privacy
|
from app.services import privacy
|
||||||
from app.services.audit import record_audit
|
from app.services.audit import record_audit
|
||||||
from app.services.exceptions import Forbidden
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
from app.services.privacy import Visibility
|
from app.services.privacy import Visibility
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +86,32 @@ async def create_person(
|
|||||||
return person
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def get_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id,
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
# Run the single person through the privacy engine (redaction lands Phase 2).
|
||||||
|
if (
|
||||||
|
await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
|
||||||
|
== Visibility.hidden
|
||||||
|
):
|
||||||
|
raise NotFound("person not found")
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
async def list_persons(
|
async def list_persons(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
) -> list[Person]:
|
) -> list[Person]:
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Relationship service. Typed, qualified edges between two Persons in a tree.
|
||||||
|
Writes require editor rights; reads go through the privacy engine."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _person_in_tree(session: AsyncSession, person_id: uuid.UUID, tree_id: uuid.UUID) -> bool:
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person.id).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree_id, Person.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_relationship(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
type: RelationshipType,
|
||||||
|
person_from_id: uuid.UUID,
|
||||||
|
person_to_id: uuid.UUID,
|
||||||
|
qualifier: ParentChildQualifier | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> Relationship:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
if person_from_id == person_to_id:
|
||||||
|
raise Conflict("a relationship needs two different people")
|
||||||
|
if qualifier is not None and type is not RelationshipType.parent_child:
|
||||||
|
raise Conflict("qualifier only applies to parent_child relationships")
|
||||||
|
for pid in (person_from_id, person_to_id):
|
||||||
|
if not await _person_in_tree(session, pid, tree.id):
|
||||||
|
raise NotFound("person not found in this tree")
|
||||||
|
|
||||||
|
relationship = Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=type,
|
||||||
|
person_from_id=person_from_id,
|
||||||
|
person_to_id=person_to_id,
|
||||||
|
qualifier=qualifier,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
session.add(relationship)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Relationship",
|
||||||
|
entity_id=relationship.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"type": type.value, "from": str(person_from_id), "to": str(person_to_id)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(relationship)
|
||||||
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
|
async def list_relationships_for_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Relationship]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Relationship)
|
||||||
|
.where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Relationship.person_from_id == person_id,
|
||||||
|
Relationship.person_to_id == person_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(Relationship.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_relationship(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, relationship_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")
|
||||||
|
relationship = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.id == relationship_id,
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if relationship is None:
|
||||||
|
raise NotFound("relationship not found")
|
||||||
|
relationship.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Relationship",
|
||||||
|
entity_id=relationship.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,116 @@
|
|||||||
|
"""Events and relationships through the API."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_tree_with_two_people(client, email: str):
|
||||||
|
token = await register(client, email)
|
||||||
|
h = auth(token)
|
||||||
|
tree_id = (
|
||||||
|
await client.post("/api/v1/trees", json={"name": "Graph"}, headers=h)
|
||||||
|
).json()["id"]
|
||||||
|
parent = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons",
|
||||||
|
json={"given": "Anna", "surname": "Vogel"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
child = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons",
|
||||||
|
json={"given": "Beth", "surname": "Vogel"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
return h, tree_id, parent, child
|
||||||
|
|
||||||
|
|
||||||
|
async def test_event_create_list_delete(client):
|
||||||
|
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "ev1@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/events",
|
||||||
|
json={"event_type": "birth", "person_id": parent, "date_value": "ABT 1850"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
event_id = resp.json()["id"]
|
||||||
|
|
||||||
|
listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
assert listed.json()[0]["event_type"] == "birth"
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/trees/{tree_id}/events/{event_id}", headers=h)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h)
|
||||||
|
assert len(listed.json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_event_requires_exactly_one_subject(client):
|
||||||
|
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/events", json={"event_type": "birth"}, headers=h
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_relationship_create_and_list(client):
|
||||||
|
h, tree_id, parent, child = await _setup_tree_with_two_people(client, "rel1@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/relationships",
|
||||||
|
json={
|
||||||
|
"type": "parent_child",
|
||||||
|
"person_from_id": parent,
|
||||||
|
"person_to_id": child,
|
||||||
|
"qualifier": "biological",
|
||||||
|
},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
for pid in (parent, child):
|
||||||
|
listed = await client.get(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons/{pid}/relationships", headers=h
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
assert listed.json()[0]["qualifier"] == "biological"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_relationship_validation(client):
|
||||||
|
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel2@example.com")
|
||||||
|
# Same person on both ends.
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/relationships",
|
||||||
|
json={"type": "sibling", "person_from_id": parent, "person_to_id": parent},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
# Qualifier on a non-parent_child edge.
|
||||||
|
h2, t2, p_a, p_b = await _setup_tree_with_two_people(client, "rel3@example.com")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{t2}/relationships",
|
||||||
|
json={
|
||||||
|
"type": "partnership",
|
||||||
|
"person_from_id": p_a,
|
||||||
|
"person_to_id": p_b,
|
||||||
|
"qualifier": "biological",
|
||||||
|
},
|
||||||
|
headers=h2,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_member_cannot_write_graph(client):
|
||||||
|
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel4@example.com")
|
||||||
|
other = auth(await register(client, "intruder@example.com"))
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/events",
|
||||||
|
json={"event_type": "birth", "person_id": parent},
|
||||||
|
headers=other,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -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
|
||||||
@@ -30,9 +30,10 @@ S3_REGION=us-east-1
|
|||||||
# tunnel forwards plain HTTP to caddy:80.
|
# tunnel forwards plain HTTP to caddy:80.
|
||||||
PROVENANCE_SITE_ADDRESS=:80
|
PROVENANCE_SITE_ADDRESS=:80
|
||||||
|
|
||||||
# --- Cloudflare Tunnel (optional) ---
|
# --- Deploy-host services (optional, selected via COMPOSE_PROFILES) ---
|
||||||
# Enable by setting COMPOSE_PROFILES=tunnel and supplying the connector token
|
# 'tunnel' -> cloudflared connector (needs CLOUDFLARE_TUNNEL_TOKEN; public hostname -> http://caddy:80)
|
||||||
# from the Cloudflare dashboard. Public hostname -> http://caddy:80.
|
# Auto-deploy is handled by the host's global Watchtower (watches the
|
||||||
|
# watchtower-enabled backend/frontend labels) — no profile needed here.
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=
|
CLOUDFLARE_TUNNEL_TOKEN=
|
||||||
COMPOSE_PROFILES=
|
COMPOSE_PROFILES=
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ services:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
|
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: ${APP_ENV:-development}
|
APP_ENV: ${APP_ENV:-development}
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
||||||
@@ -62,6 +64,8 @@ services:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main}
|
image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main}
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -104,6 +108,12 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- tunnel
|
- tunnel
|
||||||
|
|
||||||
|
# Auto-deploy is handled by the host's global Watchtower (a single
|
||||||
|
# nickfedor/watchtower instance watches every container labelled
|
||||||
|
# `com.centurylinklabs.watchtower.enable=true` across all stacks). The backend
|
||||||
|
# and frontend carry that label above, so a new :test-main image is pulled and
|
||||||
|
# the container recreated automatically — no per-stack Watchtower needed.
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
miniodata:
|
miniodata:
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Provenance — Brand
|
||||||
|
|
||||||
|
Draft v0.1. The visual identity for Provenance: an **Origin mark** (primary logo) and a **monogram tile** (app icon / favicon), in a warm ink-and-bronze palette.
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
The mark is a survey-datum / origin point: a ring with cardinal ticks (a surveyor's monument — the **land / property** side of the product) enclosing four nodes connected to a center point (a **family graph** — the **people** side). One mark for both halves of what Provenance does. The bronze echoes survey-marker disks and aged document seals — heritage without sepia cliché.
|
||||||
|
|
||||||
|
## Palette
|
||||||
|
|
||||||
|
| Role | Light | Dark | Hex notes |
|
||||||
|
|------|-------|------|-----------|
|
||||||
|
| Ink (primary text/marks) | `#1A1A17` | `#F2EEE6` | warm near-black / warm off-white |
|
||||||
|
| Bronze (accent) | `#A06A42` | `#A06A42` | constant in both modes |
|
||||||
|
| Paper (knockout on bronze) | `#F7F3EC` | `#F7F3EC` | constant |
|
||||||
|
| Muted (tagline/secondary) | `#6B6862` | `#9A968E` | |
|
||||||
|
|
||||||
|
The SVG assets carry an embedded `prefers-color-scheme` rule, so ink and muted tones auto-adapt to light/dark; bronze and paper are intentionally fixed (bronze is mid-tone and reads on both).
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- **Wordmark:** a refined transitional **serif** (the heritage/archival register). The wordmark in these assets is **outlined to vector paths**, so the files render identically everywhere with no font dependency. For production UI headings, pair with a comparable licensed serif (e.g. a Times/Garamond-class face).
|
||||||
|
- **Tagline & secondary:** a clean humanist/grotesque **sans**.
|
||||||
|
- **Tagline:** *where it came from matters* — sentence case, never title case.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
| File | Use |
|
||||||
|
|------|-----|
|
||||||
|
| `provenance-logo.svg` | Primary horizontal lockup — mark + wordmark + tagline |
|
||||||
|
| `provenance-logo-plain.svg` | Lockup without tagline (tight spaces, headers) |
|
||||||
|
| `provenance-mark.svg` | Mark only — square, for avatars, small placements, loading states |
|
||||||
|
| `provenance-icon.svg` | App icon — 512px bronze monogram tile |
|
||||||
|
| `favicon.svg` | Favicon — 48px monogram tile |
|
||||||
|
| `generate.py` | Reproducible generator for all of the above |
|
||||||
|
|
||||||
|
## Usage notes
|
||||||
|
|
||||||
|
- **Clear space:** keep padding around the lockup at least the height of the mark's center-to-tick distance (≈ the ring radius). Don't crowd it.
|
||||||
|
- **Minimum size:** the full lockup stays legible down to ~140px wide; below that, use the mark or the icon.
|
||||||
|
- **Don't:** recolor outside the palette, add gradients/shadows, stretch, or rotate. Don't put the ink lockup on a busy or mid-tone background where it loses contrast — use the icon tile instead.
|
||||||
|
- **Backgrounds:** the lockup and mark are transparent and adapt to light/dark. The icon tile supplies its own bronze background.
|
||||||
|
|
||||||
|
## Regenerating
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 docs/brand/generate.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `matplotlib` and the Liberation Serif/Sans fonts (or edit the font paths at the top of the script). Outputs all SVGs into this directory.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" role="img" aria-label="Provenance icon">
|
||||||
|
<title>Provenance icon</title>
|
||||||
|
<rect x="0" y="0" width="48" height="48" rx="8.64" fill="#A06A42"/>
|
||||||
|
<g transform="translate(16.32 14.26)"><path d="M12.47 5.77Q12.47 3.37 11.35 2.34Q10.23 1.31 7.58 1.31L6.16 1.31L6.16 10.54L7.67 10.54Q10.13 10.54 11.29 9.42Q12.47 8.3 12.47 5.77ZM6.16 11.84L6.16 18.33L9.26 18.72L9.26 19.49L1.05 19.49L1.05 18.72L3.36 18.33L3.36 1.15L0.86 0.77L0.86 0.0L8.21 0.0Q15.36 0.0 15.36 5.74Q15.36 8.73 13.55 10.29Q11.74 11.84 8.36 11.84L6.16 11.84Z" fill="#F7F3EC"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate Provenance brand assets as portable, font-independent SVGs."""
|
||||||
|
import os
|
||||||
|
from matplotlib.textpath import TextPath
|
||||||
|
from matplotlib.font_manager import FontProperties
|
||||||
|
from matplotlib.path import Path
|
||||||
|
|
||||||
|
SERIF = "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf"
|
||||||
|
SANS = "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
|
||||||
|
|
||||||
|
INK = "#1A1A17"
|
||||||
|
INK_DARK = "#F2EEE6"
|
||||||
|
BRONZE = "#A06A42"
|
||||||
|
PAPER = "#F7F3EC"
|
||||||
|
MUTED = "#6B6862"
|
||||||
|
MUTED_DARK = "#9A968E"
|
||||||
|
|
||||||
|
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def text_to_path(s, font_path, size):
|
||||||
|
"""Return (d_string, width, height). Coordinates: top-left origin, y down."""
|
||||||
|
fp = FontProperties(fname=font_path)
|
||||||
|
tp = TextPath((0, 0), s, size=size, prop=fp)
|
||||||
|
verts, codes = tp.vertices, tp.codes
|
||||||
|
xs = verts[:, 0]
|
||||||
|
ys = verts[:, 1]
|
||||||
|
xmin, xmax = xs.min(), xs.max()
|
||||||
|
ymin, ymax = ys.min(), ys.max()
|
||||||
|
|
||||||
|
def tx(x):
|
||||||
|
return round(float(x - xmin), 2)
|
||||||
|
|
||||||
|
def ty(y):
|
||||||
|
return round(float(ymax - y), 2)
|
||||||
|
|
||||||
|
d = []
|
||||||
|
i = 0
|
||||||
|
n = len(codes)
|
||||||
|
while i < n:
|
||||||
|
c = codes[i]
|
||||||
|
if c == Path.MOVETO:
|
||||||
|
x, y = verts[i]
|
||||||
|
d.append(f"M{tx(x)} {ty(y)}")
|
||||||
|
i += 1
|
||||||
|
elif c == Path.LINETO:
|
||||||
|
x, y = verts[i]
|
||||||
|
d.append(f"L{tx(x)} {ty(y)}")
|
||||||
|
i += 1
|
||||||
|
elif c == Path.CURVE3:
|
||||||
|
x1, y1 = verts[i]
|
||||||
|
x2, y2 = verts[i + 1]
|
||||||
|
d.append(f"Q{tx(x1)} {ty(y1)} {tx(x2)} {ty(y2)}")
|
||||||
|
i += 2
|
||||||
|
elif c == Path.CURVE4:
|
||||||
|
x1, y1 = verts[i]
|
||||||
|
x2, y2 = verts[i + 1]
|
||||||
|
x3, y3 = verts[i + 2]
|
||||||
|
d.append(f"C{tx(x1)} {ty(y1)} {tx(x2)} {ty(y2)} {tx(x3)} {ty(y3)}")
|
||||||
|
i += 3
|
||||||
|
elif c == Path.CLOSEPOLY:
|
||||||
|
d.append("Z")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
return "".join(d), round(float(xmax - xmin), 2), round(float(ymax - ymin), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def origin_mark(cx, cy, R, conn_sw=1.5, tick_sw=2):
|
||||||
|
"""Origin/survey-datum mark primitives as an SVG fragment string.
|
||||||
|
Ink elements get class='ink'; bronze elements class='br'."""
|
||||||
|
import math
|
||||||
|
diag = R * 0.7071
|
||||||
|
nodes = [(cx + diag, cy - diag), (cx - diag, cy - diag),
|
||||||
|
(cx + diag, cy + diag), (cx - diag, cy + diag)]
|
||||||
|
parts = []
|
||||||
|
# ring (bronze)
|
||||||
|
parts.append(f'<circle cx="{cx}" cy="{cy}" r="{R}" class="br-s" '
|
||||||
|
f'fill="none" stroke-width="{tick_sw}"/>')
|
||||||
|
# cardinal ticks (bronze) crossing the ring
|
||||||
|
t_in, t_out = R - 4, R + 4
|
||||||
|
for (dx, dy) in [(0, -1), (0, 1), (1, 0), (-1, 0)]:
|
||||||
|
x1, y1 = cx + dx * t_in, cy + dy * t_in
|
||||||
|
x2, y2 = cx + dx * t_out, cy + dy * t_out
|
||||||
|
parts.append(f'<line x1="{round(x1,2)}" y1="{round(y1,2)}" '
|
||||||
|
f'x2="{round(x2,2)}" y2="{round(y2,2)}" class="br-s" '
|
||||||
|
f'stroke-width="{tick_sw}" stroke-linecap="round"/>')
|
||||||
|
# connectors (ink) center -> diagonal nodes
|
||||||
|
for (nx, ny) in nodes:
|
||||||
|
parts.append(f'<line x1="{cx}" y1="{cy}" x2="{round(nx,2)}" '
|
||||||
|
f'y2="{round(ny,2)}" class="ink-s" fill="none" '
|
||||||
|
f'stroke-width="{conn_sw}"/>')
|
||||||
|
# diagonal nodes (ink)
|
||||||
|
nr = max(2.0, R * 0.105)
|
||||||
|
for (nx, ny) in nodes:
|
||||||
|
parts.append(f'<circle cx="{round(nx,2)}" cy="{round(ny,2)}" '
|
||||||
|
f'r="{round(nr,2)}" class="ink"/>')
|
||||||
|
# center dot (ink)
|
||||||
|
parts.append(f'<circle cx="{cx}" cy="{cy}" r="{round(R*0.15,2)}" '
|
||||||
|
f'class="ink"/>')
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
STYLE = f""".ink{{fill:{INK}}}.ink-s{{stroke:{INK}}}.br{{fill:{BRONZE}}}.br-s{{stroke:{BRONZE}}}.muted{{fill:{MUTED}}}
|
||||||
|
@media (prefers-color-scheme:dark){{.ink{{fill:{INK_DARK}}}.ink-s{{stroke:{INK_DARK}}}.muted{{fill:{MUTED_DARK}}}}}"""
|
||||||
|
|
||||||
|
# ---- 1. Primary horizontal lockup ----
|
||||||
|
WM_SIZE = 64
|
||||||
|
TAG_SIZE = 15
|
||||||
|
wm_d, wm_w, wm_h = text_to_path("Provenance", SERIF, WM_SIZE)
|
||||||
|
tag_d, tag_w, tag_h = text_to_path("where it came from matters", SANS, TAG_SIZE)
|
||||||
|
|
||||||
|
pad = 28
|
||||||
|
R = 30
|
||||||
|
mark_box = 2 * (R + 6)
|
||||||
|
gap = 26
|
||||||
|
block_gap = 12
|
||||||
|
text_block_h = wm_h + block_gap + tag_h
|
||||||
|
content_h = max(mark_box, text_block_h)
|
||||||
|
H = round(pad * 2 + content_h, 2)
|
||||||
|
mark_cx = pad + (R + 6)
|
||||||
|
mark_cy = round(H / 2, 2)
|
||||||
|
text_x = pad + mark_box + gap
|
||||||
|
W = round(text_x + max(wm_w, tag_w) + pad, 2)
|
||||||
|
# vertically center the text block
|
||||||
|
block_top = (H - text_block_h) / 2
|
||||||
|
wm_y = round(block_top, 2)
|
||||||
|
tag_y = round(block_top + wm_h + block_gap, 2)
|
||||||
|
|
||||||
|
logo = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}" role="img" aria-label="Provenance">
|
||||||
|
<title>Provenance</title>
|
||||||
|
<style>{STYLE}</style>
|
||||||
|
{origin_mark(mark_cx, mark_cy, R)}
|
||||||
|
<g transform="translate({text_x} {wm_y})"><path d="{wm_d}" class="ink"/></g>
|
||||||
|
<g transform="translate({round(text_x+0.5,2)} {tag_y})"><path d="{tag_d}" class="muted"/></g>
|
||||||
|
</svg>
|
||||||
|
'''
|
||||||
|
open(f"{OUT}/provenance-logo.svg", "w").write(logo)
|
||||||
|
|
||||||
|
# ---- 1b. Lockup without tagline ----
|
||||||
|
H2 = round(pad * 2 + max(mark_box, wm_h), 2)
|
||||||
|
mcy2 = round(H2 / 2, 2)
|
||||||
|
wm_y2 = round((H2 - wm_h) / 2, 2)
|
||||||
|
W2 = round(text_x + wm_w + pad, 2)
|
||||||
|
logo2 = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{W2}" height="{H2}" viewBox="0 0 {W2} {H2}" role="img" aria-label="Provenance">
|
||||||
|
<title>Provenance</title>
|
||||||
|
<style>{STYLE}</style>
|
||||||
|
{origin_mark(mark_cx, mcy2, R)}
|
||||||
|
<g transform="translate({text_x} {wm_y2})"><path d="{wm_d}" class="ink"/></g>
|
||||||
|
</svg>
|
||||||
|
'''
|
||||||
|
open(f"{OUT}/provenance-logo-plain.svg", "w").write(logo2)
|
||||||
|
|
||||||
|
# ---- 2. Mark only (square) ----
|
||||||
|
S = 96
|
||||||
|
c = S / 2
|
||||||
|
markonly = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{S}" height="{S}" viewBox="0 0 {S} {S}" role="img" aria-label="Provenance mark">
|
||||||
|
<title>Provenance mark</title>
|
||||||
|
<style>{STYLE}</style>
|
||||||
|
{origin_mark(c, c, 34, conn_sw=1.6, tick_sw=2.2)}
|
||||||
|
</svg>
|
||||||
|
'''
|
||||||
|
open(f"{OUT}/provenance-mark.svg", "w").write(markonly)
|
||||||
|
|
||||||
|
# ---- 3. App icon / monogram tile (square) ----
|
||||||
|
def monogram(side, radius_ratio=0.22):
|
||||||
|
p_size = side * 0.62
|
||||||
|
pd, pw, ph = text_to_path("P", SERIF, p_size)
|
||||||
|
px = round((side - pw) / 2, 2)
|
||||||
|
py = round((side - ph) / 2, 2)
|
||||||
|
rx = round(side * radius_ratio, 2)
|
||||||
|
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{side}" height="{side}" viewBox="0 0 {side} {side}" role="img" aria-label="Provenance icon">
|
||||||
|
<title>Provenance icon</title>
|
||||||
|
<rect x="0" y="0" width="{side}" height="{side}" rx="{rx}" fill="{BRONZE}"/>
|
||||||
|
<g transform="translate({px} {py})"><path d="{pd}" fill="{PAPER}"/></g>
|
||||||
|
</svg>
|
||||||
|
'''
|
||||||
|
|
||||||
|
open(f"{OUT}/provenance-icon.svg", "w").write(monogram(512))
|
||||||
|
open(f"{OUT}/favicon.svg", "w").write(monogram(48, radius_ratio=0.18))
|
||||||
|
|
||||||
|
print("WROTE:")
|
||||||
|
for f in sorted(os.listdir(OUT)):
|
||||||
|
p = os.path.join(OUT, f)
|
||||||
|
print(f" {f} ({os.path.getsize(p)} bytes)")
|
||||||
|
print(f"\nlogo lockup: {W} x {H}")
|
||||||
|
print(f"wordmark path size: w={wm_w} h={wm_h}")
|
||||||
|
print(f"tagline path size: w={tag_w} h={tag_h}")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Provenance icon">
|
||||||
|
<title>Provenance icon</title>
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="112.64" fill="#A06A42"/>
|
||||||
|
<g transform="translate(174.08 152.06)"><path d="M132.98 61.55Q132.98 35.96 121.02 25.0Q109.12 13.99 80.9 13.99L65.72 13.99L65.72 112.39L81.84 112.39Q108.03 112.39 120.48 100.49Q132.98 88.54 132.98 61.55ZM65.72 126.33L65.72 195.47L98.75 199.64L98.75 207.87L11.16 207.87L11.16 199.64L35.81 195.47L35.81 12.25L9.13 8.23L9.13 0.0L87.59 0.0Q163.83 0.0 163.83 61.26Q163.83 93.15 144.53 109.76Q125.24 126.33 89.13 126.33L65.72 126.33Z" fill="#F7F3EC"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 689 B |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="450.31" height="128" viewBox="0 0 450.31 128" role="img" aria-label="Provenance">
|
||||||
|
<title>Provenance</title>
|
||||||
|
<style>.ink{fill:#1A1A17}.ink-s{stroke:#1A1A17}.br{fill:#A06A42}.br-s{stroke:#A06A42}.muted{fill:#6B6862}
|
||||||
|
@media (prefers-color-scheme:dark){.ink{fill:#F2EEE6}.ink-s{stroke:#F2EEE6}.muted{fill:#9A968E}}</style>
|
||||||
|
<circle cx="64" cy="64.0" r="30" class="br-s" fill="none" stroke-width="2"/>
|
||||||
|
<line x1="64" y1="38.0" x2="64" y2="30.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="64" y1="90.0" x2="64" y2="98.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="90" y1="64.0" x2="98" y2="64.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="38" y1="64.0" x2="30" y2="64.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="64" y1="64.0" x2="85.21" y2="42.79" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||||
|
<line x1="64" y1="64.0" x2="42.79" y2="42.79" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||||
|
<line x1="64" y1="64.0" x2="85.21" y2="85.21" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||||
|
<line x1="64" y1="64.0" x2="42.79" y2="85.21" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||||
|
<circle cx="85.21" cy="42.79" r="3.15" class="ink"/>
|
||||||
|
<circle cx="42.79" cy="42.79" r="3.15" class="ink"/>
|
||||||
|
<circle cx="85.21" cy="85.21" r="3.15" class="ink"/>
|
||||||
|
<circle cx="42.79" cy="85.21" r="3.15" class="ink"/>
|
||||||
|
<circle cx="64" cy="64.0" r="4.5" class="ink"/>
|
||||||
|
<g transform="translate(126 42.73)"><path d="M26.81 12.41Q26.81 7.25 24.4 5.04Q22.0 2.82 16.31 2.82L13.25 2.82L13.25 22.66L16.5 22.66Q21.78 22.66 24.29 20.26Q26.81 17.85 26.81 12.41ZM13.25 25.47L13.25 39.41L19.91 40.25L19.91 41.91L2.25 41.91L2.25 40.25L7.22 39.41L7.22 2.47L1.84 1.66L1.84 0.0L17.66 0.0Q33.03 0.0 33.03 12.35Q33.03 18.78 29.14 22.13Q25.25 25.47 17.97 25.47L13.25 25.47ZM56.34 11.75L56.34 19.69L55.0 19.69L53.18 16.25Q51.62 16.25 49.48 16.68Q47.34 17.1 45.78 17.78L45.78 39.72L50.81 40.5L50.81 41.91L36.87 41.91L36.87 40.5L40.59 39.72L40.59 14.72L36.87 13.94L36.87 12.53L45.43 12.53L45.72 16.19Q47.59 14.63 50.79 13.19Q54.0 11.75 55.87 11.75L56.34 11.75ZM86.47 27.07Q86.47 42.54 72.72 42.54Q66.1 42.54 62.72 38.57Q59.35 34.6 59.35 27.07Q59.35 19.63 62.72 15.69Q66.1 11.75 72.97 11.75Q79.66 11.75 83.06 15.61Q86.47 19.47 86.47 27.07ZM80.85 27.07Q80.85 20.32 78.88 17.29Q76.91 14.25 72.72 14.25Q68.63 14.25 66.8 17.16Q64.97 20.07 64.97 27.07Q64.97 34.16 66.83 37.12Q68.69 40.07 72.72 40.07Q76.85 40.07 78.85 37.01Q80.85 33.94 80.85 27.07ZM106.32 42.54L104.0 42.54L91.91 14.72L88.91 13.94L88.91 12.53L102.6 12.53L102.6 13.94L97.94 14.78L106.5 35.07L114.69 14.72L110.04 13.94L110.04 12.53L120.91 12.53L120.91 13.94L118.1 14.6L106.32 42.54ZM129.04 27.13L129.04 27.69Q129.04 32.0 129.99 34.39Q130.94 36.78 132.92 38.03Q134.91 39.28 138.13 39.28Q139.82 39.28 142.13 39.0Q144.44 38.72 145.94 38.38L145.94 40.13Q144.44 41.1 141.86 41.82Q139.29 42.54 136.6 42.54Q129.75 42.54 126.58 38.85Q123.41 35.16 123.41 27.0Q123.41 19.32 126.63 15.54Q129.85 11.75 135.82 11.75Q147.1 11.75 147.1 24.57L147.1 27.13L129.04 27.13ZM135.82 14.25Q132.57 14.25 130.83 16.88Q129.1 19.5 129.1 24.63L141.66 24.63Q141.66 19.03 140.22 16.64Q138.79 14.25 135.82 14.25ZM159.44 14.91Q161.84 13.53 164.56 12.64Q167.28 11.75 169.09 11.75Q172.9 11.75 174.84 13.97Q176.78 16.19 176.78 20.41L176.78 39.72L180.34 40.5L180.34 41.91L167.69 41.91L167.69 40.5L171.59 39.72L171.59 20.97Q171.59 18.38 170.32 16.9Q169.06 15.41 166.4 15.41Q163.59 15.41 159.5 16.32L159.5 39.72L163.47 40.5L163.47 41.91L150.78 41.91L150.78 40.5L154.31 39.72L154.31 14.72L150.78 13.94L150.78 12.53L159.15 12.53L159.44 14.91ZM195.84 11.88Q200.65 11.88 202.92 13.85Q205.19 15.82 205.19 19.88L205.19 39.72L208.84 40.5L208.84 41.91L200.78 41.91L200.19 38.97Q196.62 42.54 191.09 42.54Q183.56 42.54 183.56 33.78Q183.56 30.85 184.7 28.93Q185.84 27.0 188.34 25.99Q190.84 24.97 195.59 24.88L200.0 24.75L200.0 20.16Q200.0 17.13 198.89 15.69Q197.78 14.25 195.47 14.25Q192.34 14.25 189.75 15.72L188.69 19.38L186.94 19.38L186.94 12.97Q192.0 11.88 195.84 11.88ZM200.0 26.94L195.9 27.07Q191.72 27.22 190.23 28.69Q188.75 30.16 188.75 33.6Q188.75 39.1 193.22 39.1Q195.34 39.1 196.89 38.62Q198.44 38.13 200.0 37.38L200.0 26.94ZM219.85 14.91Q222.25 13.53 224.97 12.64Q227.69 11.75 229.5 11.75Q233.31 11.75 235.25 13.97Q237.19 16.19 237.19 20.41L237.19 39.72L240.75 40.5L240.75 41.91L228.1 41.91L228.1 40.5L232.0 39.72L232.0 20.97Q232.0 18.38 230.73 16.9Q229.47 15.41 226.81 15.41Q224.0 15.41 219.91 16.32L219.91 39.72L223.88 40.5L223.88 41.91L211.19 41.91L211.19 40.5L214.72 39.72L214.72 14.72L211.19 13.94L211.19 12.53L219.56 12.53L219.85 14.91ZM268.16 40.13Q266.63 41.25 263.94 41.9Q261.25 42.54 258.44 42.54Q244.16 42.54 244.16 27.0Q244.16 19.66 247.8 15.71Q251.44 11.75 258.22 11.75Q262.44 11.75 267.44 12.72L267.44 20.91L265.72 20.91L264.38 15.72Q261.78 14.25 258.16 14.25Q249.78 14.25 249.78 27.0Q249.78 33.63 252.33 36.46Q254.88 39.28 260.22 39.28Q264.78 39.28 268.16 38.25L268.16 40.13ZM278.25 27.13L278.25 27.69Q278.25 32.0 279.2 34.39Q280.16 36.78 282.13 38.03Q284.12 39.28 287.35 39.28Q289.04 39.28 291.35 39.0Q293.66 38.72 295.16 38.38L295.16 40.13Q293.66 41.1 291.07 41.82Q288.5 42.54 285.81 42.54Q278.97 42.54 275.8 38.85Q272.62 35.16 272.62 27.0Q272.62 19.32 275.85 15.54Q279.06 11.75 285.04 11.75Q296.31 11.75 296.31 24.57L296.31 27.13L278.25 27.13ZM285.04 14.25Q281.79 14.25 280.05 16.88Q278.31 19.5 278.31 24.63L290.88 24.63Q290.88 19.03 289.44 16.64Q288.0 14.25 285.04 14.25Z" class="ink"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Provenance mark">
|
||||||
|
<title>Provenance mark</title>
|
||||||
|
<style>.ink{fill:#1A1A17}.ink-s{stroke:#1A1A17}.br{fill:#A06A42}.br-s{stroke:#A06A42}.muted{fill:#6B6862}
|
||||||
|
@media (prefers-color-scheme:dark){.ink{fill:#F2EEE6}.ink-s{stroke:#F2EEE6}.muted{fill:#9A968E}}</style>
|
||||||
|
<circle cx="48.0" cy="48.0" r="34" class="br-s" fill="none" stroke-width="2.2"/>
|
||||||
|
<line x1="48.0" y1="18.0" x2="48.0" y2="10.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="48.0" y1="78.0" x2="48.0" y2="86.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="78.0" y1="48.0" x2="86.0" y2="48.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="18.0" y1="48.0" x2="10.0" y2="48.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="48.0" y1="48.0" x2="72.04" y2="23.96" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||||
|
<line x1="48.0" y1="48.0" x2="23.96" y2="23.96" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||||
|
<line x1="48.0" y1="48.0" x2="72.04" y2="72.04" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||||
|
<line x1="48.0" y1="48.0" x2="23.96" y2="72.04" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||||
|
<circle cx="72.04" cy="23.96" r="3.57" class="ink"/>
|
||||||
|
<circle cx="23.96" cy="23.96" r="3.57" class="ink"/>
|
||||||
|
<circle cx="72.04" cy="72.04" r="3.57" class="ink"/>
|
||||||
|
<circle cx="23.96" cy="72.04" r="3.57" class="ink"/>
|
||||||
|
<circle cx="48.0" cy="48.0" r="5.1" class="ink"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,14 +1,31 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Brand palette (docs/brand): warm ink + bronze + paper. */
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adaptive tokens (ink/paper flip for light/dark; bronze + paper are constant). */
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #f7f3ec; /* paper */
|
||||||
--foreground: #0a0a0a;
|
--foreground: #1a1a17; /* ink */
|
||||||
|
--muted: #6b6862;
|
||||||
|
--surface: #fbf8f2;
|
||||||
|
--border: #e4dccb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #1a1a17; /* warm near-black */
|
||||||
--foreground: #ededed;
|
--foreground: #f2eee6; /* warm off-white */
|
||||||
|
--muted: #9a968e;
|
||||||
|
--surface: #232019;
|
||||||
|
--border: #3a352c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,3 +34,11 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Headings use the heritage serif register. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
.font-serif {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,28 +6,35 @@ import "./globals.css";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Provenance",
|
title: "Provenance",
|
||||||
description: "Where it came from matters — family and land, every fact sourced.",
|
description: "Where it came from matters — family and land, every fact sourced.",
|
||||||
|
icons: { icon: "/favicon.svg" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body className="flex min-h-screen flex-col">
|
||||||
<header className="border-b border-neutral-200">
|
<header className="border-b border-[var(--border)]">
|
||||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||||
<Link href="/" className="font-semibold">
|
<Link href="/" className="flex items-center" aria-label="Provenance — home">
|
||||||
Provenance
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex gap-4 text-sm">
|
<nav className="flex gap-5 text-sm">
|
||||||
<Link href="/trees" className="hover:underline">
|
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-bronze">
|
||||||
Trees
|
Trees
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/login" className="hover:underline">
|
<Link href="/login" className="text-[var(--muted)] transition-colors hover:text-bronze">
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-3xl px-4 py-8">{children}</main>
|
<main className="mx-auto w-full max-w-3xl flex-1 px-4 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>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ export default function LoginPage() {
|
|||||||
{loading ? "Signing in…" : "Sign in"}
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-4 text-sm text-neutral-600">
|
<p className="mt-4 text-sm text-[var(--muted)]">
|
||||||
No account?{" "}
|
No account?{" "}
|
||||||
<Link href="/register" className="underline">
|
<Link href="/register" className="text-bronze underline">
|
||||||
Create one
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import { Button } from "@/components/ui/button";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<h1 className="text-3xl font-bold">Provenance</h1>
|
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||||
<p className="text-neutral-600">
|
Where it came from matters
|
||||||
Trace where you come from — your family and your land — with every fact linked to a
|
</h1>
|
||||||
source, on infrastructure you control.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link href="/register">
|
<Link href="/register">
|
||||||
<Button>Create an account</Button>
|
<Button>Create an account</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export default function RegisterPage() {
|
|||||||
{loading ? "Creating…" : "Create account"}
|
{loading ? "Creating…" : "Create account"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-4 text-sm text-neutral-600">
|
<p className="mt-4 text-sm text-[var(--muted)]">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link href="/login" className="underline">
|
<Link href="/login" className="text-bronze underline">
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -52,13 +52,18 @@ export default function TreeDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-neutral-500">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Link href="/trees" className="text-sm text-neutral-500 hover:underline">
|
<div className="flex items-center justify-between">
|
||||||
← All trees
|
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
||||||
</Link>
|
← All trees
|
||||||
|
</Link>
|
||||||
|
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
|
||||||
|
Sources →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -76,16 +81,20 @@ export default function TreeDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
||||||
{persons.length === 0 ? (
|
{persons.length === 0 ? (
|
||||||
<p className="text-neutral-500">No people yet.</p>
|
<p className="text-[var(--muted)]">No people yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{persons.map((person) => (
|
{persons.map((person) => (
|
||||||
<li key={person.id}>
|
<li key={person.id}>
|
||||||
<Card>
|
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
||||||
<CardContent className="p-4">
|
<Card className="transition-colors hover:border-bronze/50">
|
||||||
{person.primary_name ?? <span className="text-neutral-400">Unnamed</span>}
|
<CardContent className="p-4">
|
||||||
</CardContent>
|
{person.primary_name ?? (
|
||||||
</Card>
|
<span className="text-[var(--muted)]">Unnamed</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, 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 Person = components["schemas"]["PersonRead"];
|
||||||
|
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-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() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ id: string; personId: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
const personId = params.personId;
|
||||||
|
|
||||||
|
const [person, setPerson] = useState<Person | null>(null);
|
||||||
|
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");
|
||||||
|
const [evDate, setEvDate] = useState("");
|
||||||
|
|
||||||
|
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
||||||
|
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 } },
|
||||||
|
});
|
||||||
|
if (p.response.status === 401) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPerson(p.data ?? null);
|
||||||
|
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 } },
|
||||||
|
}),
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const nameOf = useMemo(() => {
|
||||||
|
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();
|
||||||
|
if (!evType.trim()) return;
|
||||||
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { event_type: evType, person_id: personId, date_value: evDate || null },
|
||||||
|
});
|
||||||
|
if (!error) {
|
||||||
|
setEvDate("");
|
||||||
|
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 } },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRel(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!relOther) return;
|
||||||
|
let body: RelCreate;
|
||||||
|
if (relKind === "parent") {
|
||||||
|
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual };
|
||||||
|
} else if (relKind === "child") {
|
||||||
|
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual };
|
||||||
|
} else if (relKind === "partner") {
|
||||||
|
body = { type: "partnership", person_from_id: personId, person_to_id: relOther };
|
||||||
|
} else {
|
||||||
|
body = { type: "sibling", person_from_id: personId, person_to_id: relOther };
|
||||||
|
}
|
||||||
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!error) {
|
||||||
|
setRelOther("");
|
||||||
|
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 } },
|
||||||
|
});
|
||||||
|
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>
|
||||||
|
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{items.map((r) => (
|
||||||
|
<li key={r.id} className="flex items-center justify-between text-sm">
|
||||||
|
<Link href={`/trees/${treeId}/persons/${otherId(r)}`} className="hover:underline">
|
||||||
|
{nameOf(otherId(r))}
|
||||||
|
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => removeRel(r.id)}
|
||||||
|
className="text-[var(--muted)] hover:text-bronze"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
||||||
|
← Back to tree
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<CardTitle className="text-base">Life events</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{events.map((ev) => (
|
||||||
|
<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>
|
||||||
|
<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)} />
|
||||||
|
<Button type="submit">Add event</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Relationships</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{rels.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">No relationships yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{relGroup("Parents", parents, (r) => r.person_from_id)}
|
||||||
|
{relGroup("Children", children, (r) => r.person_to_id)}
|
||||||
|
{relGroup("Partners", partners, (r) =>
|
||||||
|
r.person_from_id === personId ? r.person_to_id : r.person_from_id,
|
||||||
|
)}
|
||||||
|
{relGroup("Siblings", siblings, (r) =>
|
||||||
|
r.person_from_id === personId ? r.person_to_id : r.person_from_id,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{others.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">Add more people to the tree to link them.</p>
|
||||||
|
) : (
|
||||||
|
<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)}>
|
||||||
|
<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)}>
|
||||||
|
<option value="">— person —</option>
|
||||||
|
{others.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.primary_name ?? "Unnamed"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(relKind === "parent" || relKind === "child") && (
|
||||||
|
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
||||||
|
{QUALIFIERS.map((q) => (
|
||||||
|
<option key={q} value={q}>
|
||||||
|
{q}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button type="submit">Link</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export default function TreesPage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-neutral-500">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -75,16 +75,16 @@ export default function TreesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{trees.length === 0 ? (
|
{trees.length === 0 ? (
|
||||||
<p className="text-neutral-500">No trees yet — create your first one above.</p>
|
<p className="text-[var(--muted)]">No trees yet — create your first one above.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<li key={tree.id}>
|
<li key={tree.id}>
|
||||||
<Link href={`/trees/${tree.id}`}>
|
<Link href={`/trees/${tree.id}`}>
|
||||||
<Card className="transition-colors hover:bg-neutral-50">
|
<Card className="transition-colors hover:border-bronze/50">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<span className="font-medium">{tree.name}</span>
|
<span className="font-medium">{tree.name}</span>
|
||||||
<span className="text-xs uppercase tracking-wide text-neutral-400">
|
<span className="text-xs uppercase tracking-wide text-bronze">
|
||||||
{tree.visibility}
|
{tree.visibility}
|
||||||
</span>
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 disabled:pointer-events-none disabled:opacity-50",
|
"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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-neutral-900 text-white hover:bg-neutral-700",
|
// Bronze is the brand accent; paper reads cleanly on it.
|
||||||
outline: "border border-neutral-300 bg-transparent hover:bg-neutral-100",
|
default: "bg-bronze text-paper hover:bg-bronze-deep",
|
||||||
ghost: "hover:bg-neutral-100",
|
outline:
|
||||||
|
"border border-bronze text-bronze bg-transparent hover:bg-bronze hover:text-paper",
|
||||||
|
ghost: "text-[var(--foreground)] hover:bg-bronze/10",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { cn } from "@/lib/utils";
|
|||||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("rounded-lg border border-neutral-200 bg-white/50 shadow-sm", className)}
|
className={cn(
|
||||||
|
"rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -16,7 +19,7 @@ export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDiv
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
return <h3 className={cn("text-lg font-semibold", className)} {...props} />;
|
return <h3 className={cn("font-serif text-lg font-semibold", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
|
|||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-neutral-300 bg-transparent px-3 py-2 text-sm placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 disabled:opacity-50",
|
"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",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -210,10 +210,330 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Person */
|
||||||
|
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/events": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Create Event */
|
||||||
|
post: operations["create_event_api_v1_trees__tree_id__events_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/events": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Person Events */
|
||||||
|
get: operations["list_person_events_api_v1_trees__tree_id__persons__person_id__events_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/events/{event_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Event */
|
||||||
|
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/relationships": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Create Relationship */
|
||||||
|
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Person Relationships */
|
||||||
|
get: operations["list_person_relationships_api_v1_trees__tree_id__persons__person_id__relationships_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/relationships/{relationship_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Relationship */
|
||||||
|
delete: operations["delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
/**
|
||||||
|
* CitationConfidence
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
CitationConfidence: "high" | "medium" | "low";
|
||||||
|
/** CitationCreate */
|
||||||
|
CitationCreate: {
|
||||||
|
/**
|
||||||
|
* Source Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
source_id: string;
|
||||||
|
/** Person Id */
|
||||||
|
person_id?: string | null;
|
||||||
|
/** Event Id */
|
||||||
|
event_id?: string | null;
|
||||||
|
/** Name Id */
|
||||||
|
name_id?: string | null;
|
||||||
|
/** Relationship Id */
|
||||||
|
relationship_id?: string | null;
|
||||||
|
/** Page */
|
||||||
|
page?: string | null;
|
||||||
|
/** Detail */
|
||||||
|
detail?: string | null;
|
||||||
|
confidence?: components["schemas"]["CitationConfidence"] | null;
|
||||||
|
};
|
||||||
|
/** CitationRead */
|
||||||
|
CitationRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
/**
|
||||||
|
* Source Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
source_id: string;
|
||||||
|
/** Person Id */
|
||||||
|
person_id: string | null;
|
||||||
|
/** Event Id */
|
||||||
|
event_id: string | null;
|
||||||
|
/** Name Id */
|
||||||
|
name_id: string | null;
|
||||||
|
/** Relationship Id */
|
||||||
|
relationship_id: string | null;
|
||||||
|
/** Page */
|
||||||
|
page: string | null;
|
||||||
|
/** Detail */
|
||||||
|
detail: string | null;
|
||||||
|
confidence: components["schemas"]["CitationConfidence"] | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
/** EventCreate */
|
||||||
|
EventCreate: {
|
||||||
|
/** Event Type */
|
||||||
|
event_type: string;
|
||||||
|
/** Person Id */
|
||||||
|
person_id?: string | null;
|
||||||
|
/** Relationship Id */
|
||||||
|
relationship_id?: string | null;
|
||||||
|
/** Place Id */
|
||||||
|
place_id?: string | null;
|
||||||
|
/** Date Value */
|
||||||
|
date_value?: string | null;
|
||||||
|
/** Date Start */
|
||||||
|
date_start?: string | null;
|
||||||
|
/** Date End */
|
||||||
|
date_end?: string | null;
|
||||||
|
/** Date Precision */
|
||||||
|
date_precision?: string | null;
|
||||||
|
/**
|
||||||
|
* Calendar
|
||||||
|
* @default gregorian
|
||||||
|
*/
|
||||||
|
calendar?: string;
|
||||||
|
/** Detail */
|
||||||
|
detail?: string | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
/** EventRead */
|
||||||
|
EventRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
/** Event Type */
|
||||||
|
event_type: string;
|
||||||
|
/** Person Id */
|
||||||
|
person_id: string | null;
|
||||||
|
/** Relationship Id */
|
||||||
|
relationship_id: string | null;
|
||||||
|
/** Place Id */
|
||||||
|
place_id: string | null;
|
||||||
|
/** Date Value */
|
||||||
|
date_value: string | null;
|
||||||
|
/** Date Start */
|
||||||
|
date_start: string | null;
|
||||||
|
/** Date End */
|
||||||
|
date_end: string | null;
|
||||||
|
/** Date Precision */
|
||||||
|
date_precision: string | null;
|
||||||
|
/** Calendar */
|
||||||
|
calendar: string;
|
||||||
|
/** Detail */
|
||||||
|
detail: string | null;
|
||||||
|
/** Notes */
|
||||||
|
notes: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
/** HTTPValidationError */
|
/** HTTPValidationError */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
@@ -226,6 +546,13 @@ export interface components {
|
|||||||
/** Password */
|
/** Password */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ParentChildQualifier
|
||||||
|
* @description Qualifies a parent_child edge so adoption/donor/blended families are
|
||||||
|
* first-class rather than edge cases (ARCHITECTURE §5).
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
ParentChildQualifier: "biological" | "adoptive" | "step" | "foster" | "donor" | "guardian";
|
||||||
/** PasswordResetConfirm */
|
/** PasswordResetConfirm */
|
||||||
PasswordResetConfirm: {
|
PasswordResetConfirm: {
|
||||||
/** Token */
|
/** Token */
|
||||||
@@ -293,6 +620,60 @@ export interface components {
|
|||||||
/** Display Name */
|
/** Display Name */
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
};
|
};
|
||||||
|
/** RelationshipCreate */
|
||||||
|
RelationshipCreate: {
|
||||||
|
type: components["schemas"]["RelationshipType"];
|
||||||
|
/**
|
||||||
|
* Person From Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
person_from_id: string;
|
||||||
|
/**
|
||||||
|
* Person To Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
person_to_id: string;
|
||||||
|
qualifier?: components["schemas"]["ParentChildQualifier"] | null;
|
||||||
|
/** Notes */
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
/** RelationshipRead */
|
||||||
|
RelationshipRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
type: components["schemas"]["RelationshipType"];
|
||||||
|
/**
|
||||||
|
* Person From Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
person_from_id: string;
|
||||||
|
/**
|
||||||
|
* Person To Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
person_to_id: string;
|
||||||
|
qualifier: components["schemas"]["ParentChildQualifier"] | null;
|
||||||
|
/** Notes */
|
||||||
|
notes: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* RelationshipType
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
RelationshipType: "parent_child" | "partnership" | "sibling";
|
||||||
/** SessionRead */
|
/** SessionRead */
|
||||||
SessionRead: {
|
SessionRead: {
|
||||||
user: components["schemas"]["UserRead"];
|
user: components["schemas"]["UserRead"];
|
||||||
@@ -304,6 +685,59 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
};
|
};
|
||||||
|
/** SourceCreate */
|
||||||
|
SourceCreate: {
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
/** Author */
|
||||||
|
author?: string | null;
|
||||||
|
/** Source Type */
|
||||||
|
source_type?: string | null;
|
||||||
|
/** Repository */
|
||||||
|
repository?: string | null;
|
||||||
|
/** Url */
|
||||||
|
url?: string | null;
|
||||||
|
/** Citation Text */
|
||||||
|
citation_text?: string | null;
|
||||||
|
/** Publication Info */
|
||||||
|
publication_info?: string | null;
|
||||||
|
/** Quality Note */
|
||||||
|
quality_note?: string | null;
|
||||||
|
};
|
||||||
|
/** SourceRead */
|
||||||
|
SourceRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
/** Title */
|
||||||
|
title: string;
|
||||||
|
/** Author */
|
||||||
|
author: string | null;
|
||||||
|
/** Source Type */
|
||||||
|
source_type: string | null;
|
||||||
|
/** Repository */
|
||||||
|
repository: string | null;
|
||||||
|
/** Url */
|
||||||
|
url: string | null;
|
||||||
|
/** Citation Text */
|
||||||
|
citation_text: string | null;
|
||||||
|
/** Publication Info */
|
||||||
|
publication_info: string | null;
|
||||||
|
/** Quality Note */
|
||||||
|
quality_note: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
/** TokenRequest */
|
/** TokenRequest */
|
||||||
TokenRequest: {
|
TokenRequest: {
|
||||||
/** Token */
|
/** Token */
|
||||||
@@ -782,4 +1216,454 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
get_person_api_v1_trees__tree_id__persons__person_id__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
person_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PersonRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_event_api_v1_trees__tree_id__events_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EventCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EventRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_person_events_api_v1_trees__tree_id__persons__person_id__events_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
person_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EventRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_event_api_v1_trees__tree_id__events__event_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
event_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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_relationship_api_v1_trees__tree_id__relationships_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RelationshipCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RelationshipRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_person_relationships_api_v1_trees__tree_id__persons__person_id__relationships_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
person_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RelationshipRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
relationship_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_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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" role="img" aria-label="Provenance icon">
|
||||||
|
<title>Provenance icon</title>
|
||||||
|
<rect x="0" y="0" width="48" height="48" rx="8.64" fill="#A06A42"/>
|
||||||
|
<g transform="translate(16.32 14.26)"><path d="M12.47 5.77Q12.47 3.37 11.35 2.34Q10.23 1.31 7.58 1.31L6.16 1.31L6.16 10.54L7.67 10.54Q10.13 10.54 11.29 9.42Q12.47 8.3 12.47 5.77ZM6.16 11.84L6.16 18.33L9.26 18.72L9.26 19.49L1.05 19.49L1.05 18.72L3.36 18.33L3.36 1.15L0.86 0.77L0.86 0.0L8.21 0.0Q15.36 0.0 15.36 5.74Q15.36 8.73 13.55 10.29Q11.74 11.84 8.36 11.84L6.16 11.84Z" fill="#F7F3EC"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="450.31" height="128" viewBox="0 0 450.31 128" role="img" aria-label="Provenance">
|
||||||
|
<title>Provenance</title>
|
||||||
|
<style>.ink{fill:#1A1A17}.ink-s{stroke:#1A1A17}.br{fill:#A06A42}.br-s{stroke:#A06A42}.muted{fill:#6B6862}
|
||||||
|
@media (prefers-color-scheme:dark){.ink{fill:#F2EEE6}.ink-s{stroke:#F2EEE6}.muted{fill:#9A968E}}</style>
|
||||||
|
<circle cx="64" cy="64.0" r="30" class="br-s" fill="none" stroke-width="2"/>
|
||||||
|
<line x1="64" y1="38.0" x2="64" y2="30.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="64" y1="90.0" x2="64" y2="98.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="90" y1="64.0" x2="98" y2="64.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="38" y1="64.0" x2="30" y2="64.0" class="br-s" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="64" y1="64.0" x2="85.21" y2="42.79" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||||
|
<line x1="64" y1="64.0" x2="42.79" y2="42.79" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||||
|
<line x1="64" y1="64.0" x2="85.21" y2="85.21" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||||
|
<line x1="64" y1="64.0" x2="42.79" y2="85.21" class="ink-s" fill="none" stroke-width="1.5"/>
|
||||||
|
<circle cx="85.21" cy="42.79" r="3.15" class="ink"/>
|
||||||
|
<circle cx="42.79" cy="42.79" r="3.15" class="ink"/>
|
||||||
|
<circle cx="85.21" cy="85.21" r="3.15" class="ink"/>
|
||||||
|
<circle cx="42.79" cy="85.21" r="3.15" class="ink"/>
|
||||||
|
<circle cx="64" cy="64.0" r="4.5" class="ink"/>
|
||||||
|
<g transform="translate(126 42.73)"><path d="M26.81 12.41Q26.81 7.25 24.4 5.04Q22.0 2.82 16.31 2.82L13.25 2.82L13.25 22.66L16.5 22.66Q21.78 22.66 24.29 20.26Q26.81 17.85 26.81 12.41ZM13.25 25.47L13.25 39.41L19.91 40.25L19.91 41.91L2.25 41.91L2.25 40.25L7.22 39.41L7.22 2.47L1.84 1.66L1.84 0.0L17.66 0.0Q33.03 0.0 33.03 12.35Q33.03 18.78 29.14 22.13Q25.25 25.47 17.97 25.47L13.25 25.47ZM56.34 11.75L56.34 19.69L55.0 19.69L53.18 16.25Q51.62 16.25 49.48 16.68Q47.34 17.1 45.78 17.78L45.78 39.72L50.81 40.5L50.81 41.91L36.87 41.91L36.87 40.5L40.59 39.72L40.59 14.72L36.87 13.94L36.87 12.53L45.43 12.53L45.72 16.19Q47.59 14.63 50.79 13.19Q54.0 11.75 55.87 11.75L56.34 11.75ZM86.47 27.07Q86.47 42.54 72.72 42.54Q66.1 42.54 62.72 38.57Q59.35 34.6 59.35 27.07Q59.35 19.63 62.72 15.69Q66.1 11.75 72.97 11.75Q79.66 11.75 83.06 15.61Q86.47 19.47 86.47 27.07ZM80.85 27.07Q80.85 20.32 78.88 17.29Q76.91 14.25 72.72 14.25Q68.63 14.25 66.8 17.16Q64.97 20.07 64.97 27.07Q64.97 34.16 66.83 37.12Q68.69 40.07 72.72 40.07Q76.85 40.07 78.85 37.01Q80.85 33.94 80.85 27.07ZM106.32 42.54L104.0 42.54L91.91 14.72L88.91 13.94L88.91 12.53L102.6 12.53L102.6 13.94L97.94 14.78L106.5 35.07L114.69 14.72L110.04 13.94L110.04 12.53L120.91 12.53L120.91 13.94L118.1 14.6L106.32 42.54ZM129.04 27.13L129.04 27.69Q129.04 32.0 129.99 34.39Q130.94 36.78 132.92 38.03Q134.91 39.28 138.13 39.28Q139.82 39.28 142.13 39.0Q144.44 38.72 145.94 38.38L145.94 40.13Q144.44 41.1 141.86 41.82Q139.29 42.54 136.6 42.54Q129.75 42.54 126.58 38.85Q123.41 35.16 123.41 27.0Q123.41 19.32 126.63 15.54Q129.85 11.75 135.82 11.75Q147.1 11.75 147.1 24.57L147.1 27.13L129.04 27.13ZM135.82 14.25Q132.57 14.25 130.83 16.88Q129.1 19.5 129.1 24.63L141.66 24.63Q141.66 19.03 140.22 16.64Q138.79 14.25 135.82 14.25ZM159.44 14.91Q161.84 13.53 164.56 12.64Q167.28 11.75 169.09 11.75Q172.9 11.75 174.84 13.97Q176.78 16.19 176.78 20.41L176.78 39.72L180.34 40.5L180.34 41.91L167.69 41.91L167.69 40.5L171.59 39.72L171.59 20.97Q171.59 18.38 170.32 16.9Q169.06 15.41 166.4 15.41Q163.59 15.41 159.5 16.32L159.5 39.72L163.47 40.5L163.47 41.91L150.78 41.91L150.78 40.5L154.31 39.72L154.31 14.72L150.78 13.94L150.78 12.53L159.15 12.53L159.44 14.91ZM195.84 11.88Q200.65 11.88 202.92 13.85Q205.19 15.82 205.19 19.88L205.19 39.72L208.84 40.5L208.84 41.91L200.78 41.91L200.19 38.97Q196.62 42.54 191.09 42.54Q183.56 42.54 183.56 33.78Q183.56 30.85 184.7 28.93Q185.84 27.0 188.34 25.99Q190.84 24.97 195.59 24.88L200.0 24.75L200.0 20.16Q200.0 17.13 198.89 15.69Q197.78 14.25 195.47 14.25Q192.34 14.25 189.75 15.72L188.69 19.38L186.94 19.38L186.94 12.97Q192.0 11.88 195.84 11.88ZM200.0 26.94L195.9 27.07Q191.72 27.22 190.23 28.69Q188.75 30.16 188.75 33.6Q188.75 39.1 193.22 39.1Q195.34 39.1 196.89 38.62Q198.44 38.13 200.0 37.38L200.0 26.94ZM219.85 14.91Q222.25 13.53 224.97 12.64Q227.69 11.75 229.5 11.75Q233.31 11.75 235.25 13.97Q237.19 16.19 237.19 20.41L237.19 39.72L240.75 40.5L240.75 41.91L228.1 41.91L228.1 40.5L232.0 39.72L232.0 20.97Q232.0 18.38 230.73 16.9Q229.47 15.41 226.81 15.41Q224.0 15.41 219.91 16.32L219.91 39.72L223.88 40.5L223.88 41.91L211.19 41.91L211.19 40.5L214.72 39.72L214.72 14.72L211.19 13.94L211.19 12.53L219.56 12.53L219.85 14.91ZM268.16 40.13Q266.63 41.25 263.94 41.9Q261.25 42.54 258.44 42.54Q244.16 42.54 244.16 27.0Q244.16 19.66 247.8 15.71Q251.44 11.75 258.22 11.75Q262.44 11.75 267.44 12.72L267.44 20.91L265.72 20.91L264.38 15.72Q261.78 14.25 258.16 14.25Q249.78 14.25 249.78 27.0Q249.78 33.63 252.33 36.46Q254.88 39.28 260.22 39.28Q264.78 39.28 268.16 38.25L268.16 40.13ZM278.25 27.13L278.25 27.69Q278.25 32.0 279.2 34.39Q280.16 36.78 282.13 38.03Q284.12 39.28 287.35 39.28Q289.04 39.28 291.35 39.0Q293.66 38.72 295.16 38.38L295.16 40.13Q293.66 41.1 291.07 41.82Q288.5 42.54 285.81 42.54Q278.97 42.54 275.8 38.85Q272.62 35.16 272.62 27.0Q272.62 19.32 275.85 15.54Q279.06 11.75 285.04 11.75Q296.31 11.75 296.31 24.57L296.31 27.13L278.25 27.13ZM285.04 14.25Q281.79 14.25 280.05 16.88Q278.31 19.5 278.31 24.63L290.88 24.63Q290.88 19.03 289.44 16.64Q288.0 14.25 285.04 14.25Z" class="ink"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Provenance mark">
|
||||||
|
<title>Provenance mark</title>
|
||||||
|
<style>.ink{fill:#1A1A17}.ink-s{stroke:#1A1A17}.br{fill:#A06A42}.br-s{stroke:#A06A42}.muted{fill:#6B6862}
|
||||||
|
@media (prefers-color-scheme:dark){.ink{fill:#F2EEE6}.ink-s{stroke:#F2EEE6}.muted{fill:#9A968E}}</style>
|
||||||
|
<circle cx="48.0" cy="48.0" r="34" class="br-s" fill="none" stroke-width="2.2"/>
|
||||||
|
<line x1="48.0" y1="18.0" x2="48.0" y2="10.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="48.0" y1="78.0" x2="48.0" y2="86.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="78.0" y1="48.0" x2="86.0" y2="48.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="18.0" y1="48.0" x2="10.0" y2="48.0" class="br-s" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="48.0" y1="48.0" x2="72.04" y2="23.96" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||||
|
<line x1="48.0" y1="48.0" x2="23.96" y2="23.96" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||||
|
<line x1="48.0" y1="48.0" x2="72.04" y2="72.04" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||||
|
<line x1="48.0" y1="48.0" x2="23.96" y2="72.04" class="ink-s" fill="none" stroke-width="1.6"/>
|
||||||
|
<circle cx="72.04" cy="23.96" r="3.57" class="ink"/>
|
||||||
|
<circle cx="23.96" cy="23.96" r="3.57" class="ink"/>
|
||||||
|
<circle cx="72.04" cy="72.04" r="3.57" class="ink"/>
|
||||||
|
<circle cx="23.96" cy="72.04" r="3.57" class="ink"/>
|
||||||
|
<circle cx="48.0" cy="48.0" r="5.1" class="ink"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |