Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfa6c0782a | |||
| 2f21e767f3 | |||
| f6bcf198ee | |||
| b13fafd624 | |||
| 631d050540 | |||
| d48029a407 | |||
| 18dea507d1 | |||
| 99a660485e | |||
| cf6dcf9ce2 | |||
| 22bc536978 | |||
| f2205b93f4 | |||
| b0c7c8570b | |||
| fe9a95c60d | |||
| bd8ee9b647 | |||
| 660130f007 | |||
| 34d30e3134 | |||
| 049545fcc8 | |||
| 3a14fcc4ca | |||
| fc4cb0273e |
@@ -10,6 +10,8 @@ from app.core.db import get_session
|
|||||||
from app.integrations.mailer.base import Mailer
|
from app.integrations.mailer.base import Mailer
|
||||||
from app.integrations.mailer.console import ConsoleMailer
|
from app.integrations.mailer.console import ConsoleMailer
|
||||||
from app.integrations.mailer.smtp import SMTPMailer
|
from app.integrations.mailer.smtp import SMTPMailer
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.integrations.objectstore.s3 import S3ObjectStore
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import auth_service
|
from app.services import auth_service
|
||||||
|
|
||||||
@@ -46,3 +48,10 @@ def get_mailer() -> Mailer:
|
|||||||
|
|
||||||
|
|
||||||
MailerDep = Annotated[Mailer, Depends(get_mailer)]
|
MailerDep = Annotated[Mailer, Depends(get_mailer)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_objectstore() -> ObjectStore:
|
||||||
|
return S3ObjectStore(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
ObjectStoreDep = Annotated[ObjectStore, Depends(get_objectstore)]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from app.api.v1 import (
|
|||||||
auth,
|
auth,
|
||||||
citations,
|
citations,
|
||||||
events,
|
events,
|
||||||
|
gedcom,
|
||||||
|
media,
|
||||||
persons,
|
persons,
|
||||||
relationships,
|
relationships,
|
||||||
sources,
|
sources,
|
||||||
@@ -22,3 +24,5 @@ api_router.include_router(events.router)
|
|||||||
api_router.include_router(relationships.router)
|
api_router.include_router(relationships.router)
|
||||||
api_router.include_router(sources.router)
|
api_router.include_router(sources.router)
|
||||||
api_router.include_router(citations.router)
|
api_router.include_router(citations.router)
|
||||||
|
api_router.include_router(media.router)
|
||||||
|
api_router.include_router(gedcom.router)
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ async def create_event(
|
|||||||
return EventRead.model_validate(event)
|
return EventRead.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/events", response_model=list[EventRead])
|
||||||
|
async def list_tree_events(
|
||||||
|
tree_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(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||||
async def list_person_events(
|
async def list_person_events(
|
||||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Response, UploadFile
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.gedcom import ImportReport
|
||||||
|
from app.services import gedcom, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["gedcom"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
|
||||||
|
async def import_gedcom(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> ImportReport:
|
||||||
|
# NOTE: additive — records are created as new; existing people are not merged.
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = (await file.read()).decode("utf-8", errors="replace")
|
||||||
|
report = await gedcom.import_gedcom(session, actor=current, tree=tree, text=text)
|
||||||
|
return ImportReport(**report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/gedcom/export")
|
||||||
|
async def export_gedcom(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> Response:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = await gedcom.export_gedcom(session, viewer_id=current.id, tree=tree)
|
||||||
|
safe = "".join(c for c in tree.name if c.isalnum() or c in " -_").strip() or "tree"
|
||||||
|
return Response(
|
||||||
|
content=text,
|
||||||
|
media_type="text/plain",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe}.ged"'},
|
||||||
|
)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, Response, UploadFile, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
|
from app.schemas.media import MediaRead
|
||||||
|
from app.services import media_service, tree_service
|
||||||
|
|
||||||
|
|
||||||
|
def _content_url(media) -> str:
|
||||||
|
return f"/api/v1/trees/{media.tree_id}/media/{media.id}/content"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(media) -> MediaRead:
|
||||||
|
out = MediaRead.model_validate(media)
|
||||||
|
# Stream through the backend (privacy-checked, browser-reachable) rather
|
||||||
|
# than expose the internal object store directly.
|
||||||
|
out.url = _content_url(media)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_media(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
title: str | None = Form(None),
|
||||||
|
person_id: uuid.UUID | None = Form(None),
|
||||||
|
event_id: uuid.UUID | None = Form(None),
|
||||||
|
source_id: uuid.UUID | None = Form(None),
|
||||||
|
) -> MediaRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
data = await file.read()
|
||||||
|
media = await media_service.upload_media(
|
||||||
|
session,
|
||||||
|
store,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
data=data,
|
||||||
|
filename=file.filename or "upload",
|
||||||
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
title=title,
|
||||||
|
person_id=person_id,
|
||||||
|
event_id=event_id,
|
||||||
|
source_id=source_id,
|
||||||
|
)
|
||||||
|
return _read(media)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/media", response_model=list[MediaRead])
|
||||||
|
async def list_media(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[MediaRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [_read(m) for m in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/media/{media_id}/content")
|
||||||
|
async def media_content(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
media_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
) -> Response:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
media = await media_service.get_media(
|
||||||
|
session, viewer_id=current.id, tree=tree, media_id=media_id
|
||||||
|
)
|
||||||
|
data = await store.get_object(key=media.storage_key)
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type=media.content_type,
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{media.original_filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_media(
|
||||||
|
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await media_service.delete_media(session, actor=current, tree=tree, media_id=media_id)
|
||||||
@@ -36,13 +36,37 @@ async def create_person(
|
|||||||
|
|
||||||
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
||||||
async def list_persons(
|
async def list_persons(
|
||||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, deleted: bool = False
|
||||||
) -> list[PersonRead]:
|
) -> list[PersonRead]:
|
||||||
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)
|
if deleted:
|
||||||
|
persons = await person_service.list_deleted_persons(
|
||||||
|
session, viewer_id=current.id, tree=tree
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_person(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
|
||||||
|
async def restore_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.restore_person(
|
||||||
|
session, actor=current, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
async def get_person(
|
async def get_person(
|
||||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ async def create_relationship(
|
|||||||
return RelationshipRead.model_validate(relationship)
|
return RelationshipRead.model_validate(relationship)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
|
||||||
|
async def list_relationships(
|
||||||
|
tree_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(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{tree_id}/persons/{person_id}/relationships",
|
"/{tree_id}/persons/{person_id}/relationships",
|
||||||
response_model=list[RelationshipRead],
|
response_model=list[RelationshipRead],
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TreeRead])
|
@router.get("", response_model=list[TreeRead])
|
||||||
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]:
|
async def list_my_trees(
|
||||||
trees = await tree_service.list_trees_for_user(session, user=current)
|
session: SessionDep, current: CurrentUser, deleted: bool = False
|
||||||
|
) -> list[TreeRead]:
|
||||||
|
if deleted:
|
||||||
|
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
|
||||||
|
else:
|
||||||
|
trees = await tree_service.list_trees_for_user(session, user=current)
|
||||||
return [TreeRead.model_validate(t) for t in trees]
|
return [TreeRead.model_validate(t) for t in trees]
|
||||||
|
|
||||||
|
|
||||||
@@ -31,3 +36,14 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR
|
|||||||
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
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)
|
||||||
return TreeRead.model_validate(tree)
|
return TreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
|
||||||
|
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/restore", response_model=TreeRead)
|
||||||
|
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
|
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ class Settings(BaseSettings):
|
|||||||
# Base URL used to build links in outbound email.
|
# Base URL used to build links in outbound email.
|
||||||
app_base_url: str = "http://localhost"
|
app_base_url: str = "http://localhost"
|
||||||
|
|
||||||
|
# --- Object storage (S3-compatible / MinIO) ---
|
||||||
|
s3_endpoint_url: str = "http://minio:9000"
|
||||||
|
s3_bucket: str = "provenance"
|
||||||
|
s3_access_key: str = "provenance"
|
||||||
|
s3_secret_key: str = "change-me-too"
|
||||||
|
s3_region: str = "us-east-1"
|
||||||
|
s3_presign_ttl: int = 3600 # seconds
|
||||||
|
|
||||||
|
# --- Worker ---
|
||||||
|
purge_interval_seconds: int = 3600 # how often to run the soft-delete purge
|
||||||
|
purge_after_days: int = 30 # soft-deleted rows older than this are purged
|
||||||
|
|
||||||
# --- Email (SMTP) ---
|
# --- Email (SMTP) ---
|
||||||
mailer: str = Field(default="console", description="console | smtp")
|
mailer: str = Field(default="console", description="console | smtp")
|
||||||
smtp_host: str | None = None
|
smtp_host: str | None = None
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""ObjectStore interface — pluggable binary storage behind the service layer.
|
||||||
|
|
||||||
|
Implementations are S3-compatible (MinIO for self-host, any S3 otherwise).
|
||||||
|
Methods are async wrappers so the service layer stays non-blocking even though
|
||||||
|
the underlying SDK (boto3) is synchronous.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def ensure_bucket(self) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_object(self, *, key: str) -> bytes: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_object(self, *, key: str) -> None: ...
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""S3-compatible ObjectStore (boto3), suitable for MinIO or any S3 provider.
|
||||||
|
|
||||||
|
boto3 is synchronous; each call is dispatched to a thread so request handlers
|
||||||
|
and the worker stay async."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
|
||||||
|
|
||||||
|
class S3ObjectStore(ObjectStore):
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.bucket = settings.s3_bucket
|
||||||
|
self.presign_ttl = settings.s3_presign_ttl
|
||||||
|
self._client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.s3_endpoint_url,
|
||||||
|
aws_access_key_id=settings.s3_access_key,
|
||||||
|
aws_secret_access_key=settings.s3_secret_key,
|
||||||
|
region_name=settings.s3_region,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_bucket_sync(self) -> None:
|
||||||
|
try:
|
||||||
|
self._client.head_bucket(Bucket=self.bucket)
|
||||||
|
except ClientError:
|
||||||
|
self._client.create_bucket(Bucket=self.bucket)
|
||||||
|
|
||||||
|
async def ensure_bucket(self) -> None:
|
||||||
|
await asyncio.to_thread(self._ensure_bucket_sync)
|
||||||
|
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self._client.put_object,
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=key,
|
||||||
|
Body=data,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_object(self, *, key: str) -> bytes:
|
||||||
|
def _get() -> bytes:
|
||||||
|
obj = self._client.get_object(Bucket=self.bucket, Key=key)
|
||||||
|
return obj["Body"].read()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_get)
|
||||||
|
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self._client.generate_presigned_url,
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": self.bucket, "Key": key},
|
||||||
|
ExpiresIn=self.presign_ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_object(self, *, key: str) -> None:
|
||||||
|
await asyncio.to_thread(self._client.delete_object, Bucket=self.bucket, Key=key)
|
||||||
@@ -5,6 +5,7 @@ from app.models.audit import AuditEntry
|
|||||||
from app.models.auth import Session, UserToken
|
from app.models.auth import Session, UserToken
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
|
from app.models.media import Media
|
||||||
from app.models.person import Name, Person
|
from app.models.person import Name, Person
|
||||||
from app.models.place import Place, PlaceName
|
from app.models.place import Place, PlaceName
|
||||||
from app.models.relationship import Relationship
|
from app.models.relationship import Relationship
|
||||||
@@ -28,4 +29,5 @@ __all__ = [
|
|||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"Session",
|
"Session",
|
||||||
"UserToken",
|
"UserToken",
|
||||||
|
"Media",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Media — a binary asset (image, scan, PDF, audio) in object storage. The row
|
||||||
|
holds metadata + checksum + the storage key; the bytes live in the ObjectStore.
|
||||||
|
Optionally attached to a single fact (person, event, or source) for now."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Media(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "media"
|
||||||
|
|
||||||
|
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
storage_key: Mapped[str] = mapped_column(String(512), unique=True)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(512))
|
||||||
|
content_type: Mapped[str] = mapped_column(String(128))
|
||||||
|
byte_size: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
checksum_sha256: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
title: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
|
||||||
|
# Optional single attachment target.
|
||||||
|
person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("persons.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
event_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("events.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("sources.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ImportReport(BaseModel):
|
||||||
|
counts: dict[str, int]
|
||||||
|
unmapped_tags: list[str]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class MediaRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
byte_size: int
|
||||||
|
checksum_sha256: str
|
||||||
|
title: str | None
|
||||||
|
person_id: uuid.UUID | None
|
||||||
|
event_id: uuid.UUID | None
|
||||||
|
source_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
# Presigned download URL, filled in by the router from the ObjectStore.
|
||||||
|
url: str | None = None
|
||||||
@@ -91,6 +91,20 @@ async def create_event(
|
|||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def list_events(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Event]:
|
||||||
|
"""All events in the tree — lets the family view compute birth/death years."""
|
||||||
|
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.deleted_at.is_(None))
|
||||||
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def list_events_for_person(
|
async def list_events_for_person(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""GEDCOM import/export.
|
||||||
|
|
||||||
|
A pragmatic parser + mapper for the common subset of GEDCOM (5.5.1 / 7 share
|
||||||
|
the line grammar): INDI, FAM, SOUR. Import maps records into a tree and returns
|
||||||
|
a mapping report (counts + unmapped tags); export serializes the tree back to
|
||||||
|
GEDCOM. Runs inline for now — large files should move to the worker later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.place import Place
|
||||||
|
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 Forbidden
|
||||||
|
|
||||||
|
# GEDCOM event tag -> our event_type (INDI-level).
|
||||||
|
INDI_EVENTS = {
|
||||||
|
"BIRT": "birth", "DEAT": "death", "BAPM": "baptism", "CHR": "christening",
|
||||||
|
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
|
||||||
|
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
|
||||||
|
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
|
||||||
|
"NATU": "naturalization", "BAPL": "baptism",
|
||||||
|
}
|
||||||
|
# FAM-level events.
|
||||||
|
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
|
||||||
|
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
|
||||||
|
|
||||||
|
|
||||||
|
class GedcomNode:
|
||||||
|
__slots__ = ("level", "tag", "value", "xref", "children")
|
||||||
|
|
||||||
|
def __init__(self, level: int, tag: str, value: str = "", xref: str | None = None):
|
||||||
|
self.level = level
|
||||||
|
self.tag = tag
|
||||||
|
self.value = value
|
||||||
|
self.xref = xref
|
||||||
|
self.children: list[GedcomNode] = []
|
||||||
|
|
||||||
|
def first(self, tag: str) -> "GedcomNode | None":
|
||||||
|
return next((c for c in self.children if c.tag == tag), None)
|
||||||
|
|
||||||
|
def all(self, tag: str) -> list["GedcomNode"]:
|
||||||
|
return [c for c in self.children if c.tag == tag]
|
||||||
|
|
||||||
|
def text(self, tag: str, default: str | None = None) -> str | None:
|
||||||
|
n = self.first(tag)
|
||||||
|
return n.value if n is not None else default
|
||||||
|
|
||||||
|
|
||||||
|
def parse_records(text: str) -> list[GedcomNode]:
|
||||||
|
roots: list[GedcomNode] = []
|
||||||
|
stack: list[GedcomNode] = []
|
||||||
|
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
line = raw.lstrip("").rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split(" ", 1)
|
||||||
|
try:
|
||||||
|
level = int(parts[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
rest = parts[1] if len(parts) > 1 else ""
|
||||||
|
xref: str | None = None
|
||||||
|
if rest.startswith("@"):
|
||||||
|
end = rest.find("@", 1)
|
||||||
|
if end != -1:
|
||||||
|
xref = rest[: end + 1]
|
||||||
|
rest = rest[end + 1:].strip()
|
||||||
|
tparts = rest.split(" ", 1)
|
||||||
|
tag = tparts[0]
|
||||||
|
value = tparts[1] if len(tparts) > 1 else ""
|
||||||
|
|
||||||
|
while stack and stack[-1].level >= level:
|
||||||
|
stack.pop()
|
||||||
|
parent = stack[-1] if stack else None
|
||||||
|
|
||||||
|
if tag in ("CONC", "CONT") and parent is not None:
|
||||||
|
parent.value += ("" if tag == "CONC" else "\n") + value
|
||||||
|
continue
|
||||||
|
|
||||||
|
node = GedcomNode(level, tag, value, xref)
|
||||||
|
if parent is None:
|
||||||
|
roots.append(node)
|
||||||
|
else:
|
||||||
|
parent.children.append(node)
|
||||||
|
stack.append(node)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_name(value: str) -> tuple[str | None, str | None]:
|
||||||
|
if "/" in value:
|
||||||
|
given, _, rest = value.partition("/")
|
||||||
|
surname = rest.split("/", 1)[0]
|
||||||
|
return given.strip() or None, surname.strip() or None
|
||||||
|
return value.strip() or None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _year(date_value: str | None) -> str | None:
|
||||||
|
if not date_value:
|
||||||
|
return None
|
||||||
|
m = re.search(r"\b(\d{3,4})\b", date_value)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _date_start(date_value: str | None) -> date | None:
|
||||||
|
y = _year(date_value)
|
||||||
|
if not y:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date(int(y), 1, 1)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sex(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
v = value.strip().upper()
|
||||||
|
return {"M": "male", "F": "female"}.get(v, value.strip().lower() or None)
|
||||||
|
|
||||||
|
|
||||||
|
async def import_gedcom(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, text: str
|
||||||
|
) -> dict:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
roots = parse_records(text)
|
||||||
|
counts = defaultdict(int)
|
||||||
|
unmapped: set[str] = set()
|
||||||
|
place_cache: dict[str, uuid.UUID] = {}
|
||||||
|
source_map: dict[str, uuid.UUID] = {}
|
||||||
|
person_map: dict[str, uuid.UUID] = {}
|
||||||
|
|
||||||
|
async def place_id(name: str | None) -> uuid.UUID | None:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
if name in place_cache:
|
||||||
|
return place_cache[name]
|
||||||
|
p = Place(tree_id=tree.id, name=name)
|
||||||
|
session.add(p)
|
||||||
|
await session.flush()
|
||||||
|
place_cache[name] = p.id
|
||||||
|
counts["places"] += 1
|
||||||
|
return p.id
|
||||||
|
|
||||||
|
# Sources first (so citations can reference them).
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag == "SOUR" and rec.xref:
|
||||||
|
src = Source(
|
||||||
|
tree_id=tree.id,
|
||||||
|
title=rec.text("TITL") or rec.text("ABBR") or "Untitled source",
|
||||||
|
author=rec.text("AUTH"),
|
||||||
|
publication_info=rec.text("PUBL"),
|
||||||
|
citation_text=rec.text("TEXT"),
|
||||||
|
)
|
||||||
|
session.add(src)
|
||||||
|
await session.flush()
|
||||||
|
source_map[rec.xref] = src.id
|
||||||
|
counts["sources"] += 1
|
||||||
|
|
||||||
|
async def add_citations(holder: GedcomNode, **target) -> None:
|
||||||
|
for s in holder.all("SOUR"):
|
||||||
|
sid = source_map.get(s.value.strip())
|
||||||
|
if sid is None:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target)
|
||||||
|
)
|
||||||
|
counts["citations"] += 1
|
||||||
|
|
||||||
|
# Individuals.
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "INDI" or not rec.xref:
|
||||||
|
continue
|
||||||
|
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")))
|
||||||
|
session.add(person)
|
||||||
|
await session.flush()
|
||||||
|
person_map[rec.xref] = person.id
|
||||||
|
counts["persons"] += 1
|
||||||
|
|
||||||
|
for i, nm in enumerate(rec.all("NAME")):
|
||||||
|
given, surname = _parse_name(nm.value)
|
||||||
|
session.add(
|
||||||
|
Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person.id,
|
||||||
|
name_type="birth",
|
||||||
|
given=given,
|
||||||
|
surname=surname,
|
||||||
|
display_name=nm.value or None,
|
||||||
|
is_primary=(i == 0),
|
||||||
|
sort_order=i,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["names"] += 1
|
||||||
|
|
||||||
|
await add_citations(rec, person_id=person.id)
|
||||||
|
|
||||||
|
for child in rec.children:
|
||||||
|
if child.tag in INDI_EVENTS:
|
||||||
|
dv = child.text("DATE")
|
||||||
|
ev = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person.id,
|
||||||
|
event_type=INDI_EVENTS[child.tag],
|
||||||
|
date_value=dv,
|
||||||
|
date_start=_date_start(dv),
|
||||||
|
place_id=await place_id(child.text("PLAC")),
|
||||||
|
)
|
||||||
|
session.add(ev)
|
||||||
|
await session.flush()
|
||||||
|
counts["events"] += 1
|
||||||
|
await add_citations(child, event_id=ev.id)
|
||||||
|
elif child.tag in ("NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID"):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
unmapped.add(child.tag)
|
||||||
|
|
||||||
|
# Families -> partnerships, parent-child edges, marriage events.
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "FAM":
|
||||||
|
continue
|
||||||
|
counts["families"] += 1
|
||||||
|
husb = person_map.get((rec.text("HUSB") or "").strip())
|
||||||
|
wife = person_map.get((rec.text("WIFE") or "").strip())
|
||||||
|
partnership_id: uuid.UUID | None = None
|
||||||
|
if husb and wife:
|
||||||
|
rel = Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=RelationshipType.partnership,
|
||||||
|
person_from_id=husb,
|
||||||
|
person_to_id=wife,
|
||||||
|
)
|
||||||
|
session.add(rel)
|
||||||
|
await session.flush()
|
||||||
|
partnership_id = rel.id
|
||||||
|
counts["relationships"] += 1
|
||||||
|
|
||||||
|
for fe in rec.children:
|
||||||
|
if fe.tag in FAM_EVENTS and partnership_id is not None:
|
||||||
|
dv = fe.text("DATE")
|
||||||
|
ev = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
relationship_id=partnership_id,
|
||||||
|
event_type=FAM_EVENTS[fe.tag],
|
||||||
|
date_value=dv,
|
||||||
|
date_start=_date_start(dv),
|
||||||
|
place_id=await place_id(fe.text("PLAC")),
|
||||||
|
)
|
||||||
|
session.add(ev)
|
||||||
|
await session.flush()
|
||||||
|
counts["events"] += 1
|
||||||
|
|
||||||
|
for chil in rec.all("CHIL"):
|
||||||
|
cp = person_map.get(chil.value.strip())
|
||||||
|
if cp is None:
|
||||||
|
continue
|
||||||
|
for parent in (husb, wife):
|
||||||
|
if parent and parent != cp:
|
||||||
|
session.add(
|
||||||
|
Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=RelationshipType.parent_child,
|
||||||
|
person_from_id=parent,
|
||||||
|
person_to_id=cp,
|
||||||
|
qualifier=ParentChildQualifier.biological,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["relationships"] += 1
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="import",
|
||||||
|
entity_type="Gedcom",
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=dict(counts),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return {"counts": dict(counts), "unmapped_tags": sorted(unmapped)}
|
||||||
|
|
||||||
|
|
||||||
|
def _ged_date(value: str | None) -> str | None:
|
||||||
|
return value.strip() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> str:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
|
||||||
|
persons = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
names = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
events = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
rels = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
sources = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Source).where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
places = {
|
||||||
|
p.id: p
|
||||||
|
for p in (
|
||||||
|
await session.execute(select(Place).where(Place.tree_id == tree.id))
|
||||||
|
).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
|
||||||
|
gender_by_id = {p.id: p.gender for p in persons}
|
||||||
|
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
|
||||||
|
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
|
||||||
|
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
|
||||||
|
names_by_person[n.person_id].append(n)
|
||||||
|
events_by_person: dict[uuid.UUID, list[Event]] = defaultdict(list)
|
||||||
|
events_by_rel: dict[uuid.UUID, list[Event]] = defaultdict(list)
|
||||||
|
for e in events:
|
||||||
|
if e.person_id:
|
||||||
|
events_by_person[e.person_id].append(e)
|
||||||
|
elif e.relationship_id:
|
||||||
|
events_by_rel[e.relationship_id].append(e)
|
||||||
|
|
||||||
|
# Build families from parent-child + partnership edges (group by parent set).
|
||||||
|
parents_of: dict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
|
||||||
|
for r in rels:
|
||||||
|
if r.type == RelationshipType.parent_child:
|
||||||
|
parents_of[r.person_to_id].add(r.person_from_id)
|
||||||
|
fams: dict[frozenset, dict] = {}
|
||||||
|
for child, ps in parents_of.items():
|
||||||
|
key = frozenset(ps)
|
||||||
|
fams.setdefault(key, {"parents": set(ps), "children": [], "rel_id": None})
|
||||||
|
fams[key]["children"].append(child)
|
||||||
|
for r in rels:
|
||||||
|
if r.type == RelationshipType.partnership:
|
||||||
|
key = frozenset({r.person_from_id, r.person_to_id})
|
||||||
|
fam = fams.setdefault(
|
||||||
|
key,
|
||||||
|
{"parents": {r.person_from_id, r.person_to_id}, "children": [], "rel_id": None},
|
||||||
|
)
|
||||||
|
fam["rel_id"] = r.id
|
||||||
|
fam_list = list(fams.values())
|
||||||
|
fxref = {id(f): f"@F{i + 1}@" for i, f in enumerate(fam_list)}
|
||||||
|
# person -> the families they are a spouse in / a child in
|
||||||
|
spouse_fams: dict[uuid.UUID, list[str]] = defaultdict(list)
|
||||||
|
child_fams: dict[uuid.UUID, str] = {}
|
||||||
|
for f in fam_list:
|
||||||
|
x = fxref[id(f)]
|
||||||
|
for pid in f["parents"]:
|
||||||
|
spouse_fams[pid].append(x)
|
||||||
|
for cid in f["children"]:
|
||||||
|
child_fams[cid] = x
|
||||||
|
|
||||||
|
out: list[str] = ["0 HEAD", "1 SOUR Provenance", "1 GEDC", "2 VERS 5.5.1", "1 CHAR UTF-8"]
|
||||||
|
|
||||||
|
for p in persons:
|
||||||
|
out.append(f"0 {pxref[p.id]} INDI")
|
||||||
|
for n in names_by_person.get(p.id, []):
|
||||||
|
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
|
||||||
|
out.append(f"1 NAME {display}")
|
||||||
|
sex = {"male": "M", "female": "F"}.get(p.gender or "")
|
||||||
|
if sex:
|
||||||
|
out.append(f"1 SEX {sex}")
|
||||||
|
for e in events_by_person.get(p.id, []):
|
||||||
|
tag = EVENT_TO_GED.get(e.event_type)
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
out.append(f"1 {tag}")
|
||||||
|
if _ged_date(e.date_value):
|
||||||
|
out.append(f"2 DATE {e.date_value}")
|
||||||
|
if e.place_id and e.place_id in places:
|
||||||
|
out.append(f"2 PLAC {places[e.place_id].name}")
|
||||||
|
if p.id in child_fams:
|
||||||
|
out.append(f"1 FAMC {child_fams[p.id]}")
|
||||||
|
for x in spouse_fams.get(p.id, []):
|
||||||
|
out.append(f"1 FAMS {x}")
|
||||||
|
|
||||||
|
for f in fam_list:
|
||||||
|
x = fxref[id(f)]
|
||||||
|
out.append(f"0 {x} FAM")
|
||||||
|
ps = list(f["parents"])
|
||||||
|
# HUSB/WIFE by recorded gender where possible.
|
||||||
|
males = [pid for pid in ps if gender_by_id.get(pid) == "male"]
|
||||||
|
females = [pid for pid in ps if gender_by_id.get(pid) == "female"]
|
||||||
|
husb = males[0] if males else (ps[0] if ps else None)
|
||||||
|
wife = females[0] if females else next((pid for pid in ps if pid != husb), None)
|
||||||
|
if husb:
|
||||||
|
out.append(f"1 HUSB {pxref[husb]}")
|
||||||
|
if wife:
|
||||||
|
out.append(f"1 WIFE {pxref[wife]}")
|
||||||
|
for cid in f["children"]:
|
||||||
|
out.append(f"1 CHIL {pxref[cid]}")
|
||||||
|
if f["rel_id"]:
|
||||||
|
for e in events_by_rel.get(f["rel_id"], []):
|
||||||
|
tag = EVENT_TO_GED.get(e.event_type)
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
out.append(f"1 {tag}")
|
||||||
|
if _ged_date(e.date_value):
|
||||||
|
out.append(f"2 DATE {e.date_value}")
|
||||||
|
|
||||||
|
for s in sources:
|
||||||
|
out.append(f"0 {sxref[s.id]} SOUR")
|
||||||
|
if s.title:
|
||||||
|
out.append(f"1 TITL {s.title}")
|
||||||
|
if s.author:
|
||||||
|
out.append(f"1 AUTH {s.author}")
|
||||||
|
if s.publication_info:
|
||||||
|
out.append(f"1 PUBL {s.publication_info}")
|
||||||
|
|
||||||
|
out.append("0 TRLR")
|
||||||
|
return "\n".join(out) + "\n"
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Media service. Bytes go to the ObjectStore; a metadata row goes to the DB.
|
||||||
|
Writes require editor rights; reads go through the privacy engine."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.models.media import Media
|
||||||
|
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 upload_media(
|
||||||
|
session: AsyncSession,
|
||||||
|
store: ObjectStore,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
data: bytes,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
title: str | None = None,
|
||||||
|
person_id: uuid.UUID | None = None,
|
||||||
|
event_id: uuid.UUID | None = None,
|
||||||
|
source_id: uuid.UUID | None = None,
|
||||||
|
) -> Media:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
media_id = uuid.uuid4()
|
||||||
|
key = f"{tree.id}/{media_id}/{filename}"
|
||||||
|
await store.ensure_bucket()
|
||||||
|
await store.put_object(key=key, data=data, content_type=content_type)
|
||||||
|
|
||||||
|
media = Media(
|
||||||
|
id=media_id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
uploader_id=actor.id,
|
||||||
|
storage_key=key,
|
||||||
|
original_filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
byte_size=len(data),
|
||||||
|
checksum_sha256=hashlib.sha256(data).hexdigest(),
|
||||||
|
title=title,
|
||||||
|
person_id=person_id,
|
||||||
|
event_id=event_id,
|
||||||
|
source_id=source_id,
|
||||||
|
)
|
||||||
|
session.add(media)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Media",
|
||||||
|
entity_id=media.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"filename": filename, "bytes": len(data)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(media)
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Media)
|
||||||
|
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
||||||
|
.order_by(Media.created_at.desc())
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_media(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, media_id: uuid.UUID
|
||||||
|
) -> Media:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
media = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(
|
||||||
|
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if media is None:
|
||||||
|
raise NotFound("media not found")
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_media(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, media_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")
|
||||||
|
media = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(
|
||||||
|
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if media is None:
|
||||||
|
raise NotFound("media not found")
|
||||||
|
# Soft delete the row; the object is removed by the worker's purge job.
|
||||||
|
media.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Media",
|
||||||
|
entity_id=media.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -4,6 +4,7 @@ person through the privacy engine. Each returned Person gets a transient
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -112,6 +113,77 @@ async def get_person(
|
|||||||
return person
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_person(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_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")
|
||||||
|
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")
|
||||||
|
person.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_person(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("deleted person not found")
|
||||||
|
person.deleted_at = None
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="restore",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deleted_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Person]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Person)
|
||||||
|
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
|
||||||
|
.order_by(Person.deleted_at.desc())
|
||||||
|
)
|
||||||
|
persons = list((await session.execute(stmt)).scalars().all())
|
||||||
|
for person in persons:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return persons
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ async def create_relationship(
|
|||||||
return relationship
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
|
async def list_relationships(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Relationship]:
|
||||||
|
"""All relationships in the tree — powers the family/pedigree view in one call."""
|
||||||
|
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))
|
||||||
|
.order_by(Relationship.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def list_relationships_for_person(
|
async def list_relationships_for_person(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
) -> list[Relationship]:
|
) -> list[Relationship]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -59,3 +60,55 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
|
|||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
|
||||||
|
"""Load a tree (including soft-deleted) and require the actor be its owner."""
|
||||||
|
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
|
||||||
|
if tree is None:
|
||||||
|
raise NotFound("tree not found")
|
||||||
|
role = await privacy.get_membership_role(session, actor.id, tree.id)
|
||||||
|
if role is not MembershipRole.owner:
|
||||||
|
raise Forbidden("only the owner can delete or restore a tree")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is None:
|
||||||
|
tree.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is not None:
|
||||||
|
tree.deleted_at = None
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="restore",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
||||||
|
stmt = (
|
||||||
|
select(Tree)
|
||||||
|
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
|
||||||
|
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None))
|
||||||
|
.order_by(Tree.deleted_at.desc())
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Background worker. Same image as the backend, run in worker mode
|
||||||
|
(`python -m app.worker`). First job: the scheduled soft-delete purge — hard-
|
||||||
|
delete rows whose ``deleted_at`` is older than the recovery window, and remove
|
||||||
|
their objects from storage. More jobs (media processing, scraping, hints) and a
|
||||||
|
proper queue arrive in later phases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import get_sessionmaker
|
||||||
|
from app.integrations.objectstore.s3 import S3ObjectStore
|
||||||
|
from app.models import (
|
||||||
|
Citation,
|
||||||
|
Event,
|
||||||
|
Media,
|
||||||
|
Name,
|
||||||
|
Person,
|
||||||
|
Place,
|
||||||
|
PlaceName,
|
||||||
|
Relationship,
|
||||||
|
Source,
|
||||||
|
Tree,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("provenance.worker")
|
||||||
|
|
||||||
|
# Child -> parent so foreign keys are satisfied as rows are removed.
|
||||||
|
_PURGE_ORDER = [Citation, Name, Event, Relationship, PlaceName, Place, Source, Person, Tree, User]
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_media(sessionmaker, store, cutoff: datetime) -> None:
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(Media.deleted_at.is_not(None), Media.deleted_at < cutoff)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for media in rows:
|
||||||
|
try:
|
||||||
|
await store.delete_object(key=media.storage_key)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("object delete failed for %s: %s", media.storage_key, exc)
|
||||||
|
await session.delete(media)
|
||||||
|
await session.commit()
|
||||||
|
if rows:
|
||||||
|
logger.info("purged %d media", len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_table(sessionmaker, model, cutoff: datetime) -> None:
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
try:
|
||||||
|
res = await session.execute(
|
||||||
|
delete(model).where(model.deleted_at.is_not(None), model.deleted_at < cutoff)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
if res.rowcount:
|
||||||
|
logger.info("purged %d %s", res.rowcount, model.__tablename__)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await session.rollback()
|
||||||
|
logger.warning("purge %s failed: %s", model.__tablename__, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_once(sessionmaker, store) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(days=settings.purge_after_days)
|
||||||
|
await _purge_media(sessionmaker, store, cutoff)
|
||||||
|
for model in _PURGE_ORDER:
|
||||||
|
await _purge_table(sessionmaker, model, cutoff)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s", stream=sys.stdout
|
||||||
|
)
|
||||||
|
settings = get_settings()
|
||||||
|
store = S3ObjectStore(settings)
|
||||||
|
try:
|
||||||
|
await store.ensure_bucket()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("ensure_bucket failed: %s", exc)
|
||||||
|
sessionmaker = get_sessionmaker()
|
||||||
|
logger.info(
|
||||||
|
"worker started; purge every %ds (recovery window %dd)",
|
||||||
|
settings.purge_interval_seconds,
|
||||||
|
settings.purge_after_days,
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await purge_once(sessionmaker, store)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("purge cycle error: %s", exc)
|
||||||
|
await asyncio.sleep(settings.purge_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""media
|
||||||
|
|
||||||
|
Revision ID: 7fc7024ef432
|
||||||
|
Revises: 1f6e54f6406a
|
||||||
|
Create Date: 2026-06-06 21:44:03.204170
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7fc7024ef432'
|
||||||
|
down_revision: str | None = '1f6e54f6406a'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('media',
|
||||||
|
sa.Column('uploader_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('storage_key', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('original_filename', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('content_type', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('byte_size', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('person_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('event_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('source_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_media_event_id_events'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_media_person_id_persons'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_media_source_id_sources'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_media_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['uploader_id'], ['users.id'], name=op.f('fk_media_uploader_id_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_media')),
|
||||||
|
sa.UniqueConstraint('storage_key', name=op.f('uq_media_storage_key'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_media_checksum_sha256'), 'media', ['checksum_sha256'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_event_id'), 'media', ['event_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_person_id'), 'media', ['person_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_source_id'), 'media', ['source_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_tree_id'), 'media', ['tree_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_uploader_id'), 'media', ['uploader_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_media_uploader_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_tree_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_source_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_person_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_event_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_checksum_sha256'), table_name='media')
|
||||||
|
op.drop_table('media')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -12,6 +12,8 @@ dependencies = [
|
|||||||
"asyncpg>=0.30",
|
"asyncpg>=0.30",
|
||||||
"alembic>=1.14",
|
"alembic>=1.14",
|
||||||
"argon2-cffi>=23.1",
|
"argon2-cffi>=23.1",
|
||||||
|
"boto3>=1.35",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -36,6 +38,10 @@ extend-exclude = ["migrations/versions"]
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B"]
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
|
# FastAPI uses these as call-expressions in argument defaults by design.
|
||||||
|
extend-immutable-calls = ["fastapi.File", "fastapi.Form", "fastapi.Depends", "fastapi.Query", "fastapi.Header"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
import app.models # noqa: F401 — register all models on Base.metadata
|
import app.models # noqa: F401 — register all models on Base.metadata
|
||||||
from app.api.deps import get_mailer
|
from app.api.deps import get_mailer, get_objectstore
|
||||||
from app.core.db import get_session
|
from app.core.db import get_session
|
||||||
from app.integrations.mailer.base import Mailer
|
from app.integrations.mailer.base import Mailer
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
|
|
||||||
@@ -35,7 +36,28 @@ class CapturingMailer(Mailer):
|
|||||||
self.resets.append((to, link))
|
self.resets.append((to, link))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeObjectStore(ObjectStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.objects: dict[str, tuple[bytes, str]] = {}
|
||||||
|
|
||||||
|
async def ensure_bucket(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
self.objects[key] = (data, content_type)
|
||||||
|
|
||||||
|
async def get_object(self, *, key: str) -> bytes:
|
||||||
|
return self.objects[key][0]
|
||||||
|
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str:
|
||||||
|
return f"https://objects.test/{key}"
|
||||||
|
|
||||||
|
async def delete_object(self, *, key: str) -> None:
|
||||||
|
self.objects.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
_mailer = CapturingMailer()
|
_mailer = CapturingMailer()
|
||||||
|
_store = FakeObjectStore()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -61,8 +83,10 @@ async def client():
|
|||||||
|
|
||||||
_mailer.verifications.clear()
|
_mailer.verifications.clear()
|
||||||
_mailer.resets.clear()
|
_mailer.resets.clear()
|
||||||
|
_store.objects.clear()
|
||||||
app.dependency_overrides[get_session] = _override_session
|
app.dependency_overrides[get_session] = _override_session
|
||||||
app.dependency_overrides[get_mailer] = lambda: _mailer
|
app.dependency_overrides[get_mailer] = lambda: _mailer
|
||||||
|
app.dependency_overrides[get_objectstore] = lambda: _store
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""GEDCOM import + export round-trip."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
SAMPLE = b"""0 HEAD
|
||||||
|
1 CHAR UTF-8
|
||||||
|
0 @I1@ INDI
|
||||||
|
1 NAME John /Smith/
|
||||||
|
1 SEX M
|
||||||
|
1 BIRT
|
||||||
|
2 DATE 1850
|
||||||
|
2 PLAC Boston, Massachusetts
|
||||||
|
0 @I2@ INDI
|
||||||
|
1 NAME Mary /Jones/
|
||||||
|
1 SEX F
|
||||||
|
0 @I3@ INDI
|
||||||
|
1 NAME Junior /Smith/
|
||||||
|
1 BIRT
|
||||||
|
2 DATE 1872
|
||||||
|
0 @F1@ FAM
|
||||||
|
1 HUSB @I1@
|
||||||
|
1 WIFE @I2@
|
||||||
|
1 CHIL @I3@
|
||||||
|
1 MARR
|
||||||
|
2 DATE 1870
|
||||||
|
0 TRLR
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "Imported"}, headers=h)).json()["id"]
|
||||||
|
return h, tid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gedcom_import(client):
|
||||||
|
h, tid = await _tree(client, "ged1@example.com")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/gedcom/import",
|
||||||
|
files={"file": ("sample.ged", SAMPLE, "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
counts = resp.json()["counts"]
|
||||||
|
assert counts["persons"] == 3
|
||||||
|
assert counts["families"] == 1
|
||||||
|
# partnership (1) + parent_child from both parents to the child (2)
|
||||||
|
assert counts["relationships"] == 3
|
||||||
|
assert counts["events"] == 3 # 2 births + 1 marriage
|
||||||
|
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||||
|
assert len(people) == 3
|
||||||
|
rels = (await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)).json()
|
||||||
|
assert len(rels) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gedcom_export_and_reimport(client):
|
||||||
|
h, tid = await _tree(client, "ged2@example.com")
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/gedcom/import",
|
||||||
|
files={"file": ("sample.ged", SAMPLE, "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
exported = await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)
|
||||||
|
assert exported.status_code == 200
|
||||||
|
text = exported.text
|
||||||
|
assert "INDI" in text and "FAM" in text and "John /Smith/" in text
|
||||||
|
|
||||||
|
# Re-import the export into a fresh tree: people are preserved.
|
||||||
|
tid2 = (await client.post("/api/v1/trees", json={"name": "Round"}, headers=h)).json()["id"]
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tid2}/gedcom/import",
|
||||||
|
files={"file": ("rt.ged", text.encode(), "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.json()["counts"]["persons"] == 3
|
||||||
|
assert resp.json()["counts"]["relationships"] == 3
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Media upload/list/delete through the API (object store faked in conftest)."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "M"}, headers=h)).json()["id"]
|
||||||
|
return h, tree_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_upload_list_delete(client):
|
||||||
|
h, tree_id = await _tree(client, "media1@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/media",
|
||||||
|
files={"file": ("scan.txt", b"hello world", "text/plain")},
|
||||||
|
data={"title": "A scan"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["original_filename"] == "scan.txt"
|
||||||
|
assert body["byte_size"] == 11
|
||||||
|
assert body["url"] == f"/api/v1/trees/{tree_id}/media/{body['id']}/content"
|
||||||
|
media_id = body["id"]
|
||||||
|
|
||||||
|
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
# The content endpoint streams the bytes back.
|
||||||
|
content = await client.get(f"/api/v1/trees/{tree_id}/media/{media_id}/content", headers=h)
|
||||||
|
assert content.status_code == 200
|
||||||
|
assert content.content == b"hello world"
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_member_cannot_upload(client):
|
||||||
|
h, tree_id = await _tree(client, "media2@example.com")
|
||||||
|
other = auth(await register(client, "media-intruder@example.com"))
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/media",
|
||||||
|
files={"file": ("x.txt", b"x", "text/plain")},
|
||||||
|
headers=other,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Soft-delete + recovery for trees and people."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tree_delete_and_restore(client):
|
||||||
|
h = auth(await register(client, "rec1@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
|
||||||
|
# Delete -> gone from active lists, present in the recovery list.
|
||||||
|
assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204
|
||||||
|
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0
|
||||||
|
# A soft-deleted tree is no longer visible (404 to the would-be viewer).
|
||||||
|
gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h)
|
||||||
|
assert gone.status_code == 404
|
||||||
|
deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json()
|
||||||
|
assert len(deleted) == 1 and deleted[0]["id"] == tree_id
|
||||||
|
|
||||||
|
# Restore -> back in active lists.
|
||||||
|
assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200
|
||||||
|
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1
|
||||||
|
assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_owner_can_delete_tree(client):
|
||||||
|
owner = auth(await register(client, "rec-owner@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
|
||||||
|
other = auth(await register(client, "rec-other@example.com"))
|
||||||
|
blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other)
|
||||||
|
assert blocked.status_code in (403, 404)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_person_delete_and_restore(client):
|
||||||
|
h = auth(await register(client, "rec2@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
person_id = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
|
||||||
|
).status_code == 204
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
|
||||||
|
deleted = (
|
||||||
|
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
|
||||||
|
).json()
|
||||||
|
assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h)
|
||||||
|
).status_code == 200
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1
|
||||||
Generated
+92
@@ -125,6 +125,34 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "boto3"
|
||||||
|
version = "1.43.24"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "s3transfer" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/8f/94dfa39ec618ecb2fe5b5b79428c95100e3ae3c1aa5083c283dd3cfb5ecd/boto3-1.43.24.tar.gz", hash = "sha256:ba5afa266bf7265e0c1a454fcfd48bffe5939cb16ed223bebc669c3dc8ee0bc8", size = 113154, upload-time = "2026-06-05T19:30:01.635Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/b7/e66c9b37b96153aa371fe48d24194151293f6577dd3eaa1fc146c281456d/boto3-1.43.24-py3-none-any.whl", hash = "sha256:b18ef745274ef548a9660d733d985d4a971b16bd8a6af88165ea9d0e40913b86", size = 140536, upload-time = "2026-06-05T19:29:58.968Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "botocore"
|
||||||
|
version = "1.43.24"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/78/67/55d0611b341482bc9649d16df765f849a1862184ac3709356decf632279f/botocore-1.43.24.tar.gz", hash = "sha256:0c02f2b40e99419d496ece0ea2dcdedb5c45998c16fd1674276c7dbb30767a16", size = 15471690, upload-time = "2026-06-05T19:29:33.731Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/b7/360b5afe74c4d7cff871ea6e8f335e2e11de2945c9deb1eea6438f49faa2/botocore-1.43.24-py3-none-any.whl", hash = "sha256:42903b4bfafd8f15a735ed940473f28e4ba21b2ea67a9b9aaa11dfa7fcb19fd5", size = 15155182, upload-time = "2026-06-05T19:29:29.457Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.5.20"
|
version = "2026.5.20"
|
||||||
@@ -357,6 +385,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jmespath"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mako"
|
name = "mako"
|
||||||
version = "1.3.12"
|
version = "1.3.12"
|
||||||
@@ -447,9 +484,11 @@ dependencies = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
{ name = "boto3" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
@@ -467,9 +506,11 @@ requires-dist = [
|
|||||||
{ name = "alembic", specifier = ">=1.14" },
|
{ name = "alembic", specifier = ">=1.14" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||||
{ name = "asyncpg", specifier = ">=0.30" },
|
{ name = "asyncpg", specifier = ">=0.30" },
|
||||||
|
{ name = "boto3", specifier = ">=1.35" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "pydantic", specifier = ">=2.9" },
|
{ name = "pydantic", specifier = ">=2.9" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.5" },
|
{ name = "pydantic-settings", specifier = ">=2.5" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.12" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
|
||||||
]
|
]
|
||||||
@@ -613,6 +654,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -622,6 +675,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.32"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
@@ -683,6 +745,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "s3transfer"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.50"
|
version = "2.0.50"
|
||||||
@@ -755,6 +838,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.49.0"
|
version = "0.49.0"
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ services:
|
|||||||
context: ../backend
|
context: ../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ../frontend
|
context: ../frontend
|
||||||
|
|||||||
@@ -47,9 +47,16 @@ services:
|
|||||||
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}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-provenance}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
@@ -62,6 +69,28 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Background worker — same image as the backend, run in worker mode.
|
||||||
|
# First job: the scheduled soft-delete purge (and media object cleanup).
|
||||||
|
worker:
|
||||||
|
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
|
||||||
|
command: ["uv", "run", "--no-dev", "python", "-m", "app.worker"]
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV:-development}
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-provenance}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
|
||||||
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
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:
|
labels:
|
||||||
|
|||||||
+78
-14
@@ -1,44 +1,108 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* Brand palette (docs/brand): warm ink + bronze + paper. */
|
/* Brand palette + type (docs/brand): warm ink + bronze + paper, serif display. */
|
||||||
@theme {
|
@theme {
|
||||||
--color-bronze: #a06a42;
|
--color-bronze: #a06a42;
|
||||||
--color-bronze-deep: #8a5836;
|
--color-bronze-deep: #8a5836;
|
||||||
--color-paper: #f7f3ec;
|
--color-paper: #f7f3ec;
|
||||||
--color-ink: #1a1a17;
|
--color-ink: #1a1a17;
|
||||||
|
|
||||||
--font-serif: Georgia, "Times New Roman", "Liberation Serif", ui-serif, serif;
|
--font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adaptive tokens (ink/paper flip for light/dark; bronze + paper are constant). */
|
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant. */
|
||||||
:root {
|
:root {
|
||||||
--background: #f7f3ec; /* paper */
|
--background: #f7f3ec;
|
||||||
--foreground: #1a1a17; /* ink */
|
--foreground: #1a1a17;
|
||||||
--muted: #6b6862;
|
--muted: #6b6862;
|
||||||
--surface: #fbf8f2;
|
--surface: #fffdf9;
|
||||||
--border: #e4dccb;
|
--border: #e6ddcc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #1a1a17; /* warm near-black */
|
--background: #161410;
|
||||||
--foreground: #f2eee6; /* warm off-white */
|
--foreground: #f2eee6;
|
||||||
--muted: #9a968e;
|
--muted: #9a968e;
|
||||||
--surface: #232019;
|
--surface: #211d17;
|
||||||
--border: #3a352c;
|
--border: #353029;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
/* A faint bronze warmth pooled at the top gives the flat paper some depth. */
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
1100px 520px at 50% -8%,
|
||||||
|
color-mix(in srgb, var(--color-bronze) 9%, var(--background)),
|
||||||
|
var(--background) 60%
|
||||||
|
);
|
||||||
|
background-attachment: fixed;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Headings use the heritage serif register. */
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
.font-serif {
|
.font-serif {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pedigree bracket connectors (ancestors grow rightward). Each leaf draws its
|
||||||
|
own half of the vertical spine + a horizontal stub, so lines stay correct
|
||||||
|
regardless of box heights: focus → 2 parents, each parent → 2 grandparents. */
|
||||||
|
.ped-person {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ped-self {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ped-branch {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-left: 2.5rem;
|
||||||
|
}
|
||||||
|
.ped-branch::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -2.5rem;
|
||||||
|
top: 50%;
|
||||||
|
width: 2.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ped-leaf {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.ped-leaf::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
width: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ped-leaf::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ped-leaf:first-child::after {
|
||||||
|
top: 50%;
|
||||||
|
}
|
||||||
|
.ped-leaf:last-child::after {
|
||||||
|
bottom: 50%;
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-28
@@ -1,41 +1,27 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import { Fraunces, Inter } from "next/font/google";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
const serif = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-fraunces",
|
||||||
|
display: "swap",
|
||||||
|
axes: ["opsz"],
|
||||||
|
});
|
||||||
|
const sans = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Provenance",
|
title: "Provenance — where it came from matters",
|
||||||
description: "Where it came from matters — family and land, every fact sourced.",
|
description:
|
||||||
|
"Trace your family and your land in one place — every fact linked to the record it came from. Self-hosted, sourced, and yours to keep.",
|
||||||
icons: { icon: "/favicon.svg" },
|
icons: { icon: "/favicon.svg" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
||||||
<body className="flex min-h-screen flex-col">
|
<body className="min-h-screen antialiased">{children}</body>
|
||||||
<header className="border-b border-[var(--border)]">
|
|
||||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
|
||||||
<Link href="/" className="flex items-center" aria-label="Provenance — home">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
|
||||||
</Link>
|
|
||||||
<nav className="flex gap-5 text-sm">
|
|
||||||
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-bronze">
|
|
||||||
Trees
|
|
||||||
</Link>
|
|
||||||
<Link href="/login" className="text-[var(--muted)] transition-colors hover:text-bronze">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-10">{children}</main>
|
|
||||||
<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>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mx-auto max-w-md">
|
<div className="grid min-h-screen place-items-center px-4 py-10">
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
|
<Link href="/" className="flex justify-center" aria-label="Provenance — home">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-8 w-auto" />
|
||||||
|
</Link>
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sign in</CardTitle>
|
<CardTitle>Sign in</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -70,5 +76,7 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-18
@@ -1,27 +1,105 @@
|
|||||||
|
import { BadgeCheck, MapPin, ShieldCheck, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: "Family and land, together",
|
||||||
|
body: "People, relationships, and life events alongside property and chain-of-title — one documented story of where you come from.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BadgeCheck,
|
||||||
|
title: "Sourced or it didn't happen",
|
||||||
|
body: "Every fact can carry a citation back to the record it came from. Sources are first-class, reusable, and visible.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: "Yours to keep",
|
||||||
|
body: "Self-hosted and source-available. Living people protected by default. Open formats — export anytime, run it anywhere.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 py-4">
|
<div className="flex min-h-screen flex-col">
|
||||||
<div className="space-y-4">
|
<header className="border-b border-[var(--border)]">
|
||||||
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||||
Where it came from matters
|
<Link href="/" aria-label="Provenance — home">
|
||||||
</h1>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<p className="max-w-prose text-lg text-[var(--muted)]">
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||||
Trace where you come from — your family <span className="text-bronze">and</span> your
|
</Link>
|
||||||
land — with every fact linked to a source, on infrastructure you control.
|
<nav className="flex items-center gap-5 text-sm">
|
||||||
</p>
|
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||||
</div>
|
Trees
|
||||||
<div className="flex flex-wrap gap-3">
|
</Link>
|
||||||
<Link href="/register">
|
<Link
|
||||||
<Button>Create an account</Button>
|
href="/login"
|
||||||
</Link>
|
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium hover:border-bronze hover:text-bronze"
|
||||||
<Link href="/login">
|
>
|
||||||
<Button variant="outline">Sign in</Button>
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-6">
|
||||||
|
<section className="grid items-center gap-10 py-16 sm:grid-cols-[1.3fr_1fr] sm:py-24">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
|
||||||
|
Family · Land · Provenance
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
|
||||||
|
Where it came from <span className="italic text-bronze">matters</span>.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
|
||||||
|
Trace your family and your land in one place — every name, every parcel, every claim
|
||||||
|
linked to the record it came from. Self-hosted, sourced, and yours to keep.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
|
<Link href="/register">
|
||||||
|
<Button size="lg">Create your account</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button size="lg" variant="outline">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden justify-self-end sm:block">
|
||||||
|
<div className="relative grid h-64 w-64 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] shadow-[0_24px_60px_-24px_rgba(160,106,66,0.35)]">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/provenance-mark.svg" alt="" className="h-36 w-36" />
|
||||||
|
<MapPin className="absolute -right-2 top-10 h-7 w-7 text-bronze" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-5 pb-20 sm:grid-cols-3">
|
||||||
|
{features.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.title}
|
||||||
|
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-[0_1px_2px_rgba(26,26,23,0.04)]"
|
||||||
|
>
|
||||||
|
<div className="grid h-10 w-10 place-items-center rounded-lg bg-bronze/12 text-bronze">
|
||||||
|
<f.icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-lg font-semibold">{f.title}</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">{f.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-[var(--border)]">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-6 py-6 text-sm text-[var(--muted)]">
|
||||||
|
<span className="font-serif text-base italic">where it came from matters</span>
|
||||||
|
<span>Self-hosted · source-available · your data, your infrastructure</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mx-auto max-w-md">
|
<div className="grid min-h-screen place-items-center px-4 py-10">
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
|
<Link href="/" className="flex justify-center" aria-label="Provenance — home">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-8 w-auto" />
|
||||||
|
</Link>
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Create your account</CardTitle>
|
<CardTitle>Create your account</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -78,5 +84,7 @@ export default function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
|
||||||
|
|
||||||
|
export default function GedcomPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
|
||||||
|
const [target, setTarget] = useState<"new" | "this">("new");
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [report, setReport] = useState<Report | null>(null);
|
||||||
|
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setReport(null);
|
||||||
|
setImportedTreeId(null);
|
||||||
|
|
||||||
|
let tid = treeId;
|
||||||
|
if (target === "new") {
|
||||||
|
const { data } = await api.POST("/api/v1/trees", {
|
||||||
|
body: { name: newName.trim() || "Imported tree" },
|
||||||
|
});
|
||||||
|
if (!data) {
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tid = data.id;
|
||||||
|
setImportedTreeId(tid);
|
||||||
|
} else {
|
||||||
|
setImportedTreeId(treeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (resp.ok) setReport(await resp.json());
|
||||||
|
setBusy(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportGed() {
|
||||||
|
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "tree.ged";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="target"
|
||||||
|
checked={target === "new"}
|
||||||
|
onChange={() => setTarget("new")}
|
||||||
|
/>
|
||||||
|
Import into a <strong>new tree</strong> (recommended)
|
||||||
|
</label>
|
||||||
|
{target === "new" && (
|
||||||
|
<Input
|
||||||
|
className="max-w-xs"
|
||||||
|
placeholder="New tree name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="target"
|
||||||
|
checked={target === "this"}
|
||||||
|
onChange={() => setTarget("this")}
|
||||||
|
/>
|
||||||
|
Import into <strong>this tree</strong> (appends)
|
||||||
|
</label>
|
||||||
|
{target === "this" && (
|
||||||
|
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
|
||||||
|
Importing appends everyone in the file as new records — it does not merge with
|
||||||
|
people already in this tree, so duplicates are possible.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input ref={fileRef} type="file" accept=".ged,.gedcom,text/plain" onChange={onFile} className="hidden" />
|
||||||
|
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||||
|
{busy ? "Importing…" : "Choose GEDCOM file"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||||
|
<div className="font-medium">Import complete</div>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
|
||||||
|
{Object.entries(report.counts).map(([k, v]) => (
|
||||||
|
<span key={k}>
|
||||||
|
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{report.unmapped_tags.length > 0 && (
|
||||||
|
<div className="text-xs text-[var(--muted)]">
|
||||||
|
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importedTreeId && (
|
||||||
|
<Link
|
||||||
|
href={`/trees/${importedTreeId}`}
|
||||||
|
className="inline-block text-sm text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
Open the imported tree →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Export this tree</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Download this tree as a GEDCOM file — people, relationships, events, and sources.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={exportGed}>
|
||||||
|
Download .ged
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Media = components["schemas"]["MediaRead"];
|
||||||
|
|
||||||
|
function humanSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
|
||||||
|
const [items, setItems] = useState<Media[]>([]);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/media", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems(data ?? []);
|
||||||
|
setReady(true);
|
||||||
|
}, [router, treeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
// Plain fetch for multipart (same origin → cookie auth via Caddy).
|
||||||
|
await fetch(`/api/v1/trees/${treeId}/media`, {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
setUploading(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
await api.DELETE("/api/v1/trees/{tree_id}/media/{media_id}", {
|
||||||
|
params: { path: { tree_id: treeId, media_id: id } },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Media</h1>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
onChange={onFile}
|
||||||
|
className="hidden"
|
||||||
|
id="media-upload"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||||
|
{uploading ? "Uploading…" : "Upload file"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-[var(--muted)]">
|
||||||
|
No media yet — upload scans, photos, or documents and attach them to facts.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{items.map((m) => (
|
||||||
|
<Card key={m.id} className="overflow-hidden">
|
||||||
|
<a href={m.url ?? "#"} target="_blank" rel="noreferrer" className="block">
|
||||||
|
{m.content_type.startsWith("image/") ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={m.url ?? ""}
|
||||||
|
alt={m.title ?? m.original_filename}
|
||||||
|
className="aspect-square w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid aspect-square w-full place-items-center bg-bronze/[0.06] text-3xl font-serif text-bronze">
|
||||||
|
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium" title={m.original_filename}>
|
||||||
|
{m.title ?? m.original_filename}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--muted)]">{humanSize(m.byte_size)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => remove(m.id)}
|
||||||
|
className="text-[var(--muted)] hover:text-bronze"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,35 +2,62 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type Person = components["schemas"]["PersonRead"];
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
|
||||||
export default function TreeDetailPage() {
|
function splitName(full: string): { given: string | null; surname: string | null } {
|
||||||
|
const t = full.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (t.length === 0) return { given: null, surname: null };
|
||||||
|
if (t.length === 1) return { given: t[0], surname: null };
|
||||||
|
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddKind = "parent" | "child" | "partner";
|
||||||
|
|
||||||
|
export default function FamilyViewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
|
|
||||||
const [persons, setPersons] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [given, setGiven] = useState("");
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
const [surname, setSurname] = useState("");
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [focusId, setFocusId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
// Inline add-relative form: which anchor + kind is open, and the typed name.
|
||||||
|
// `key` keeps each empty slot's inline form independent (a person has 2
|
||||||
|
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
|
||||||
|
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
|
||||||
|
const [addName, setAddName] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
});
|
});
|
||||||
if (response.status === 401) {
|
if (p.response.status === 401) {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPersons(data ?? []);
|
const [r, e] = await Promise.all([
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
]);
|
||||||
|
const ppl = p.data ?? [];
|
||||||
|
setPeople(ppl);
|
||||||
|
setRels(r.data ?? []);
|
||||||
|
setEvents(e.data ?? []);
|
||||||
|
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [router, treeId]);
|
||||||
|
|
||||||
@@ -38,67 +65,305 @@ export default function TreeDetailPage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
async function addPerson(e: React.FormEvent) {
|
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||||
e.preventDefault();
|
const parentsOf = (id: string) =>
|
||||||
if (!given.trim() && !surname.trim()) return;
|
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
|
||||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
const childrenOf = (id: string) =>
|
||||||
params: { path: { tree_id: treeId } },
|
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||||||
body: { given: given || null, surname: surname || null },
|
const partnersOf = (id: string) =>
|
||||||
});
|
rels
|
||||||
if (!error) {
|
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
|
||||||
setGiven("");
|
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
|
||||||
setSurname("");
|
|
||||||
load();
|
const years = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
|
||||||
|
for (const p of people) {
|
||||||
|
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
|
||||||
|
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
|
||||||
|
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
|
||||||
|
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, ""));
|
||||||
}
|
}
|
||||||
|
return m;
|
||||||
|
}, [people, events]);
|
||||||
|
|
||||||
|
async function addPerson(name: string): Promise<string | null> {
|
||||||
|
const { given, surname } = splitName(name);
|
||||||
|
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { given, surname },
|
||||||
|
});
|
||||||
|
return data?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFirst(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!firstName.trim()) return;
|
||||||
|
const id = await addPerson(firstName);
|
||||||
|
setFirstName("");
|
||||||
|
if (id) setFocusId(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!adding || !addName.trim()) return;
|
||||||
|
const newId = await addPerson(addName);
|
||||||
|
if (newId) {
|
||||||
|
const { kind, anchor } = adding;
|
||||||
|
const body =
|
||||||
|
kind === "parent"
|
||||||
|
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
|
||||||
|
: kind === "child"
|
||||||
|
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
|
||||||
|
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
|
||||||
|
await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAdding(null);
|
||||||
|
setAddName("");
|
||||||
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
|
if (people.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Start your tree</h1>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form onSubmit={createFirst} className="flex flex-wrap gap-2">
|
||||||
|
<Input
|
||||||
|
className="w-64"
|
||||||
|
placeholder="First person's full name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit">Add person</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = focusId ? byId.get(focusId) : undefined;
|
||||||
|
if (!focus) {
|
||||||
|
setFocusId(people[0].id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersonBox = ({
|
||||||
|
id,
|
||||||
|
muted,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
muted?: boolean;
|
||||||
|
}) => {
|
||||||
|
const p = byId.get(id);
|
||||||
|
if (!p) return null;
|
||||||
|
const isFocus = id === focusId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setFocusId(id)}
|
||||||
|
className={`w-44 rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||||
|
isFocus
|
||||||
|
? "border-bronze bg-bronze/[0.08]"
|
||||||
|
: "border-[var(--border)] bg-[var(--surface)] hover:border-bronze/60"
|
||||||
|
} ${muted ? "opacity-90" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="truncate text-sm font-medium">{p.primary_name ?? "Unnamed"}</div>
|
||||||
|
<div className="text-xs text-[var(--muted)]">{years.get(id) ?? "—"}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddSlot = ({
|
||||||
|
formKey,
|
||||||
|
kind,
|
||||||
|
anchor,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
formKey: string;
|
||||||
|
kind: AddKind;
|
||||||
|
anchor: string;
|
||||||
|
label: string;
|
||||||
|
}) =>
|
||||||
|
adding?.key === formKey ? (
|
||||||
|
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
className="h-9"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={addName}
|
||||||
|
onChange={(e) => setAddName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdding(null)}
|
||||||
|
className="text-xs text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAdding({ key: formKey, kind, anchor });
|
||||||
|
setAddName("");
|
||||||
|
}}
|
||||||
|
className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
|
||||||
|
>
|
||||||
|
+ {label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recursive ancestor chart (grows rightward): a node is its box plus a
|
||||||
|
// two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 =
|
||||||
|
// focus, capped at grandparents (depth 2).
|
||||||
|
const renderNode = (
|
||||||
|
slotPersonId: string | null,
|
||||||
|
childId: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
depth: number,
|
||||||
|
): React.ReactNode => {
|
||||||
|
const box = slotPersonId ? (
|
||||||
|
<PersonBox id={slotPersonId} muted={depth > 0} />
|
||||||
|
) : (
|
||||||
|
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
|
||||||
|
);
|
||||||
|
if (!slotPersonId || depth >= 2) {
|
||||||
|
return <div className="ped-person">{box}</div>;
|
||||||
|
}
|
||||||
|
const ps = parentsOf(slotPersonId);
|
||||||
|
return (
|
||||||
|
<div className="ped-person">
|
||||||
|
<div className="ped-self">{box}</div>
|
||||||
|
<div className="ped-branch">
|
||||||
|
<div className="ped-leaf">
|
||||||
|
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
|
||||||
|
</div>
|
||||||
|
<div className="ped-leaf">
|
||||||
|
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const partners = partnersOf(focus.id);
|
||||||
|
const children = childrenOf(focus.id);
|
||||||
|
|
||||||
|
const sorted = [...people].sort((a, b) =>
|
||||||
|
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||||||
|
);
|
||||||
|
const matches = search
|
||||||
|
? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: sorted;
|
||||||
|
const shown = matches.slice(0, 200); // cap DOM nodes; refine search to narrow
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
<h1 className="text-2xl font-semibold">Family view</h1>
|
||||||
← All trees
|
<Link
|
||||||
</Link>
|
href={`/trees/${treeId}/persons/${focus.id}`}
|
||||||
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
|
className="text-sm text-bronze hover:underline"
|
||||||
Sources →
|
>
|
||||||
|
Open {focus.primary_name ?? "person"} →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="overflow-x-auto p-6">
|
||||||
<CardTitle className="text-base">Add a person</CardTitle>
|
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={addPerson} className="flex gap-2">
|
|
||||||
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
|
|
||||||
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
|
|
||||||
<Button type="submit">Add</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div>
|
{/* Family group: partners + children of the focus */}
|
||||||
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
{persons.length === 0 ? (
|
<Card>
|
||||||
<p className="text-[var(--muted)]">No people yet.</p>
|
<CardContent className="space-y-3 p-6">
|
||||||
) : (
|
<h2 className="font-serif text-base font-semibold">Spouses & partners</h2>
|
||||||
<ul className="space-y-2">
|
<div className="flex flex-wrap gap-3">
|
||||||
{persons.map((person) => (
|
{partners.map((id) => (
|
||||||
<li key={person.id}>
|
<PersonBox key={id} id={id} muted />
|
||||||
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
))}
|
||||||
<Card className="transition-colors hover:border-bronze/50">
|
<AddSlot
|
||||||
<CardContent className="p-4">
|
formKey={`partner-${focus.id}`}
|
||||||
{person.primary_name ?? (
|
kind="partner"
|
||||||
<span className="text-[var(--muted)]">Unnamed</span>
|
anchor={focus.id}
|
||||||
)}
|
label="add spouse"
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
</div>
|
||||||
</Link>
|
</CardContent>
|
||||||
</li>
|
</Card>
|
||||||
))}
|
|
||||||
</ul>
|
<Card>
|
||||||
)}
|
<CardContent className="space-y-3 p-6">
|
||||||
|
<h2 className="font-serif text-base font-semibold">Children</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{children.map((id) => (
|
||||||
|
<PersonBox key={id} id={id} muted />
|
||||||
|
))}
|
||||||
|
<AddSlot
|
||||||
|
formKey={`child-${focus.id}`}
|
||||||
|
kind="child"
|
||||||
|
anchor={focus.id}
|
||||||
|
label="add child"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable, searchable people directory (scales to large trees) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="font-serif text-base font-semibold">People ({people.length})</h2>
|
||||||
|
<Input
|
||||||
|
className="w-64"
|
||||||
|
placeholder="Search by name…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
|
||||||
|
) : (
|
||||||
|
shown.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setFocusId(p.id)}
|
||||||
|
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
|
||||||
|
i > 0 ? "border-t border-[var(--border)]" : ""
|
||||||
|
} ${p.id === focusId ? "bg-bronze/[0.08]" : "hover:bg-bronze/[0.05]"}`}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
|
||||||
|
<span className="shrink-0 text-xs text-[var(--muted)]">
|
||||||
|
{years.get(p.id) ?? ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{matches.length > shown.length && (
|
||||||
|
<div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]">
|
||||||
|
Showing {shown.length} of {matches.length} — refine your search to narrow.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ type CitationCreate = components["schemas"]["CitationCreate"];
|
|||||||
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
|
const 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"];
|
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
|
||||||
|
|
||||||
|
// Curated genealogical event vocabulary (with an escape hatch).
|
||||||
|
const EVENT_TYPES = [
|
||||||
|
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
|
||||||
|
"residence", "census", "immigration", "emigration", "occupation", "education",
|
||||||
|
"military service", "naturalization", "other",
|
||||||
|
];
|
||||||
|
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
||||||
|
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
||||||
|
const pad = (n: number, len: number) => String(n).padStart(len, "0");
|
||||||
|
|
||||||
export default function PersonDetailPage() {
|
export default function PersonDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string; personId: string }>();
|
const params = useParams<{ id: string; personId: string }>();
|
||||||
@@ -37,7 +48,11 @@ export default function PersonDetailPage() {
|
|||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
const [evType, setEvType] = useState("birth");
|
const [evType, setEvType] = useState("birth");
|
||||||
const [evDate, setEvDate] = useState("");
|
const [evTypeOther, setEvTypeOther] = useState("");
|
||||||
|
const [dateQual, setDateQual] = useState("exact");
|
||||||
|
const [dateDay, setDateDay] = useState("");
|
||||||
|
const [dateMonth, setDateMonth] = useState("");
|
||||||
|
const [dateYear, setDateYear] = useState("");
|
||||||
|
|
||||||
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
||||||
const [relOther, setRelOther] = useState("");
|
const [relOther, setRelOther] = useState("");
|
||||||
@@ -97,15 +112,40 @@ export default function PersonDetailPage() {
|
|||||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||||
const personCites = citations.filter((c) => c.person_id === personId);
|
const personCites = citations.filter((c) => c.person_id === personId);
|
||||||
|
|
||||||
|
function buildDate() {
|
||||||
|
const year = dateYear.trim();
|
||||||
|
if (!year || Number.isNaN(Number(year))) {
|
||||||
|
return { date_value: null, date_start: null, date_precision: null };
|
||||||
|
}
|
||||||
|
const m = dateMonth ? Number(dateMonth) : null;
|
||||||
|
const d = dateDay.trim() ? Number(dateDay) : null;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (d && m) parts.push(String(d));
|
||||||
|
if (m) parts.push(GED_MON[m]);
|
||||||
|
parts.push(year);
|
||||||
|
const prefix = DATE_QUALS[dateQual];
|
||||||
|
return {
|
||||||
|
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
|
||||||
|
date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
|
||||||
|
date_precision: dateQual,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function addEvent(e: React.FormEvent) {
|
async function addEvent(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!evType.trim()) return;
|
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
||||||
|
if (!event_type) return;
|
||||||
|
const { date_value, date_start, date_precision } = buildDate();
|
||||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
body: { event_type: evType, person_id: personId, date_value: evDate || null },
|
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
||||||
});
|
});
|
||||||
if (!error) {
|
if (!error) {
|
||||||
setEvDate("");
|
setDateDay("");
|
||||||
|
setDateMonth("");
|
||||||
|
setDateYear("");
|
||||||
|
setDateQual("exact");
|
||||||
|
setEvTypeOther("");
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +206,13 @@ export default function PersonDetailPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removePerson() {
|
||||||
|
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
});
|
||||||
|
router.push(`/trees/${treeId}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
||||||
|
|
||||||
@@ -271,7 +318,12 @@ export default function PersonDetailPage() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
||||||
{citeControl("p", { person_id: personId }, personCites)}
|
<div className="flex items-center gap-3">
|
||||||
|
{citeControl("p", { person_id: personId }, personCites)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={removePerson}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -305,9 +357,68 @@ export default function PersonDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={addEvent} className="flex flex-wrap gap-2">
|
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
|
||||||
<Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} />
|
<label className="flex flex-col gap-1">
|
||||||
<Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} />
|
<span className="text-xs text-[var(--muted)]">Event</span>
|
||||||
|
<select
|
||||||
|
className={`${fieldCls} capitalize`}
|
||||||
|
value={evType}
|
||||||
|
onChange={(e) => setEvType(e.target.value)}
|
||||||
|
>
|
||||||
|
{EVENT_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t} className="capitalize">
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{evType === "other" && (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Type</span>
|
||||||
|
<Input
|
||||||
|
className="h-9 w-36"
|
||||||
|
placeholder="Custom"
|
||||||
|
value={evTypeOther}
|
||||||
|
onChange={(e) => setEvTypeOther(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">When</span>
|
||||||
|
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
|
||||||
|
<option value="exact">on</option>
|
||||||
|
<option value="about">about</option>
|
||||||
|
<option value="before">before</option>
|
||||||
|
<option value="after">after</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Day</span>
|
||||||
|
<input
|
||||||
|
className={`${fieldCls} w-14`}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="—"
|
||||||
|
value={dateDay}
|
||||||
|
onChange={(e) => setDateDay(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Month</span>
|
||||||
|
<select className={fieldCls} value={dateMonth} onChange={(e) => setDateMonth(e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Year</span>
|
||||||
|
<input
|
||||||
|
className={`${fieldCls} w-20`}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="YYYY"
|
||||||
|
value={dateYear}
|
||||||
|
onChange={(e) => setDateYear(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<Button type="submit">Add event</Button>
|
<Button type="submit">Add event</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
|
||||||
|
export default function RecoveryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId }, query: { deleted: true } },
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPeople(data ?? []);
|
||||||
|
setReady(true);
|
||||||
|
}, [router, treeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function restore(id: string) {
|
||||||
|
await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/restore", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: id } },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Recently deleted</h1>
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Deleted people are recoverable for 30 days, then permanently purged.
|
||||||
|
</p>
|
||||||
|
{people.length === 0 ? (
|
||||||
|
<p className="text-[var(--muted)]">Nothing here.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{people.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-[var(--muted)]">{p.primary_name ?? "Unnamed"}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => restore(p.id)}>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,880 @@
|
|||||||
|
.f3 {
|
||||||
|
--female-color: rgb(196, 138, 146);
|
||||||
|
--male-color: rgb(120, 159, 172);
|
||||||
|
--genderless-color: lightgray;
|
||||||
|
--background-color: rgb(33, 33, 33);
|
||||||
|
--text-color: #fff;
|
||||||
|
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.f3 svg.main_svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.f3 svg.main_svg text {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
.f3 rect.card-female, .f3 .card-female .card-body-rect, .f3 .card-female .text-overflow-mask {
|
||||||
|
fill: var(--female-color);
|
||||||
|
}
|
||||||
|
.f3 rect.card-male, .f3 .card-male .card-body-rect, .f3 .card-male .text-overflow-mask {
|
||||||
|
fill: var(--male-color);
|
||||||
|
}
|
||||||
|
.f3 .card-genderless .card-body-rect, .f3 .card-genderless .text-overflow-mask {
|
||||||
|
fill: var(--genderless-color);
|
||||||
|
}
|
||||||
|
.f3 .card_add .card-body-rect {
|
||||||
|
fill: #3b5560;
|
||||||
|
stroke-width: 4px;
|
||||||
|
stroke: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.f3 g.card_add text {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
.f3 .card-main-outline {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
|
.f3 .card_family_tree rect {
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.f3 .card_family_tree:hover rect {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.f3 .card_add_relative {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.f3 .card_add_relative circle {
|
||||||
|
fill: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
.f3 .card_add_relative:hover {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.f3 .card_edit.pencil_icon {
|
||||||
|
color: #fff;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.f3 .card_edit.pencil_icon:hover {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.f3 .card_break_link, .f3 .link_upper, .f3 .link_lower, .f3 .link_particles {
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
transition: 1s;
|
||||||
|
}
|
||||||
|
.f3 .card_break_link {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.f3 .card_break_link.closed .link_upper {
|
||||||
|
transform: translate(-140.5px, 655.6px);
|
||||||
|
}
|
||||||
|
.f3 .card_break_link.closed .link_upper g {
|
||||||
|
transform: rotate(-58deg);
|
||||||
|
}
|
||||||
|
.f3 .card_break_link.closed .link_particles {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
.f3 .input-field input {
|
||||||
|
height: 2.5rem !important;
|
||||||
|
}
|
||||||
|
.f3 .input-field > label:not(.label-icon).active {
|
||||||
|
-webkit-transform: translateY(-8px) scale(0.8);
|
||||||
|
transform: translateY(-8px) scale(0.8);
|
||||||
|
}
|
||||||
|
.f3.f3-cont {
|
||||||
|
width:100%;
|
||||||
|
height:900px;
|
||||||
|
max-height:70vh;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.f3 {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* form-info */
|
||||||
|
.f3-form input[type="text"],
|
||||||
|
.f3-form textarea,
|
||||||
|
.f3-form select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--background-color);
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form input[type="text"]:focus,
|
||||||
|
.f3-form textarea:focus,
|
||||||
|
.f3-form select:focus {
|
||||||
|
box-shadow: 0 0 5px rgba(76, 175, 80, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 10px 0;
|
||||||
|
transition: background-color 0.3s ease-in-out, border-color 0.3s ease-in-out, color 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form button[type="submit"] {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-cancel-btn {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form .f3-delete-btn {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #f44336;
|
||||||
|
color: #f44336;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-delete-btn:hover {
|
||||||
|
background-color: #da190b;
|
||||||
|
border-color: #da190b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-delete-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #f44336;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form .f3-remove-relative-btn {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
color: currentColor;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-remove-relative-btn:hover, .f3-remove-relative-btn.active {
|
||||||
|
background-color: var(--text-color);
|
||||||
|
border-color: var(--text-color);
|
||||||
|
color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-radio-group {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-radio-group label {
|
||||||
|
margin-right: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-radio-group input[type="radio"] {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-info-field-label, .f3-form-field label {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
display: block;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-info-field-value {
|
||||||
|
font-weight: normal;
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||||
|
padding-bottom: 1px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form-buttons {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form.non-editable .f3-form-buttons,
|
||||||
|
.f3-form.non-editable .f3-delete-btn,
|
||||||
|
.f3-form.non-editable .f3-remove-relative-btn,
|
||||||
|
.f3-form.non-editable .f3-link-existing-relative {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-close-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-edit-btn {
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-add-relative-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 27px;
|
||||||
|
height: 27px;
|
||||||
|
margin-right: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* card-html */
|
||||||
|
|
||||||
|
.f3 div.card {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color);
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 5px;
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle div.card-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
|
max-width: 150%;
|
||||||
|
min-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-rect {
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 120px;
|
||||||
|
min-height: 70px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.f3 div.card-image-rect {
|
||||||
|
width: 200px;
|
||||||
|
min-height: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-rect .person-icon {
|
||||||
|
height: 70px;
|
||||||
|
width: 70px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-rect img {
|
||||||
|
height: 70px;
|
||||||
|
width: 70px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-rect svg {
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-rect div.card-label {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.mini-tree {
|
||||||
|
text-align: right;
|
||||||
|
position: absolute;
|
||||||
|
top: -15px;
|
||||||
|
right: -2px;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.f3 div.mini-tree svg {
|
||||||
|
width: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-card-duplicate-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
color: rgb(255, 251, 220);
|
||||||
|
background-color: rgba(255, 251, 220, 0);
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 2px;
|
||||||
|
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-card-duplicate-hover div.card-inner {
|
||||||
|
transform: translate(0, -2px);
|
||||||
|
outline: 4px solid rgb(255, 251, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-card-duplicate-hover .f3-card-duplicate-tag {
|
||||||
|
background-color: rgba(255, 251, 220, .8);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card-inner {
|
||||||
|
transition: border 0.2s ease-in-out, opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card:hover .card-inner {
|
||||||
|
opacity: .25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card-male.card-depth--1:hover .card-inner {
|
||||||
|
transform: translate(-8px, -8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-female.card-depth--1:hover .card-inner {
|
||||||
|
transform: translate(8px, -8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-female.card-depth-0:hover .card-inner {
|
||||||
|
transform: translate(8px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-male.card-depth-0:hover .card-inner {
|
||||||
|
transform: translate(-8px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-depth-1:hover .card-inner {
|
||||||
|
transform: translate(0, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-main .card-inner {
|
||||||
|
transform: translate(0, 0)!important;
|
||||||
|
opacity: 1!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.f3 div.card > div {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .card-inner {
|
||||||
|
outline: 0px solid rgba(255, 255, 255, 1);
|
||||||
|
transition: outline 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-female .card-inner, .f3 div.card-female .person-icon svg {
|
||||||
|
background-color: var(--female-color);
|
||||||
|
}
|
||||||
|
.f3 div.card-male .card-inner, .f3 div.card-male .person-icon svg {
|
||||||
|
background-color: var(--male-color);
|
||||||
|
}
|
||||||
|
.f3 div.card-genderless .card-inner, .f3 div.card-genderless .person-icon svg {
|
||||||
|
background-color: var(--genderless-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-new-rel .card-inner, .f3 div.card-new-rel .person-icon svg {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-to-add .card-inner {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-to-add .card-inner .card-label {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-to-add .person-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-new-rel .card-inner {
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: dashed;
|
||||||
|
outline: 0px !important;
|
||||||
|
}
|
||||||
|
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-to-add.card-female .card-inner {
|
||||||
|
border-color: var(--female-color);
|
||||||
|
color: var(--female-color);
|
||||||
|
}
|
||||||
|
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-to-add.card-male .card-inner {
|
||||||
|
color: var(--male-color);
|
||||||
|
border-color: var(--male-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-unknown .card-inner {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-unknown .card-inner .card-label {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-unknown .person-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-new-rel .card-inner {
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: dashed;
|
||||||
|
outline: 0px !important;
|
||||||
|
}
|
||||||
|
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-unknown.card-female .card-inner {
|
||||||
|
border-color: var(--female-color);
|
||||||
|
color: var(--female-color);
|
||||||
|
}
|
||||||
|
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-unknown.card-male .card-inner {
|
||||||
|
color: var(--male-color);
|
||||||
|
border-color: var(--male-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card:hover > div {
|
||||||
|
transform: translate(0, -2px);
|
||||||
|
}
|
||||||
|
.f3 div.card-main .card-inner, .f3 div.card:hover .card-inner {
|
||||||
|
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-main .card-inner {
|
||||||
|
outline: 4px solid rgba(220, 220, 220, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-inner.f3-path-to-main {
|
||||||
|
outline: 4px solid rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .link {
|
||||||
|
transition: stroke-width 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .link.f3-path-to-main {
|
||||||
|
stroke-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.f3-form-cont {
|
||||||
|
position: relative;
|
||||||
|
z-index: 6;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
overflow: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form-cont.opened {
|
||||||
|
width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form hr {
|
||||||
|
border-style: solid;
|
||||||
|
border-width: thin 0 0 0;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-nav-cont {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-history-controls {
|
||||||
|
padding: 8px 5px 7px 9px;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-back-button, .f3-forward-button {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
margin-right: 10px;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-history-controls svg {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-back-button.disabled, .f3-forward-button.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-modal {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: rgb(0,0,0);
|
||||||
|
background-color: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-modal-content {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 15% auto;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 5px;
|
||||||
|
width: 500px;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-modal-close {
|
||||||
|
color: #aaa;
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 7px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-modal-close:hover,
|
||||||
|
.f3-modal-close:focus {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup {
|
||||||
|
position: fixed;
|
||||||
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-content {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-nav {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-content-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-close {
|
||||||
|
color: #aaa;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
right: 6px;
|
||||||
|
top: 1px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-close:hover,
|
||||||
|
.f3-popup-close:focus {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.f3-btn {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 10px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
border-width: 0;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, .6);
|
||||||
|
|
||||||
|
background-color: var(--text-color);
|
||||||
|
color: var(--background-color);
|
||||||
|
|
||||||
|
transition: background-color .3s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-btn:hover, .f3-btn:focus {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-female-bg {
|
||||||
|
background-color: var(--female-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-male-bg {
|
||||||
|
background-color: var(--male-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-genderless-bg {
|
||||||
|
background-color: var(--genderless-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-female-color {
|
||||||
|
color: var(--female-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-male-color {
|
||||||
|
color: var(--male-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-genderless-color {
|
||||||
|
color: var(--genderless-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-cont {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete input {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.f3-autocomplete input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: color 0.3s ease-in-out;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-items {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 0;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
transition: max-height 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete.active .f3-autocomplete-items {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-item > div {
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.f3-autocomplete-item > div:hover, .f3-autocomplete-item.f3-selected > div {
|
||||||
|
background-color: var(--text-color);
|
||||||
|
color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-active {
|
||||||
|
background-color: DodgerBlue !important;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-info {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-info .f3-info-field {
|
||||||
|
color:#b3b01e
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-info-icon {
|
||||||
|
cursor:pointer;
|
||||||
|
display:inline-block;
|
||||||
|
width:18px;
|
||||||
|
height:18px;
|
||||||
|
color:#04a4f4;
|
||||||
|
position:relative;
|
||||||
|
top:4px;
|
||||||
|
left:2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-info .f3 {
|
||||||
|
width:100%;
|
||||||
|
height: 100%;
|
||||||
|
position:relative;
|
||||||
|
background-color:rgb(33,33,33);
|
||||||
|
color:#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-kinship-info .card-kinship-self {
|
||||||
|
min-height: 0px;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--background-color) !important;
|
||||||
|
border: solid 3px;
|
||||||
|
color: #437fae;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-kinship-info .card-kinship-self.f3-real-label {
|
||||||
|
width: 150px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-kinship-info .card-kinship-rel {
|
||||||
|
min-height: 0px;
|
||||||
|
width: 150px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50px;
|
||||||
|
background-color: #1d3456 !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-kinship-info .card-kinship-default {
|
||||||
|
min-height: 0px;
|
||||||
|
width: 150px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50px;
|
||||||
|
background-color: var(--background-color) !important;
|
||||||
|
border: solid 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-labels-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-labels-toggle label {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-labels-toggle input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Vendored from family-chart/dist/styles (the package blocks the CSS subpath export).
|
||||||
|
import "./chart.css";
|
||||||
|
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
|
||||||
|
function splitName(name: string | null | undefined): [string, string] {
|
||||||
|
const t = (name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (t.length <= 1) return [name ?? "", ""];
|
||||||
|
return [t.slice(0, -1).join(" "), t[t.length - 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TreePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
if (p.response.status === 401) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [r, e] = await Promise.all([
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
]);
|
||||||
|
const people = p.data ?? [];
|
||||||
|
const rels: Relationship[] = r.data ?? [];
|
||||||
|
const events: Event[] = e.data ?? [];
|
||||||
|
if (people.length === 0) {
|
||||||
|
if (!cancelled) setStatus("empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentsOf = (id: string) =>
|
||||||
|
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id);
|
||||||
|
const childrenOf = (id: string) =>
|
||||||
|
rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id);
|
||||||
|
const partnersOf = (id: string) =>
|
||||||
|
rels
|
||||||
|
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id))
|
||||||
|
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id));
|
||||||
|
|
||||||
|
const birthYear = new Map<string, string>();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.person_id && ev.event_type === "birth" && !birthYear.has(ev.person_id)) {
|
||||||
|
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
|
||||||
|
if (y) birthYear.set(ev.person_id, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = people.map((pp) => {
|
||||||
|
const [fn, ln] = splitName(pp.primary_name);
|
||||||
|
return {
|
||||||
|
id: pp.id,
|
||||||
|
data: {
|
||||||
|
"first name": fn || "Unnamed",
|
||||||
|
"last name": ln,
|
||||||
|
birthday: birthYear.get(pp.id) ?? "",
|
||||||
|
gender: pp.gender === "female" ? "F" : "M",
|
||||||
|
},
|
||||||
|
rels: {
|
||||||
|
spouses: partnersOf(pp.id),
|
||||||
|
parents: parentsOf(pp.id),
|
||||||
|
children: childrenOf(pp.id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
try {
|
||||||
|
const f3 = await import("family-chart");
|
||||||
|
containerRef.current.innerHTML = "";
|
||||||
|
const chart = f3.createChart(containerRef.current, data);
|
||||||
|
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
|
||||||
|
chart.updateTree({ initial: true });
|
||||||
|
if (!cancelled) setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setStatus("error");
|
||||||
|
}
|
||||||
|
})().catch(() => {
|
||||||
|
if (!cancelled) setStatus("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [router, treeId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Tree</h1>
|
||||||
|
<span className="text-sm text-[var(--muted)]">
|
||||||
|
Drag to pan · scroll to zoom · click a person to recenter
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{status === "empty" && (
|
||||||
|
<p className="text-[var(--muted)]">
|
||||||
|
No people yet — add some under People, or import a GEDCOM.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="f3 rounded-xl border border-[var(--border)]"
|
||||||
|
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
|
||||||
|
export default function TreesLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] md:flex md:flex-col">
|
||||||
|
<AppSidebar />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* Compact bar for small screens (full sidebar is md+). */}
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 md:hidden">
|
||||||
|
<Link href="/" aria-label="Provenance — home">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
|
||||||
|
</Link>
|
||||||
|
<Link href="/trees" className="text-sm text-bronze">
|
||||||
|
Trees
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-4xl px-6 py-10 md:px-10">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+55
-31
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type Tree = components["schemas"]["TreeRead"];
|
type Tree = components["schemas"]["TreeRead"];
|
||||||
@@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"];
|
|||||||
export default function TreesPage() {
|
export default function TreesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [trees, setTrees] = useState<Tree[]>([]);
|
const [trees, setTrees] = useState<Tree[]>([]);
|
||||||
|
const [deleted, setDeleted] = useState<Tree[]>([]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ export default function TreesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTrees(data ?? []);
|
setTrees(data ?? []);
|
||||||
|
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
|
||||||
|
setDeleted(del.data ?? []);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
@@ -42,34 +45,26 @@ export default function TreesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function remove(id: string) {
|
||||||
await api.POST("/api/v1/auth/logout");
|
await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } });
|
||||||
router.push("/login");
|
load();
|
||||||
|
}
|
||||||
|
async function restore(id: string) {
|
||||||
|
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
|
||||||
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<h1 className="text-2xl font-semibold">Your trees</h1>
|
||||||
<h1 className="text-2xl font-bold">Your trees</h1>
|
|
||||||
<Button variant="ghost" size="sm" onClick={logout}>
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="p-5">
|
||||||
<CardTitle className="text-base">New tree</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={createTree} className="flex gap-2">
|
<form onSubmit={createTree} className="flex gap-2">
|
||||||
<Input
|
<Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
placeholder="Family name"
|
<Button type="submit">Create tree</Button>
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Create</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -77,23 +72,52 @@ export default function TreesPage() {
|
|||||||
{trees.length === 0 ? (
|
{trees.length === 0 ? (
|
||||||
<p className="text-[var(--muted)]">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="grid gap-3 sm:grid-cols-2">
|
||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<li key={tree.id}>
|
<li key={tree.id}>
|
||||||
<Link href={`/trees/${tree.id}`}>
|
<Card className="transition-colors hover:border-bronze/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">
|
<Link href={`/trees/${tree.id}/tree`} className="min-w-0 flex-1">
|
||||||
<span className="font-medium">{tree.name}</span>
|
<div className="truncate font-medium">{tree.name}</div>
|
||||||
<span className="text-xs uppercase tracking-wide text-bronze">
|
<div className="text-xs uppercase tracking-wide text-bronze">
|
||||||
{tree.visibility}
|
{tree.visibility}
|
||||||
</span>
|
</div>
|
||||||
</CardContent>
|
</Link>
|
||||||
</Card>
|
<button
|
||||||
</Link>
|
onClick={() => remove(tree.id)}
|
||||||
|
className="ml-3 text-[var(--muted)] hover:text-bronze"
|
||||||
|
aria-label="Delete tree"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deleted.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
|
||||||
|
Recently deleted
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{deleted.map((tree) => (
|
||||||
|
<li key={tree.id}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-[var(--muted)]">{tree.name}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowDownUp,
|
||||||
|
BookText,
|
||||||
|
FolderTree,
|
||||||
|
Image as ImageIcon,
|
||||||
|
LogOut,
|
||||||
|
Network,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
||||||
|
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
|
||||||
|
const [treeName, setTreeName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!treeId) {
|
||||||
|
setTreeName(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } })
|
||||||
|
.then((r) => setTreeName(r.data?.name ?? null));
|
||||||
|
}, [treeId]);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await api.POST("/api/v1/auth/logout");
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = ({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Users;
|
||||||
|
active: boolean;
|
||||||
|
}) => (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-bronze/12 font-medium text-bronze"
|
||||||
|
: "text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-[var(--foreground)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex h-full flex-col gap-1 p-4">
|
||||||
|
<Link href="/" className="mb-5 flex items-center px-2" aria-label="Provenance — home">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
|
||||||
|
|
||||||
|
{treeId && (
|
||||||
|
<div className="mt-5 flex flex-col gap-1">
|
||||||
|
<div className="truncate px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||||
|
{treeName ?? "Tree"}
|
||||||
|
</div>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/tree`}
|
||||||
|
label="Tree"
|
||||||
|
icon={Network}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/tree`)}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}`}
|
||||||
|
label="People"
|
||||||
|
icon={Users}
|
||||||
|
active={pathname === `/trees/${treeId}` || pathname.startsWith(`/trees/${treeId}/persons`)}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/sources`}
|
||||||
|
label="Sources"
|
||||||
|
icon={BookText}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/sources`)}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/media`}
|
||||||
|
label="Media"
|
||||||
|
icon={ImageIcon}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/media`)}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/gedcom`}
|
||||||
|
label="Import / Export"
|
||||||
|
icon={ArrowDownUp}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/recovery`}
|
||||||
|
label="Recovery"
|
||||||
|
icon={Archive}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/recovery`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="mt-auto flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-bronze"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 shrink-0" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,19 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
// Bronze is the brand accent; paper reads cleanly on it.
|
default: "bg-bronze text-paper shadow-sm hover:bg-bronze-deep hover:shadow",
|
||||||
default: "bg-bronze text-paper hover:bg-bronze-deep",
|
|
||||||
outline:
|
outline:
|
||||||
"border border-bronze text-bronze bg-transparent hover:bg-bronze hover:text-paper",
|
"border border-[var(--border)] bg-[var(--surface)] hover:border-bronze hover:text-bronze",
|
||||||
ghost: "text-[var(--foreground)] hover:bg-bronze/10",
|
ghost: "text-[var(--foreground)] hover:bg-bronze/10",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 text-sm",
|
||||||
sm: "h-9 px-3",
|
sm: "h-9 px-3 text-sm",
|
||||||
|
lg: "h-12 px-6 text-base",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "default", size: "default" },
|
defaultVariants: { variant: "default", size: "default" },
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-sm",
|
"rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-[0_1px_2px_rgba(26,26,23,0.04),0_8px_24px_-12px_rgba(26,26,23,0.10)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
|
|||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm placeholder:text-[var(--muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze disabled:opacity-50",
|
"flex h-10 w-full rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm placeholder:text-[var(--muted)] transition-colors focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40 disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Vendored
+582
-4
@@ -186,6 +186,24 @@ export interface paths {
|
|||||||
get: operations["get_tree_api_v1_trees__tree_id__get"];
|
get: operations["get_tree_api_v1_trees__tree_id__get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
|
/** Delete Tree */
|
||||||
|
delete: operations["delete_tree_api_v1_trees__tree_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/restore": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Restore Tree */
|
||||||
|
post: operations["restore_tree_api_v1_trees__tree_id__restore_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -221,6 +239,24 @@ export interface paths {
|
|||||||
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
|
/** Delete Person */
|
||||||
|
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Restore Person */
|
||||||
|
post: operations["restore_person_api_v1_trees__tree_id__persons__person_id__restore_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -234,7 +270,8 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
/** List Tree Events */
|
||||||
|
get: operations["list_tree_events_api_v1_trees__tree_id__events_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Create Event */
|
/** Create Event */
|
||||||
post: operations["create_event_api_v1_trees__tree_id__events_post"];
|
post: operations["create_event_api_v1_trees__tree_id__events_post"];
|
||||||
@@ -285,7 +322,8 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
/** List Relationships */
|
||||||
|
get: operations["list_relationships_api_v1_trees__tree_id__relationships_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Create Relationship */
|
/** Create Relationship */
|
||||||
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
|
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
|
||||||
@@ -400,10 +438,114 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/media": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Media */
|
||||||
|
get: operations["list_media_api_v1_trees__tree_id__media_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Upload Media */
|
||||||
|
post: operations["upload_media_api_v1_trees__tree_id__media_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/media/{media_id}/content": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Media Content */
|
||||||
|
get: operations["media_content_api_v1_trees__tree_id__media__media_id__content_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/media/{media_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Media */
|
||||||
|
delete: operations["delete_media_api_v1_trees__tree_id__media__media_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/gedcom/import": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Import Gedcom */
|
||||||
|
post: operations["import_gedcom_api_v1_trees__tree_id__gedcom_import_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/gedcom/export": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Export Gedcom */
|
||||||
|
get: operations["export_gedcom_api_v1_trees__tree_id__gedcom_export_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
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: {
|
||||||
|
/** Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post */
|
||||||
|
Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
|
||||||
|
/** File */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
/** Body_upload_media_api_v1_trees__tree_id__media_post */
|
||||||
|
Body_upload_media_api_v1_trees__tree_id__media_post: {
|
||||||
|
/** File */
|
||||||
|
file: string;
|
||||||
|
/** Title */
|
||||||
|
title?: string | null;
|
||||||
|
/** Person Id */
|
||||||
|
person_id?: string | null;
|
||||||
|
/** Event Id */
|
||||||
|
event_id?: string | null;
|
||||||
|
/** Source Id */
|
||||||
|
source_id?: string | null;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* CitationConfidence
|
* CitationConfidence
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -539,6 +681,15 @@ export interface components {
|
|||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: components["schemas"]["ValidationError"][];
|
detail?: components["schemas"]["ValidationError"][];
|
||||||
};
|
};
|
||||||
|
/** ImportReport */
|
||||||
|
ImportReport: {
|
||||||
|
/** Counts */
|
||||||
|
counts: {
|
||||||
|
[key: string]: number;
|
||||||
|
};
|
||||||
|
/** Unmapped Tags */
|
||||||
|
unmapped_tags: string[];
|
||||||
|
};
|
||||||
/** LoginRequest */
|
/** LoginRequest */
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
/** Email */
|
/** Email */
|
||||||
@@ -546,6 +697,42 @@ export interface components {
|
|||||||
/** Password */
|
/** Password */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
/** MediaRead */
|
||||||
|
MediaRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
/** Original Filename */
|
||||||
|
original_filename: string;
|
||||||
|
/** Content Type */
|
||||||
|
content_type: string;
|
||||||
|
/** Byte Size */
|
||||||
|
byte_size: number;
|
||||||
|
/** Checksum Sha256 */
|
||||||
|
checksum_sha256: string;
|
||||||
|
/** Title */
|
||||||
|
title: string | null;
|
||||||
|
/** Person Id */
|
||||||
|
person_id: string | null;
|
||||||
|
/** Event Id */
|
||||||
|
event_id: string | null;
|
||||||
|
/** Source Id */
|
||||||
|
source_id: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
/** Url */
|
||||||
|
url?: string | null;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* ParentChildQualifier
|
* ParentChildQualifier
|
||||||
* @description Qualifies a parent_child edge so adoption/donor/blended families are
|
* @description Qualifies a parent_child edge so adoption/donor/blended families are
|
||||||
@@ -1068,7 +1255,9 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
list_my_trees_api_v1_trees_get: {
|
list_my_trees_api_v1_trees_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: {
|
||||||
|
deleted?: boolean;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -1084,6 +1273,15 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["TreeRead"][];
|
"application/json": components["schemas"]["TreeRead"][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
create_tree_api_v1_trees_post: {
|
create_tree_api_v1_trees_post: {
|
||||||
@@ -1150,7 +1348,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
list_persons_api_v1_trees__tree_id__persons_get: {
|
delete_tree_api_v1_trees__tree_id__delete: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -1160,6 +1358,68 @@ export interface operations {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: 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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
restore_tree_api_v1_trees__tree_id__restore_post: {
|
||||||
|
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"]["TreeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_persons_api_v1_trees__tree_id__persons_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
deleted?: boolean;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Successful Response */
|
/** @description Successful Response */
|
||||||
200: {
|
200: {
|
||||||
@@ -1248,6 +1508,99 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
delete_person_api_v1_trees__tree_id__persons__person_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
person_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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: {
|
||||||
|
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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_tree_events_api_v1_trees__tree_id__events_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"]["EventRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
create_event_api_v1_trees__tree_id__events_post: {
|
create_event_api_v1_trees__tree_id__events_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1345,6 +1698,37 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_relationships_api_v1_trees__tree_id__relationships_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"]["RelationshipRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
create_relationship_api_v1_trees__tree_id__relationships_post: {
|
create_relationship_api_v1_trees__tree_id__relationships_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1666,4 +2050,198 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_media_api_v1_trees__tree_id__media_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MediaRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
upload_media_api_v1_trees__tree_id__media_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["Body_upload_media_api_v1_trees__tree_id__media_post"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MediaRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
media_content_api_v1_trees__tree_id__media__media_id__content_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
media_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_media_api_v1_trees__tree_id__media__media_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
media_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ImportReport"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export_gedcom_api_v1_trees__tree_id__gedcom_export_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": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+817
-25
@@ -281,29 +281,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees": {
|
"/api/v1/trees": {
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"trees"
|
|
||||||
],
|
|
||||||
"summary": "List My Trees",
|
|
||||||
"operationId": "list_my_trees_api_v1_trees_get",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/TreeRead"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"title": "Response List My Trees Api V1 Trees Get"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"trees"
|
"trees"
|
||||||
@@ -311,14 +288,14 @@
|
|||||||
"summary": "Create Tree",
|
"summary": "Create Tree",
|
||||||
"operationId": "create_tree_api_v1_trees_post",
|
"operationId": "create_tree_api_v1_trees_post",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/TreeCreate"
|
"$ref": "#/components/schemas/TreeCreate"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"required": true
|
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"201": {
|
"201": {
|
||||||
@@ -342,6 +319,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"trees"
|
||||||
|
],
|
||||||
|
"summary": "List My Trees",
|
||||||
|
"operationId": "list_my_trees_api_v1_trees_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "deleted",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Deleted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TreeRead"
|
||||||
|
},
|
||||||
|
"title": "Response List My Trees Api V1 Trees Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}": {
|
"/api/v1/trees/{tree_id}": {
|
||||||
@@ -385,6 +407,83 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"trees"
|
||||||
|
],
|
||||||
|
"summary": "Delete Tree",
|
||||||
|
"operationId": "delete_tree_api_v1_trees__tree_id__delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/restore": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"trees"
|
||||||
|
],
|
||||||
|
"summary": "Restore Tree",
|
||||||
|
"operationId": "restore_tree_api_v1_trees__tree_id__restore_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TreeRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons": {
|
"/api/v1/trees/{tree_id}/persons": {
|
||||||
@@ -455,6 +554,16 @@
|
|||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Tree Id"
|
"title": "Tree Id"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleted",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Deleted"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -486,6 +595,50 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"persons"
|
||||||
|
],
|
||||||
|
"summary": "Delete Person",
|
||||||
|
"operationId": "delete_person_api_v1_trees__tree_id__persons__person_id__delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "person_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Person Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"persons"
|
"persons"
|
||||||
@@ -538,6 +691,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"persons"
|
||||||
|
],
|
||||||
|
"summary": "Restore Person",
|
||||||
|
"operationId": "restore_person_api_v1_trees__tree_id__persons__person_id__restore_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "person_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Person Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PersonRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/trees/{tree_id}/events": {
|
"/api/v1/trees/{tree_id}/events": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -589,6 +795,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"events"
|
||||||
|
],
|
||||||
|
"summary": "List Tree Events",
|
||||||
|
"operationId": "list_tree_events_api_v1_trees__tree_id__events_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EventRead"
|
||||||
|
},
|
||||||
|
"title": "Response List Tree Events Api V1 Trees Tree Id Events Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}/events": {
|
"/api/v1/trees/{tree_id}/persons/{person_id}/events": {
|
||||||
@@ -745,6 +996,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"relationships"
|
||||||
|
],
|
||||||
|
"summary": "List Relationships",
|
||||||
|
"operationId": "list_relationships_api_v1_trees__tree_id__relationships_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RelationshipRead"
|
||||||
|
},
|
||||||
|
"title": "Response List Relationships Api V1 Trees Tree Id Relationships Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
|
"/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
|
||||||
@@ -1188,10 +1484,374 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/media": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"media"
|
||||||
|
],
|
||||||
|
"summary": "Upload Media",
|
||||||
|
"operationId": "upload_media_api_v1_trees__tree_id__media_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Body_upload_media_api_v1_trees__tree_id__media_post"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MediaRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"media"
|
||||||
|
],
|
||||||
|
"summary": "List Media",
|
||||||
|
"operationId": "list_media_api_v1_trees__tree_id__media_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MediaRead"
|
||||||
|
},
|
||||||
|
"title": "Response List Media Api V1 Trees Tree Id Media Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/media/{media_id}/content": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"media"
|
||||||
|
],
|
||||||
|
"summary": "Media Content",
|
||||||
|
"operationId": "media_content_api_v1_trees__tree_id__media__media_id__content_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "media_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Media Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/media/{media_id}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"media"
|
||||||
|
],
|
||||||
|
"summary": "Delete Media",
|
||||||
|
"operationId": "delete_media_api_v1_trees__tree_id__media__media_id__delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "media_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Media Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/gedcom/import": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"gedcom"
|
||||||
|
],
|
||||||
|
"summary": "Import Gedcom",
|
||||||
|
"operationId": "import_gedcom_api_v1_trees__tree_id__gedcom_import_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ImportReport"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/gedcom/export": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"gedcom"
|
||||||
|
],
|
||||||
|
"summary": "Export Gedcom",
|
||||||
|
"operationId": "export_gedcom_api_v1_trees__tree_id__gedcom_export_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post": {
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"title": "File"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"title": "Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"
|
||||||
|
},
|
||||||
|
"Body_upload_media_api_v1_trees__tree_id__media_post": {
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"title": "File"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Title"
|
||||||
|
},
|
||||||
|
"person_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Person Id"
|
||||||
|
},
|
||||||
|
"event_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Event Id"
|
||||||
|
},
|
||||||
|
"source_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Source Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"title": "Body_upload_media_api_v1_trees__tree_id__media_post"
|
||||||
|
},
|
||||||
"CitationConfidence": {
|
"CitationConfidence": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1698,6 +2358,30 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HTTPValidationError"
|
"title": "HTTPValidationError"
|
||||||
},
|
},
|
||||||
|
"ImportReport": {
|
||||||
|
"properties": {
|
||||||
|
"counts": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "Counts"
|
||||||
|
},
|
||||||
|
"unmapped_tags": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Unmapped Tags"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"counts",
|
||||||
|
"unmapped_tags"
|
||||||
|
],
|
||||||
|
"title": "ImportReport"
|
||||||
|
},
|
||||||
"LoginRequest": {
|
"LoginRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"email": {
|
"email": {
|
||||||
@@ -1716,6 +2400,114 @@
|
|||||||
],
|
],
|
||||||
"title": "LoginRequest"
|
"title": "LoginRequest"
|
||||||
},
|
},
|
||||||
|
"MediaRead": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Id"
|
||||||
|
},
|
||||||
|
"tree_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
},
|
||||||
|
"original_filename": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Original Filename"
|
||||||
|
},
|
||||||
|
"content_type": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Content Type"
|
||||||
|
},
|
||||||
|
"byte_size": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Byte Size"
|
||||||
|
},
|
||||||
|
"checksum_sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Checksum Sha256"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Title"
|
||||||
|
},
|
||||||
|
"person_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Person Id"
|
||||||
|
},
|
||||||
|
"event_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Event Id"
|
||||||
|
},
|
||||||
|
"source_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Source Id"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Created At"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Url"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"tree_id",
|
||||||
|
"original_filename",
|
||||||
|
"content_type",
|
||||||
|
"byte_size",
|
||||||
|
"checksum_sha256",
|
||||||
|
"title",
|
||||||
|
"person_id",
|
||||||
|
"event_id",
|
||||||
|
"source_id",
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"title": "MediaRead"
|
||||||
|
},
|
||||||
"ParentChildQualifier": {
|
"ParentChildQualifier": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
Generated
+429
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"family-chart": "^0.9.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"openapi-fetch": "^0.13.0",
|
"openapi-fetch": "^0.13.0",
|
||||||
@@ -1111,12 +1112,390 @@
|
|||||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/d3": {
|
||||||
|
"version": "7.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||||
|
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "3",
|
||||||
|
"d3-axis": "3",
|
||||||
|
"d3-brush": "3",
|
||||||
|
"d3-chord": "3",
|
||||||
|
"d3-color": "3",
|
||||||
|
"d3-contour": "4",
|
||||||
|
"d3-delaunay": "6",
|
||||||
|
"d3-dispatch": "3",
|
||||||
|
"d3-drag": "3",
|
||||||
|
"d3-dsv": "3",
|
||||||
|
"d3-ease": "3",
|
||||||
|
"d3-fetch": "3",
|
||||||
|
"d3-force": "3",
|
||||||
|
"d3-format": "3",
|
||||||
|
"d3-geo": "3",
|
||||||
|
"d3-hierarchy": "3",
|
||||||
|
"d3-interpolate": "3",
|
||||||
|
"d3-path": "3",
|
||||||
|
"d3-polygon": "3",
|
||||||
|
"d3-quadtree": "3",
|
||||||
|
"d3-random": "3",
|
||||||
|
"d3-scale": "4",
|
||||||
|
"d3-scale-chromatic": "3",
|
||||||
|
"d3-selection": "3",
|
||||||
|
"d3-shape": "3",
|
||||||
|
"d3-time": "3",
|
||||||
|
"d3-time-format": "4",
|
||||||
|
"d3-timer": "3",
|
||||||
|
"d3-transition": "3",
|
||||||
|
"d3-zoom": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-axis": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-brush": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "3",
|
||||||
|
"d3-transition": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-chord": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-contour": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-delaunay": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||||
|
"dependencies": {
|
||||||
|
"delaunator": "5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dsv": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "7",
|
||||||
|
"iconv-lite": "0.6",
|
||||||
|
"rw": "1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"csv2json": "bin/dsv2json.js",
|
||||||
|
"csv2tsv": "bin/dsv2dsv.js",
|
||||||
|
"dsv2dsv": "bin/dsv2dsv.js",
|
||||||
|
"dsv2json": "bin/dsv2json.js",
|
||||||
|
"json2csv": "bin/json2dsv.js",
|
||||||
|
"json2dsv": "bin/json2dsv.js",
|
||||||
|
"json2tsv": "bin/json2dsv.js",
|
||||||
|
"tsv2csv": "bin/dsv2dsv.js",
|
||||||
|
"tsv2json": "bin/dsv2json.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-fetch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dsv": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-force": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-quadtree": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.5.0 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-hierarchy": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-polygon": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-quadtree": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-random": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale-chromatic": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1134,6 +1513,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delaunator": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"robust-predicates": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1156,6 +1543,14 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/family-chart": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/family-chart/-/family-chart-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-+JdLr1Oo+YFnQWUXgdnk4nCMTbe1MXKdpbx3KEBXPeq2oX+2v5ccmrcK39CZ761/zQfgSHFZ2cT/+gbaeeACcA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3": "^7.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -1181,6 +1576,17 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/index-to-position": {
|
"node_modules/index-to-position": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||||
@@ -1193,6 +1599,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -1734,6 +2148,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/robust-predicates": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="
|
||||||
|
},
|
||||||
|
"node_modules/rw": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
|||||||
+11
-10
@@ -10,23 +10,24 @@
|
|||||||
"gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false"
|
"gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.1.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"openapi-fetch": "^0.13.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"family-chart": "^0.9.0",
|
||||||
"lucide-react": "^0.469.0"
|
"lucide-react": "^0.469.0",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"openapi-fetch": "^0.13.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"openapi-typescript": "^7.5.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"openapi-typescript": "^7.5.0"
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare module "family-chart";
|
||||||
|
declare module "family-chart/dist/styles/family-chart.css";
|
||||||
Reference in New Issue
Block a user