Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99a660485e | |||
| cf6dcf9ce2 | |||
| 22bc536978 | |||
| f2205b93f4 | |||
| b0c7c8570b | |||
| fe9a95c60d | |||
| bd8ee9b647 | |||
| 660130f007 |
@@ -20,6 +20,15 @@ async def create_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])
|
||||
async def list_person_events(
|
||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, status
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["media"])
|
||||
|
||||
def _content_url(media) -> str:
|
||||
return f"/api/v1/trees/{media.tree_id}/media/{media.id}/content"
|
||||
|
||||
|
||||
def _with_url(media, url: str) -> MediaRead:
|
||||
def _read(media) -> MediaRead:
|
||||
out = MediaRead.model_validate(media)
|
||||
out.url = url
|
||||
# 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,
|
||||
@@ -42,16 +49,36 @@ async def upload_media(
|
||||
event_id=event_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
return _with_url(media, await store.presigned_get_url(key=media.storage_key))
|
||||
return _read(media)
|
||||
|
||||
|
||||
@router.get("/{tree_id}/media", response_model=list[MediaRead])
|
||||
async def list_media(
|
||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, store: ObjectStoreDep
|
||||
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 [_with_url(m, await store.presigned_get_url(key=m.storage_key)) for m in items]
|
||||
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)
|
||||
|
||||
@@ -36,13 +36,37 @@ async def create_person(
|
||||
|
||||
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
||||
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]:
|
||||
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]
|
||||
|
||||
|
||||
@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)
|
||||
async def get_person(
|
||||
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)
|
||||
|
||||
|
||||
@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(
|
||||
"/{tree_id}/persons/{person_id}/relationships",
|
||||
response_model=list[RelationshipRead],
|
||||
|
||||
@@ -22,8 +22,13 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse
|
||||
|
||||
|
||||
@router.get("", response_model=list[TreeRead])
|
||||
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]:
|
||||
trees = await tree_service.list_trees_for_user(session, user=current)
|
||||
async def list_my_trees(
|
||||
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]
|
||||
|
||||
|
||||
@@ -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:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
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)
|
||||
|
||||
@@ -15,6 +15,9 @@ class ObjectStore(ABC):
|
||||
@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: ...
|
||||
|
||||
|
||||
@@ -44,6 +44,13 @@ class S3ObjectStore(ObjectStore):
|
||||
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,
|
||||
|
||||
@@ -91,6 +91,20 @@ async def create_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(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||
) -> list[Event]:
|
||||
|
||||
@@ -80,6 +80,23 @@ async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree)
|
||||
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:
|
||||
|
||||
@@ -4,6 +4,7 @@ person through the privacy engine. Each returned Person gets a transient
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -112,6 +113,77 @@ async def get_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(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||
) -> list[Person]:
|
||||
|
||||
@@ -73,6 +73,20 @@ async def create_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(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||
) -> list[Relationship]:
|
||||
|
||||
@@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
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):
|
||||
raise Forbidden("not permitted to view this 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())
|
||||
|
||||
@@ -46,6 +46,9 @@ class FakeObjectStore(ObjectStore):
|
||||
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}"
|
||||
|
||||
|
||||
@@ -22,13 +22,18 @@ async def test_media_upload_list_delete(client):
|
||||
body = resp.json()
|
||||
assert body["original_filename"] == "scan.txt"
|
||||
assert body["byte_size"] == 11
|
||||
assert body["url"].startswith("https://objects.test/")
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -54,3 +54,55 @@ h3,
|
||||
::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%;
|
||||
}
|
||||
|
||||
+1
-32
@@ -1,10 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fraunces, Inter } from "next/font/google";
|
||||
import Link from "next/link";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
// Heritage display serif + clean humanist sans (per docs/brand typography).
|
||||
const serif = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-fraunces",
|
||||
@@ -23,36 +21,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
||||
<body className="flex min-h-screen flex-col antialiased">
|
||||
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-5 py-3.5">
|
||||
<Link href="/" className="flex items-center" aria-label="Provenance — home">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-[var(--foreground)]">
|
||||
Trees
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium transition-colors hover:border-bronze hover:text-bronze"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-10">{children}</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-5 py-6 text-sm text-[var(--muted)]">
|
||||
<span className="font-serif text-base italic">where it came from matters</span>
|
||||
<span>Self-hosted · source-available · your data, your infrastructure</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,13 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<CardTitle>Sign in</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -70,5 +76,7 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-44
@@ -23,55 +23,83 @@ const features = [
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="space-y-20 py-6 sm:py-12">
|
||||
<section className="grid items-center gap-10 sm:grid-cols-[1.3fr_1fr]">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
|
||||
Family · Land · Provenance
|
||||
</p>
|
||||
<h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
|
||||
Where it came from{" "}
|
||||
<span className="italic text-bronze">matters</span>.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
|
||||
Trace your family and your land in one place — every name, every parcel, every claim
|
||||
linked to the record it came from. Self-hosted, sourced, and yours to keep.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link href="/register">
|
||||
<Button size="lg">Create your account</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button size="lg" variant="outline">
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden justify-self-end sm:block">
|
||||
<div className="relative grid h-64 w-64 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] shadow-[0_24px_60px_-24px_rgba(160,106,66,0.35)]">
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-[var(--border)]">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" aria-label="Provenance — home">
|
||||
{/* 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>
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||
</Link>
|
||||
<nav className="flex items-center gap-5 text-sm">
|
||||
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||
Trees
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium hover:border-bronze hover:text-bronze"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-5 sm:grid-cols-3">
|
||||
{features.map((f) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-[0_1px_2px_rgba(26,26,23,0.04)]"
|
||||
>
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-bronze/12 text-bronze">
|
||||
<f.icon className="h-5 w-5" />
|
||||
<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>
|
||||
<h2 className="mt-4 text-lg font-semibold">{f.title}</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">{f.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,13 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<CardTitle>Create your account</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -78,5 +84,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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 { 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 type { components } from "@/lib/api/schema";
|
||||
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";
|
||||
|
||||
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 params = useParams<{ id: string }>();
|
||||
const treeId = params.id;
|
||||
|
||||
const [persons, setPersons] = useState<Person[]>([]);
|
||||
const [given, setGiven] = useState("");
|
||||
const [surname, setSurname] = useState("");
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [rels, setRels] = useState<Relationship[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
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 { 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 } },
|
||||
});
|
||||
if (response.status === 401) {
|
||||
if (p.response.status === 401) {
|
||||
router.push("/login");
|
||||
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);
|
||||
}, [router, treeId]);
|
||||
|
||||
@@ -38,67 +65,292 @@ export default function TreeDetailPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function addPerson(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!given.trim() && !surname.trim()) return;
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { given: given || null, surname: surname || null },
|
||||
});
|
||||
if (!error) {
|
||||
setGiven("");
|
||||
setSurname("");
|
||||
load();
|
||||
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||
const parentsOf = (id: string) =>
|
||||
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
|
||||
const childrenOf = (id: string) =>
|
||||
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||||
const partnersOf = (id: string) =>
|
||||
rels
|
||||
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
|
||||
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
|
||||
|
||||
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 (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;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline">
|
||||
← All trees
|
||||
</Link>
|
||||
<Link href={`/trees/${treeId}/sources`} className="text-sm text-bronze hover:underline">
|
||||
Sources →
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold">Family view</h1>
|
||||
<Link
|
||||
href={`/trees/${treeId}/persons/${focus.id}`}
|
||||
className="text-sm text-bronze hover:underline"
|
||||
>
|
||||
Open {focus.primary_name ?? "person"} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Add a person</CardTitle>
|
||||
</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 className="overflow-x-auto p-6">
|
||||
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
||||
{persons.length === 0 ? (
|
||||
<p className="text-[var(--muted)]">No people yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{persons.map((person) => (
|
||||
<li key={person.id}>
|
||||
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
||||
<Card className="transition-colors hover:border-bronze/50">
|
||||
<CardContent className="p-4">
|
||||
{person.primary_name ?? (
|
||||
<span className="text-[var(--muted)]">Unnamed</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Family group: partners + children of the focus */}
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<h2 className="font-serif text-base font-semibold">Spouses & partners</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{partners.map((id) => (
|
||||
<PersonBox key={id} id={id} muted />
|
||||
))}
|
||||
<AddSlot
|
||||
formKey={`partner-${focus.id}`}
|
||||
kind="partner"
|
||||
anchor={focus.id}
|
||||
label="add spouse"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Searchable index of everyone in the tree */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-serif text-base font-semibold">All people ({people.length})</h2>
|
||||
<Input
|
||||
className="w-56"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{matches.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setFocusId(p.id)}
|
||||
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
|
||||
p.id === focusId
|
||||
? "border-bronze bg-bronze/[0.08] text-bronze"
|
||||
: "border-[var(--border)] hover:border-bronze/60"
|
||||
}`}
|
||||
>
|
||||
{p.primary_name ?? "Unnamed"}
|
||||
</button>
|
||||
))}
|
||||
</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 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() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string; personId: string }>();
|
||||
@@ -37,7 +48,11 @@ export default function PersonDetailPage() {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
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 [relOther, setRelOther] = useState("");
|
||||
@@ -97,15 +112,40 @@ export default function PersonDetailPage() {
|
||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||
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) {
|
||||
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", {
|
||||
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) {
|
||||
setEvDate("");
|
||||
setDateDay("");
|
||||
setDateMonth("");
|
||||
setDateYear("");
|
||||
setDateQual("exact");
|
||||
setEvTypeOther("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
@@ -166,6 +206,13 @@ export default function PersonDetailPage() {
|
||||
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 (!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">
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
@@ -305,9 +357,68 @@ export default function PersonDetailPage() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={addEvent} className="flex flex-wrap gap-2">
|
||||
<Input className="w-36" placeholder="Event type" value={evType} onChange={(e) => setEvType(e.target.value)} />
|
||||
<Input className="w-40" placeholder="Date (e.g. ABT 1850)" value={evDate} onChange={(e) => setEvDate(e.target.value)} />
|
||||
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<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>
|
||||
</form>
|
||||
</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,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 type { components } from "@/lib/api/schema";
|
||||
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";
|
||||
|
||||
type Tree = components["schemas"]["TreeRead"];
|
||||
@@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"];
|
||||
export default function TreesPage() {
|
||||
const router = useRouter();
|
||||
const [trees, setTrees] = useState<Tree[]>([]);
|
||||
const [deleted, setDeleted] = useState<Tree[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
@@ -25,6 +26,8 @@ export default function TreesPage() {
|
||||
return;
|
||||
}
|
||||
setTrees(data ?? []);
|
||||
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
|
||||
setDeleted(del.data ?? []);
|
||||
setReady(true);
|
||||
}, [router]);
|
||||
|
||||
@@ -42,34 +45,26 @@ export default function TreesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.POST("/api/v1/auth/logout");
|
||||
router.push("/login");
|
||||
async function remove(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } });
|
||||
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>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Your trees</h1>
|
||||
<Button variant="ghost" size="sm" onClick={logout}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-2xl font-semibold">Your trees</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">New tree</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-5">
|
||||
<form onSubmit={createTree} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Family name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button type="submit">Create</Button>
|
||||
<Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Button type="submit">Create tree</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -77,23 +72,52 @@ export default function TreesPage() {
|
||||
{trees.length === 0 ? (
|
||||
<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) => (
|
||||
<li key={tree.id}>
|
||||
<Link href={`/trees/${tree.id}`}>
|
||||
<Card className="transition-colors hover:border-bronze/50">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<span className="font-medium">{tree.name}</span>
|
||||
<span className="text-xs uppercase tracking-wide text-bronze">
|
||||
<Card className="transition-colors hover:border-bronze/50">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<Link href={`/trees/${tree.id}`} className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{tree.name}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-bronze">
|
||||
{tree.visibility}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => remove(tree.id)}
|
||||
className="ml-3 text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Delete tree"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { Archive, BookText, FolderTree, Image as ImageIcon, LogOut, 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}`}
|
||||
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}/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>
|
||||
);
|
||||
}
|
||||
Vendored
+468
-4
@@ -186,6 +186,24 @@ export interface paths {
|
||||
get: operations["get_tree_api_v1_trees__tree_id__get"];
|
||||
put?: 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;
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -221,6 +239,24 @@ export interface paths {
|
||||
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
||||
put?: 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;
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -234,7 +270,8 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** List Tree Events */
|
||||
get: operations["list_tree_events_api_v1_trees__tree_id__events_get"];
|
||||
put?: never;
|
||||
/** Create Event */
|
||||
post: operations["create_event_api_v1_trees__tree_id__events_post"];
|
||||
@@ -285,7 +322,8 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** List Relationships */
|
||||
get: operations["list_relationships_api_v1_trees__tree_id__relationships_get"];
|
||||
put?: never;
|
||||
/** Create Relationship */
|
||||
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
|
||||
@@ -400,10 +438,75 @@ export interface paths {
|
||||
patch?: 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;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** 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
|
||||
* @enum {string}
|
||||
@@ -546,6 +649,42 @@ export interface components {
|
||||
/** Password */
|
||||
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
|
||||
* @description Qualifies a parent_child edge so adoption/donor/blended families are
|
||||
@@ -1068,7 +1207,9 @@ export interface operations {
|
||||
};
|
||||
list_my_trees_api_v1_trees_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
deleted?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
@@ -1084,6 +1225,15 @@ export interface operations {
|
||||
"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: {
|
||||
@@ -1150,7 +1300,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_persons_api_v1_trees__tree_id__persons_get: {
|
||||
delete_tree_api_v1_trees__tree_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -1160,6 +1310,68 @@ export interface operations {
|
||||
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_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: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
@@ -1248,6 +1460,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1345,6 +1650,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1666,4 +2002,132 @@ 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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+685
-25
@@ -281,29 +281,6 @@
|
||||
}
|
||||
},
|
||||
"/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": {
|
||||
"tags": [
|
||||
"trees"
|
||||
@@ -311,14 +288,14 @@
|
||||
"summary": "Create Tree",
|
||||
"operationId": "create_tree_api_v1_trees_post",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TreeCreate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"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}": {
|
||||
@@ -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": {
|
||||
@@ -455,6 +554,16 @@
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deleted",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Deleted"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -486,6 +595,50 @@
|
||||
}
|
||||
},
|
||||
"/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": {
|
||||
"tags": [
|
||||
"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": {
|
||||
"post": {
|
||||
"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": {
|
||||
@@ -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": {
|
||||
@@ -1188,10 +1484,266 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"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": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1716,6 +2268,114 @@
|
||||
],
|
||||
"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": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
Reference in New Issue
Block a user