Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22bc536978 | |||
| f2205b93f4 | |||
| b0c7c8570b |
@@ -20,6 +20,15 @@ async def create_event(
|
|||||||
return EventRead.model_validate(event)
|
return EventRead.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/events", response_model=list[EventRead])
|
||||||
|
async def list_tree_events(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
events = await event_service.list_events(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||||
async def list_person_events(
|
async def list_person_events(
|
||||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -36,13 +36,37 @@ async def create_person(
|
|||||||
|
|
||||||
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
||||||
async def list_persons(
|
async def list_persons(
|
||||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, deleted: bool = False
|
||||||
) -> list[PersonRead]:
|
) -> list[PersonRead]:
|
||||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
if deleted:
|
||||||
|
persons = await person_service.list_deleted_persons(
|
||||||
|
session, viewer_id=current.id, tree=tree
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
||||||
return [PersonRead.model_validate(p) for p in persons]
|
return [PersonRead.model_validate(p) for p in persons]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_person(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
|
||||||
|
async def restore_person(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.restore_person(
|
||||||
|
session, actor=current, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
async def get_person(
|
async def get_person(
|
||||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ async def create_relationship(
|
|||||||
return RelationshipRead.model_validate(relationship)
|
return RelationshipRead.model_validate(relationship)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
|
||||||
|
async def list_relationships(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[RelationshipRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{tree_id}/persons/{person_id}/relationships",
|
"/{tree_id}/persons/{person_id}/relationships",
|
||||||
response_model=list[RelationshipRead],
|
response_model=list[RelationshipRead],
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TreeRead])
|
@router.get("", response_model=list[TreeRead])
|
||||||
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]:
|
async def list_my_trees(
|
||||||
trees = await tree_service.list_trees_for_user(session, user=current)
|
session: SessionDep, current: CurrentUser, deleted: bool = False
|
||||||
|
) -> list[TreeRead]:
|
||||||
|
if deleted:
|
||||||
|
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
|
||||||
|
else:
|
||||||
|
trees = await tree_service.list_trees_for_user(session, user=current)
|
||||||
return [TreeRead.model_validate(t) for t in trees]
|
return [TreeRead.model_validate(t) for t in trees]
|
||||||
|
|
||||||
|
|
||||||
@@ -31,3 +36,14 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR
|
|||||||
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
return TreeRead.model_validate(tree)
|
return TreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
|
||||||
|
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/restore", response_model=TreeRead)
|
||||||
|
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
|
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ async def create_event(
|
|||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def list_events(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Event]:
|
||||||
|
"""All events in the tree — lets the family view compute birth/death years."""
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Event)
|
||||||
|
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def list_events_for_person(
|
async def list_events_for_person(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ person through the privacy engine. Each returned Person gets a transient
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -112,6 +113,77 @@ async def get_person(
|
|||||||
return person
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_person(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
person.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_person(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("deleted person not found")
|
||||||
|
person.deleted_at = None
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="restore",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deleted_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Person]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Person)
|
||||||
|
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
|
||||||
|
.order_by(Person.deleted_at.desc())
|
||||||
|
)
|
||||||
|
persons = list((await session.execute(stmt)).scalars().all())
|
||||||
|
for person in persons:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return persons
|
||||||
|
|
||||||
|
|
||||||
async def list_persons(
|
async def list_persons(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
) -> list[Person]:
|
) -> list[Person]:
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ async def create_relationship(
|
|||||||
return relationship
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
|
async def list_relationships(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Relationship]:
|
||||||
|
"""All relationships in the tree — powers the family/pedigree view in one call."""
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Relationship)
|
||||||
|
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
|
||||||
|
.order_by(Relationship.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def list_relationships_for_person(
|
async def list_relationships_for_person(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
) -> list[Relationship]:
|
) -> list[Relationship]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -59,3 +60,55 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
|
|||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
|
||||||
|
"""Load a tree (including soft-deleted) and require the actor be its owner."""
|
||||||
|
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
|
||||||
|
if tree is None:
|
||||||
|
raise NotFound("tree not found")
|
||||||
|
role = await privacy.get_membership_role(session, actor.id, tree.id)
|
||||||
|
if role is not MembershipRole.owner:
|
||||||
|
raise Forbidden("only the owner can delete or restore a tree")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is None:
|
||||||
|
tree.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is not None:
|
||||||
|
tree.deleted_at = None
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="restore",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
||||||
|
stmt = (
|
||||||
|
select(Tree)
|
||||||
|
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
|
||||||
|
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None))
|
||||||
|
.order_by(Tree.deleted_at.desc())
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -2,35 +2,60 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type Person = components["schemas"]["PersonRead"];
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
|
||||||
export default function TreeDetailPage() {
|
function splitName(full: string): { given: string | null; surname: string | null } {
|
||||||
|
const t = full.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (t.length === 0) return { given: null, surname: null };
|
||||||
|
if (t.length === 1) return { given: t[0], surname: null };
|
||||||
|
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddKind = "parent" | "child" | "partner";
|
||||||
|
|
||||||
|
export default function FamilyViewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
|
|
||||||
const [persons, setPersons] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [given, setGiven] = useState("");
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
const [surname, setSurname] = useState("");
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [focusId, setFocusId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
// Inline add-relative form: which anchor + kind is open, and the typed name.
|
||||||
|
const [adding, setAdding] = useState<{ kind: AddKind; anchor: string } | null>(null);
|
||||||
|
const [addName, setAddName] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
});
|
});
|
||||||
if (response.status === 401) {
|
if (p.response.status === 401) {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPersons(data ?? []);
|
const [r, e] = await Promise.all([
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
]);
|
||||||
|
const ppl = p.data ?? [];
|
||||||
|
setPeople(ppl);
|
||||||
|
setRels(r.data ?? []);
|
||||||
|
setEvents(e.data ?? []);
|
||||||
|
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [router, treeId]);
|
||||||
|
|
||||||
@@ -38,60 +63,276 @@ export default function TreeDetailPage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
async function addPerson(e: React.FormEvent) {
|
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||||
e.preventDefault();
|
const parentsOf = (id: string) =>
|
||||||
if (!given.trim() && !surname.trim()) return;
|
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
|
||||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
const childrenOf = (id: string) =>
|
||||||
params: { path: { tree_id: treeId } },
|
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||||||
body: { given: given || null, surname: surname || null },
|
const partnersOf = (id: string) =>
|
||||||
});
|
rels
|
||||||
if (!error) {
|
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
|
||||||
setGiven("");
|
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
|
||||||
setSurname("");
|
|
||||||
load();
|
const years = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
|
||||||
|
for (const p of people) {
|
||||||
|
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
|
||||||
|
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
|
||||||
|
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
|
||||||
|
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, ""));
|
||||||
}
|
}
|
||||||
|
return m;
|
||||||
|
}, [people, events]);
|
||||||
|
|
||||||
|
async function addPerson(name: string): Promise<string | null> {
|
||||||
|
const { given, surname } = splitName(name);
|
||||||
|
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { given, surname },
|
||||||
|
});
|
||||||
|
return data?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFirst(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!firstName.trim()) return;
|
||||||
|
const id = await addPerson(firstName);
|
||||||
|
setFirstName("");
|
||||||
|
if (id) setFocusId(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!adding || !addName.trim()) return;
|
||||||
|
const newId = await addPerson(addName);
|
||||||
|
if (newId) {
|
||||||
|
const { kind, anchor } = adding;
|
||||||
|
const body =
|
||||||
|
kind === "parent"
|
||||||
|
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
|
||||||
|
: kind === "child"
|
||||||
|
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
|
||||||
|
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
|
||||||
|
await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAdding(null);
|
||||||
|
setAddName("");
|
||||||
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
return (
|
if (people.length === 0) {
|
||||||
<div className="space-y-6">
|
return (
|
||||||
<h1 className="text-2xl font-semibold">People</h1>
|
<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 = ({ kind, anchor, label }: { kind: AddKind; anchor: string; label: string }) =>
|
||||||
|
adding && adding.kind === kind && adding.anchor === anchor ? (
|
||||||
|
<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({ 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const parents = parentsOf(focus.id);
|
||||||
|
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-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 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="overflow-x-auto p-6">
|
||||||
<CardTitle className="text-base">Add a person</CardTitle>
|
<div className="flex min-w-[40rem] items-stretch gap-8">
|
||||||
</CardHeader>
|
<div className="flex flex-1 flex-col justify-center gap-3">
|
||||||
<CardContent>
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||||
<form onSubmit={addPerson} className="flex gap-2">
|
Focus
|
||||||
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
|
</div>
|
||||||
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
|
<PersonBox id={focus.id} />
|
||||||
<Button type="submit">Add</Button>
|
</div>
|
||||||
</form>
|
|
||||||
|
<div className="flex flex-1 flex-col justify-center gap-4">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||||
|
Parents
|
||||||
|
</div>
|
||||||
|
{parents.map((pid) => (
|
||||||
|
<PersonBox key={pid} id={pid} muted />
|
||||||
|
))}
|
||||||
|
{parents.length < 2 && <AddSlot kind="parent" anchor={focus.id} label="add parent" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col justify-center gap-4">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||||
|
Grandparents
|
||||||
|
</div>
|
||||||
|
{parents.length === 0 && (
|
||||||
|
<div className="text-sm text-[var(--muted)]">Add parents first.</div>
|
||||||
|
)}
|
||||||
|
{parents.map((pid) => (
|
||||||
|
<div key={pid} className="flex flex-col gap-2">
|
||||||
|
{parentsOf(pid).map((gp) => (
|
||||||
|
<PersonBox key={gp} id={gp} muted />
|
||||||
|
))}
|
||||||
|
{parentsOf(pid).length < 2 && (
|
||||||
|
<AddSlot kind="parent" anchor={pid} label="add parent" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div>
|
{/* Family group: partners + children of the focus */}
|
||||||
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
{persons.length === 0 ? (
|
<Card>
|
||||||
<p className="text-[var(--muted)]">No people yet.</p>
|
<CardContent className="space-y-3 p-6">
|
||||||
) : (
|
<h2 className="font-serif text-base font-semibold">Spouses & partners</h2>
|
||||||
<ul className="space-y-2">
|
<div className="flex flex-wrap gap-3">
|
||||||
{persons.map((person) => (
|
{partners.map((id) => (
|
||||||
<li key={person.id}>
|
<PersonBox key={id} id={id} muted />
|
||||||
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
))}
|
||||||
<Card className="transition-colors hover:border-bronze/50">
|
<AddSlot kind="partner" anchor={focus.id} label="add spouse" />
|
||||||
<CardContent className="p-4">
|
</div>
|
||||||
{person.primary_name ?? (
|
</CardContent>
|
||||||
<span className="text-[var(--muted)]">Unnamed</span>
|
</Card>
|
||||||
)}
|
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardContent className="space-y-3 p-6">
|
||||||
</Link>
|
<h2 className="font-serif text-base font-semibold">Children</h2>
|
||||||
</li>
|
<div className="flex flex-wrap gap-3">
|
||||||
))}
|
{children.map((id) => (
|
||||||
</ul>
|
<PersonBox key={id} id={id} muted />
|
||||||
)}
|
))}
|
||||||
|
<AddSlot 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -206,6 +206,13 @@ export default function PersonDetailPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removePerson() {
|
||||||
|
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
});
|
||||||
|
router.push(`/trees/${treeId}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
||||||
|
|
||||||
@@ -311,7 +318,12 @@ export default function PersonDetailPage() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
||||||
{citeControl("p", { person_id: personId }, personCites)}
|
<div className="flex items-center gap-3">
|
||||||
|
{citeControl("p", { person_id: personId }, personCites)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={removePerson}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+56
-22
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type Tree = components["schemas"]["TreeRead"];
|
type Tree = components["schemas"]["TreeRead"];
|
||||||
@@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"];
|
|||||||
export default function TreesPage() {
|
export default function TreesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [trees, setTrees] = useState<Tree[]>([]);
|
const [trees, setTrees] = useState<Tree[]>([]);
|
||||||
|
const [deleted, setDeleted] = useState<Tree[]>([]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ export default function TreesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTrees(data ?? []);
|
setTrees(data ?? []);
|
||||||
|
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
|
||||||
|
setDeleted(del.data ?? []);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
@@ -42,24 +45,26 @@ export default function TreesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<h1 className="text-2xl font-semibold">Your trees</h1>
|
<h1 className="text-2xl font-semibold">Your trees</h1>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="p-5">
|
||||||
<CardTitle className="text-base">New tree</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={createTree} className="flex gap-2">
|
<form onSubmit={createTree} className="flex gap-2">
|
||||||
<Input
|
<Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
placeholder="Family name"
|
<Button type="submit">Create tree</Button>
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Create</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -67,23 +72,52 @@ export default function TreesPage() {
|
|||||||
{trees.length === 0 ? (
|
{trees.length === 0 ? (
|
||||||
<p className="text-[var(--muted)]">No trees yet — create your first one above.</p>
|
<p className="text-[var(--muted)]">No trees yet — create your first one above.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<li key={tree.id}>
|
<li key={tree.id}>
|
||||||
<Link href={`/trees/${tree.id}`}>
|
<Card className="transition-colors hover:border-bronze/50">
|
||||||
<Card className="transition-colors hover:border-bronze/50">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<Link href={`/trees/${tree.id}`} className="min-w-0 flex-1">
|
||||||
<span className="font-medium">{tree.name}</span>
|
<div className="truncate font-medium">{tree.name}</div>
|
||||||
<span className="text-xs uppercase tracking-wide text-bronze">
|
<div className="text-xs uppercase tracking-wide text-bronze">
|
||||||
{tree.visibility}
|
{tree.visibility}
|
||||||
</span>
|
</div>
|
||||||
</CardContent>
|
</Link>
|
||||||
</Card>
|
<button
|
||||||
</Link>
|
onClick={() => remove(tree.id)}
|
||||||
|
className="ml-3 text-[var(--muted)] hover:text-bronze"
|
||||||
|
aria-label="Delete tree"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deleted.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
|
||||||
|
Recently deleted
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{deleted.map((tree) => (
|
||||||
|
<li key={tree.id}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-[var(--muted)]">{tree.name}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BookText, FolderTree, Image as ImageIcon, LogOut, Users } from "lucide-react";
|
import { Archive, BookText, FolderTree, Image as ImageIcon, LogOut, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -87,6 +87,12 @@ export function AppSidebar() {
|
|||||||
icon={ImageIcon}
|
icon={ImageIcon}
|
||||||
active={pathname.startsWith(`/trees/${treeId}/media`)}
|
active={pathname.startsWith(`/trees/${treeId}/media`)}
|
||||||
/>
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/recovery`}
|
||||||
|
label="Recovery"
|
||||||
|
icon={Archive}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/recovery`)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Vendored
+239
-4
@@ -186,6 +186,24 @@ export interface paths {
|
|||||||
get: operations["get_tree_api_v1_trees__tree_id__get"];
|
get: operations["get_tree_api_v1_trees__tree_id__get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
|
/** Delete Tree */
|
||||||
|
delete: operations["delete_tree_api_v1_trees__tree_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/restore": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Restore Tree */
|
||||||
|
post: operations["restore_tree_api_v1_trees__tree_id__restore_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -221,6 +239,24 @@ export interface paths {
|
|||||||
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
|
/** Delete Person */
|
||||||
|
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Restore Person */
|
||||||
|
post: operations["restore_person_api_v1_trees__tree_id__persons__person_id__restore_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -234,7 +270,8 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
/** List Tree Events */
|
||||||
|
get: operations["list_tree_events_api_v1_trees__tree_id__events_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Create Event */
|
/** Create Event */
|
||||||
post: operations["create_event_api_v1_trees__tree_id__events_post"];
|
post: operations["create_event_api_v1_trees__tree_id__events_post"];
|
||||||
@@ -285,7 +322,8 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
/** List Relationships */
|
||||||
|
get: operations["list_relationships_api_v1_trees__tree_id__relationships_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Create Relationship */
|
/** Create Relationship */
|
||||||
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
|
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
|
||||||
@@ -1169,7 +1207,9 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
list_my_trees_api_v1_trees_get: {
|
list_my_trees_api_v1_trees_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: {
|
||||||
|
deleted?: boolean;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -1185,6 +1225,15 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["TreeRead"][];
|
"application/json": components["schemas"]["TreeRead"][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
create_tree_api_v1_trees_post: {
|
create_tree_api_v1_trees_post: {
|
||||||
@@ -1251,7 +1300,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
list_persons_api_v1_trees__tree_id__persons_get: {
|
delete_tree_api_v1_trees__tree_id__delete: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -1261,6 +1310,68 @@ export interface operations {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
restore_tree_api_v1_trees__tree_id__restore_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TreeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_persons_api_v1_trees__tree_id__persons_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
deleted?: boolean;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Successful Response */
|
/** @description Successful Response */
|
||||||
200: {
|
200: {
|
||||||
@@ -1349,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: {
|
create_event_api_v1_trees__tree_id__events_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1446,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: {
|
create_relationship_api_v1_trees__tree_id__relationships_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+321
-25
@@ -281,29 +281,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees": {
|
"/api/v1/trees": {
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"trees"
|
|
||||||
],
|
|
||||||
"summary": "List My Trees",
|
|
||||||
"operationId": "list_my_trees_api_v1_trees_get",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/TreeRead"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"title": "Response List My Trees Api V1 Trees Get"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"trees"
|
"trees"
|
||||||
@@ -311,14 +288,14 @@
|
|||||||
"summary": "Create Tree",
|
"summary": "Create Tree",
|
||||||
"operationId": "create_tree_api_v1_trees_post",
|
"operationId": "create_tree_api_v1_trees_post",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/TreeCreate"
|
"$ref": "#/components/schemas/TreeCreate"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"required": true
|
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"201": {
|
"201": {
|
||||||
@@ -342,6 +319,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"trees"
|
||||||
|
],
|
||||||
|
"summary": "List My Trees",
|
||||||
|
"operationId": "list_my_trees_api_v1_trees_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "deleted",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Deleted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TreeRead"
|
||||||
|
},
|
||||||
|
"title": "Response List My Trees Api V1 Trees Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}": {
|
"/api/v1/trees/{tree_id}": {
|
||||||
@@ -385,6 +407,83 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"trees"
|
||||||
|
],
|
||||||
|
"summary": "Delete Tree",
|
||||||
|
"operationId": "delete_tree_api_v1_trees__tree_id__delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/restore": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"trees"
|
||||||
|
],
|
||||||
|
"summary": "Restore Tree",
|
||||||
|
"operationId": "restore_tree_api_v1_trees__tree_id__restore_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TreeRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons": {
|
"/api/v1/trees/{tree_id}/persons": {
|
||||||
@@ -455,6 +554,16 @@
|
|||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Tree Id"
|
"title": "Tree Id"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleted",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Deleted"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -486,6 +595,50 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"persons"
|
||||||
|
],
|
||||||
|
"summary": "Delete Person",
|
||||||
|
"operationId": "delete_person_api_v1_trees__tree_id__persons__person_id__delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "person_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Person Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"persons"
|
"persons"
|
||||||
@@ -538,6 +691,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"persons"
|
||||||
|
],
|
||||||
|
"summary": "Restore Person",
|
||||||
|
"operationId": "restore_person_api_v1_trees__tree_id__persons__person_id__restore_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "person_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Person Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PersonRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/trees/{tree_id}/events": {
|
"/api/v1/trees/{tree_id}/events": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -589,6 +795,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"events"
|
||||||
|
],
|
||||||
|
"summary": "List Tree Events",
|
||||||
|
"operationId": "list_tree_events_api_v1_trees__tree_id__events_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EventRead"
|
||||||
|
},
|
||||||
|
"title": "Response List Tree Events Api V1 Trees Tree Id Events Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}/events": {
|
"/api/v1/trees/{tree_id}/persons/{person_id}/events": {
|
||||||
@@ -745,6 +996,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"relationships"
|
||||||
|
],
|
||||||
|
"summary": "List Relationships",
|
||||||
|
"operationId": "list_relationships_api_v1_trees__tree_id__relationships_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RelationshipRead"
|
||||||
|
},
|
||||||
|
"title": "Response List Relationships Api V1 Trees Tree Id Relationships Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
|
"/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
|
||||||
|
|||||||
Reference in New Issue
Block a user