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>
This commit is contained in:
2026-06-06 22:19:01 -04:00
parent f2205b93f4
commit 22bc536978
7 changed files with 1002 additions and 106 deletions
+284 -43
View File
@@ -2,35 +2,60 @@
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
export default function TreeDetailPage() {
function splitName(full: string): { given: string | null; surname: string | null } {
const t = full.trim().split(/\s+/).filter(Boolean);
if (t.length === 0) return { given: null, surname: null };
if (t.length === 1) return { given: t[0], surname: null };
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
}
type AddKind = "parent" | "child" | "partner";
export default function FamilyViewPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [persons, setPersons] = useState<Person[]>([]);
const [given, setGiven] = useState("");
const [surname, setSurname] = useState("");
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [ready, setReady] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name.
const [adding, setAdding] = useState<{ kind: AddKind; anchor: string } | null>(null);
const [addName, setAddName] = useState("");
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
if (response.status === 401) {
if (p.response.status === 401) {
router.push("/login");
return;
}
setPersons(data ?? []);
const [r, e] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
]);
const ppl = p.data ?? [];
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
setReady(true);
}, [router, treeId]);
@@ -38,60 +63,276 @@ export default function TreeDetailPage() {
load();
}, [load]);
async function addPerson(e: React.FormEvent) {
e.preventDefault();
if (!given.trim() && !surname.trim()) return;
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", {
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
const parentsOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
const childrenOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
const partnersOf = (id: string) =>
rels
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
const years = useMemo(() => {
const m = new Map<string, string>();
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
for (const p of people) {
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}${parts[1]}`.replace(/^$/, ""));
}
return m;
}, [people, events]);
async function addPerson(name: string): Promise<string | null> {
const { given, surname } = splitName(name);
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
body: { given: given || null, surname: surname || null },
body: { given, surname },
});
if (!error) {
setGiven("");
setSurname("");
return data?.id ?? null;
}
async function createFirst(e: React.FormEvent) {
e.preventDefault();
if (!firstName.trim()) return;
const id = await addPerson(firstName);
setFirstName("");
if (id) setFocusId(id);
load();
}
async function submitAdd(e: React.FormEvent) {
e.preventDefault();
if (!adding || !addName.trim()) return;
const newId = await addPerson(addName);
if (newId) {
const { kind, anchor } = adding;
const body =
kind === "parent"
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
: kind === "child"
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
setAdding(null);
setAddName("");
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (people.length === 0) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">People</h1>
<h1 className="text-2xl font-semibold">Start your tree</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Add a person</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={addPerson} className="flex gap-2">
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
<Button type="submit">Add</Button>
<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>
);
}
<div>
<h2 className="mb-2 text-lg font-semibold">People</h2>
{persons.length === 0 ? (
<p className="text-[var(--muted)]">No people yet.</p>
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>
) : (
<ul className="space-y-2">
{persons.map((person) => (
<li key={person.id}>
<Link href={`/trees/${treeId}/persons/${person.id}`}>
<Card className="transition-colors hover:border-bronze/50">
<CardContent className="p-4">
{person.primary_name ?? (
<span className="text-[var(--muted)]">Unnamed</span>
<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>
<CardContent className="overflow-x-auto p-6">
<div className="flex min-w-[40rem] items-stretch gap-8">
<div className="flex flex-1 flex-col justify-center gap-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Focus
</div>
<PersonBox id={focus.id} />
</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)]">
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>
</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 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 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>
);
@@ -206,6 +206,13 @@ export default function PersonDetailPage() {
load();
}
async function removePerson() {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
});
router.push(`/trees/${treeId}`);
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
@@ -311,7 +318,12 @@ export default function PersonDetailPage() {
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
</div>
</div>
<Card>
+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 type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Tree = components["schemas"]["TreeRead"];
@@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"];
export default function TreesPage() {
const router = useRouter();
const [trees, setTrees] = useState<Tree[]>([]);
const [deleted, setDeleted] = useState<Tree[]>([]);
const [name, setName] = useState("");
const [ready, setReady] = useState(false);
@@ -25,6 +26,8 @@ export default function TreesPage() {
return;
}
setTrees(data ?? []);
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
setDeleted(del.data ?? []);
setReady(true);
}, [router]);
@@ -42,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>;
return (
<div className="space-y-6">
<div className="space-y-8">
<h1 className="text-2xl font-semibold">Your trees</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">New tree</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="p-5">
<form onSubmit={createTree} className="flex gap-2">
<Input
placeholder="Family name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit">Create</Button>
<Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
<Button type="submit">Create tree</Button>
</form>
</CardContent>
</Card>
@@ -67,23 +72,52 @@ export default function TreesPage() {
{trees.length === 0 ? (
<p className="text-[var(--muted)]">No trees yet create your first one above.</p>
) : (
<ul className="space-y-2">
<ul className="grid gap-3 sm:grid-cols-2">
{trees.map((tree) => (
<li key={tree.id}>
<Link href={`/trees/${tree.id}`}>
<Card className="transition-colors hover:border-bronze/50">
<CardContent className="flex items-center justify-between p-4">
<span className="font-medium">{tree.name}</span>
<span className="text-xs uppercase tracking-wide text-bronze">
<Link href={`/trees/${tree.id}`} className="min-w-0 flex-1">
<div className="truncate font-medium">{tree.name}</div>
<div className="text-xs uppercase tracking-wide text-bronze">
{tree.visibility}
</span>
</div>
</Link>
<button
onClick={() => remove(tree.id)}
className="ml-3 text-[var(--muted)] hover:text-bronze"
aria-label="Delete tree"
>
×
</button>
</CardContent>
</Card>
</Link>
</li>
))}
</ul>
)}
{deleted.length > 0 && (
<div className="space-y-3">
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
Recently deleted
</h2>
<ul className="space-y-2">
{deleted.map((tree) => (
<li key={tree.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{tree.name}</span>
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,6 +1,6 @@
"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 { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -87,6 +87,12 @@ export function AppSidebar() {
icon={ImageIcon}
active={pathname.startsWith(`/trees/${treeId}/media`)}
/>
<Item
href={`/trees/${treeId}/recovery`}
label="Recovery"
icon={Archive}
active={pathname.startsWith(`/trees/${treeId}/recovery`)}
/>
</div>
)}
+239 -4
View File
@@ -186,6 +186,24 @@ export interface paths {
get: operations["get_tree_api_v1_trees__tree_id__get"];
put?: never;
post?: never;
/** Delete Tree */
delete: operations["delete_tree_api_v1_trees__tree_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Restore Tree */
post: operations["restore_tree_api_v1_trees__tree_id__restore_post"];
delete?: never;
options?: never;
head?: never;
@@ -221,6 +239,24 @@ export interface paths {
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
put?: never;
post?: never;
/** Delete Person */
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Restore Person */
post: operations["restore_person_api_v1_trees__tree_id__persons__person_id__restore_post"];
delete?: never;
options?: never;
head?: never;
@@ -234,7 +270,8 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/** List Tree Events */
get: operations["list_tree_events_api_v1_trees__tree_id__events_get"];
put?: never;
/** Create Event */
post: operations["create_event_api_v1_trees__tree_id__events_post"];
@@ -285,7 +322,8 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/** List Relationships */
get: operations["list_relationships_api_v1_trees__tree_id__relationships_get"];
put?: never;
/** Create Relationship */
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
@@ -1169,7 +1207,9 @@ export interface operations {
};
list_my_trees_api_v1_trees_get: {
parameters: {
query?: never;
query?: {
deleted?: boolean;
};
header?: never;
path?: never;
cookie?: never;
@@ -1185,6 +1225,15 @@ export interface operations {
"application/json": components["schemas"]["TreeRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_tree_api_v1_trees_post: {
@@ -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: {
query?: never;
header?: never;
@@ -1261,6 +1310,68 @@ export interface operations {
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_tree_api_v1_trees__tree_id__restore_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TreeRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_persons_api_v1_trees__tree_id__persons_get: {
parameters: {
query?: {
deleted?: boolean;
};
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
@@ -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: {
parameters: {
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: {
parameters: {
query?: never;
+321 -25
View File
@@ -281,29 +281,6 @@
}
},
"/api/v1/trees": {
"get": {
"tags": [
"trees"
],
"summary": "List My Trees",
"operationId": "list_my_trees_api_v1_trees_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/TreeRead"
},
"type": "array",
"title": "Response List My Trees Api V1 Trees Get"
}
}
}
}
}
},
"post": {
"tags": [
"trees"
@@ -311,14 +288,14 @@
"summary": "Create Tree",
"operationId": "create_tree_api_v1_trees_post",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TreeCreate"
}
}
},
"required": true
}
},
"responses": {
"201": {
@@ -342,6 +319,51 @@
}
}
}
},
"get": {
"tags": [
"trees"
],
"summary": "List My Trees",
"operationId": "list_my_trees_api_v1_trees_get",
"parameters": [
{
"name": "deleted",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Deleted"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TreeRead"
},
"title": "Response List My Trees Api V1 Trees Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}": {
@@ -385,6 +407,83 @@
}
}
}
},
"delete": {
"tags": [
"trees"
],
"summary": "Delete Tree",
"operationId": "delete_tree_api_v1_trees__tree_id__delete",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/restore": {
"post": {
"tags": [
"trees"
],
"summary": "Restore Tree",
"operationId": "restore_tree_api_v1_trees__tree_id__restore_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TreeRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/persons": {
@@ -455,6 +554,16 @@
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "deleted",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Deleted"
}
}
],
"responses": {
@@ -486,6 +595,50 @@
}
},
"/api/v1/trees/{tree_id}/persons/{person_id}": {
"delete": {
"tags": [
"persons"
],
"summary": "Delete Person",
"operationId": "delete_person_api_v1_trees__tree_id__persons__person_id__delete",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "person_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Person Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"get": {
"tags": [
"persons"
@@ -538,6 +691,59 @@
}
}
},
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
"post": {
"tags": [
"persons"
],
"summary": "Restore Person",
"operationId": "restore_person_api_v1_trees__tree_id__persons__person_id__restore_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "person_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Person Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/events": {
"post": {
"tags": [
@@ -589,6 +795,51 @@
}
}
}
},
"get": {
"tags": [
"events"
],
"summary": "List Tree Events",
"operationId": "list_tree_events_api_v1_trees__tree_id__events_get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventRead"
},
"title": "Response List Tree Events Api V1 Trees Tree Id Events Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/persons/{person_id}/events": {
@@ -745,6 +996,51 @@
}
}
}
},
"get": {
"tags": [
"relationships"
],
"summary": "List Relationships",
"operationId": "list_relationships_api_v1_trees__tree_id__relationships_get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RelationshipRead"
},
"title": "Response List Relationships Api V1 Trees Tree Id Relationships Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {