5 Commits

Author SHA1 Message Date
justin 99a660485e Pedigree: connector lines + correct 4-grandparent structure
Rebuilds the family view's pedigree as a recursive bracket chart with CSS connector lines — focus links to its two parents (2 lines), and each parent links to its two parents (4 lines to grandparents). Fixes the prior ambiguity where grandparent slots weren't tied to a specific parent: now every parent shows its own two parent slots, so a person clearly has up to four grandparents grouped by lineage. Height-robust connectors (each leaf draws its own spine half + stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:32:10 -04:00
justin cf6dcf9ce2 Merge pull request 'Family view + soft-delete/recovery' (#10) from phase1-familyview into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m18s
2026-06-06 22:19:02 -04:00
justin 22bc536978 Rebuild People as a family view (pedigree + family group); add recovery UI
The People page is no longer a flat list: it's a focus-person family view with a pedigree of ancestors (parents + grandparents), a spouse/partner panel, and a children panel — with inline 'add parent/child/spouse' (creates the person + the relationship), click-to-refocus, birth–death years, and a searchable people index. Modeled on how real genealogy tools center on a person and let you walk the graph.

Adds delete/restore UI: a Delete on the person page, per-tree delete + a 'Recently deleted' restore section on the trees list, and a Recovery page (sidebar) for deleted people.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin f2205b93f4 Add soft-delete + recovery and tree-wide graph endpoints
Tree and person soft-delete + restore (owner-only for trees, editor for people) with recovery listings (?deleted=true); the worker already purges past the 30-day window. Adds tree-wide GET /relationships and /events so the family/pedigree view loads the whole graph in a few calls. 27 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin b0c7c8570b Merge pull request 'App-shell UI overhaul + media stream endpoint' (#9) from ui-shell into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m20s
2026-06-06 21:56:26 -04:00
17 changed files with 1341 additions and 110 deletions
+9
View File
@@ -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
+25 -1
View File
@@ -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)
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) 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
+9
View File
@@ -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],
+17 -1
View File
@@ -22,7 +22,12 @@ 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(
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) 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)
+14
View File
@@ -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]:
+72
View File
@@ -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]:
+53
View File
@@ -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())
+54
View File
@@ -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
+52
View File
@@ -54,3 +54,55 @@ h3,
::selection { ::selection {
background: color-mix(in srgb, var(--color-bronze) 22%, transparent); 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%;
}
+303 -44
View File
@@ -2,35 +2,62 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
export default function TreeDetailPage() { function splitName(full: string): { given: string | null; surname: string | null } {
const t = full.trim().split(/\s+/).filter(Boolean);
if (t.length === 0) return { given: null, surname: null };
if (t.length === 1) return { given: t[0], surname: null };
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
}
type AddKind = "parent" | "child" | "partner";
export default function FamilyViewPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const treeId = params.id; const treeId = params.id;
const [persons, setPersons] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [given, setGiven] = useState(""); const [rels, setRels] = useState<Relationship[]>([]);
const [surname, setSurname] = useState(""); const [events, setEvents] = useState<Event[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name.
// `key` keeps each empty slot's inline form independent (a person has 2
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
const [addName, setAddName] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
}); });
if (response.status === 401) { if (p.response.status === 401) {
router.push("/login"); router.push("/login");
return; return;
} }
setPersons(data ?? []); const [r, e] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
]);
const ppl = p.data ?? [];
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
setReady(true); setReady(true);
}, [router, treeId]); }, [router, treeId]);
@@ -38,60 +65,292 @@ 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) =>
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 } }, params: { path: { tree_id: treeId } },
body: { given: given || null, surname: surname || null }, body: { given, surname },
}); });
if (!error) { return data?.id ?? null;
setGiven(""); }
setSurname("");
async function createFirst(e: React.FormEvent) {
e.preventDefault();
if (!firstName.trim()) return;
const id = await addPerson(firstName);
setFirstName("");
if (id) setFocusId(id);
load(); load();
} }
async function submitAdd(e: React.FormEvent) {
e.preventDefault();
if (!adding || !addName.trim()) return;
const newId = await addPerson(addName);
if (newId) {
const { kind, anchor } = adding;
const body =
kind === "parent"
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
: kind === "child"
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
setAdding(null);
setAddName("");
load();
} }
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (people.length === 0) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-semibold">People</h1> <h1 className="text-2xl font-semibold">Start your tree</h1>
<Card> <Card>
<CardHeader> <CardContent className="p-6">
<CardTitle className="text-base">Add a person</CardTitle> <form onSubmit={createFirst} className="flex flex-wrap gap-2">
</CardHeader> <Input
<CardContent> className="w-64"
<form onSubmit={addPerson} className="flex gap-2"> placeholder="First person's full name"
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} /> value={firstName}
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} /> onChange={(e) => setFirstName(e.target.value)}
<Button type="submit">Add</Button> />
<Button type="submit">Add person</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div>
);
}
<div> const focus = focusId ? byId.get(focusId) : undefined;
<h2 className="mb-2 text-lg font-semibold">People</h2> if (!focus) {
{persons.length === 0 ? ( setFocusId(people[0].id);
<p className="text-[var(--muted)]">No people yet.</p> 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>
) : ( ) : (
<ul className="space-y-2"> <button
{persons.map((person) => ( onClick={() => {
<li key={person.id}> setAdding({ key: formKey, kind, anchor });
<Link href={`/trees/${treeId}/persons/${person.id}`}> setAddName("");
<Card className="transition-colors hover:border-bronze/50"> }}
<CardContent className="p-4"> 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"
{person.primary_name ?? ( >
<span className="text-[var(--muted)]">Unnamed</span> + {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-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>
<CardContent className="overflow-x-auto p-6">
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
</CardContent> </CardContent>
</Card> </Card>
</Link>
</li> {/* 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 &amp; partners</h2>
<div className="flex flex-wrap gap-3">
{partners.map((id) => (
<PersonBox key={id} id={id} muted />
))} ))}
</ul> <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>
</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>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)} {citeControl("p", { person_id: personId }, personCites)}
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
</div>
</div> </div>
<Card> <Card>
+72
View File
@@ -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>
);
}
+52 -18
View File
@@ -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">
<span className="font-medium">{tree.name}</span> <Link href={`/trees/${tree.id}`} className="min-w-0 flex-1">
<span className="text-xs uppercase tracking-wide text-bronze"> <div className="truncate font-medium">{tree.name}</div>
<div className="text-xs uppercase tracking-wide text-bronze">
{tree.visibility} {tree.visibility}
</span> </div>
</Link>
<button
onClick={() => remove(tree.id)}
className="ml-3 text-[var(--muted)] hover:text-bronze"
aria-label="Delete tree"
>
×
</button>
</CardContent> </CardContent>
</Card> </Card>
</Link>
</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>
); );
} }
+7 -1
View File
@@ -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>
)} )}
+239 -4
View File
@@ -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
View File
@@ -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": {