Family view + soft-delete/recovery #10
@@ -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", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { given: given || null, surname: surname || null },
|
||||
});
|
||||
if (!error) {
|
||||
setGiven("");
|
||||
setSurname("");
|
||||
load();
|
||||
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||
const parentsOf = (id: string) =>
|
||||
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
|
||||
const childrenOf = (id: string) =>
|
||||
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||||
const partnersOf = (id: string) =>
|
||||
rels
|
||||
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
|
||||
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
|
||||
|
||||
const years = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
|
||||
for (const p of people) {
|
||||
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
|
||||
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
|
||||
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
|
||||
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, ""));
|
||||
}
|
||||
return m;
|
||||
}, [people, events]);
|
||||
|
||||
async function addPerson(name: string): Promise<string | null> {
|
||||
const { given, surname } = splitName(name);
|
||||
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { given, surname },
|
||||
});
|
||||
return data?.id ?? null;
|
||||
}
|
||||
|
||||
async function createFirst(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!firstName.trim()) return;
|
||||
const id = await addPerson(firstName);
|
||||
setFirstName("");
|
||||
if (id) setFocusId(id);
|
||||
load();
|
||||
}
|
||||
|
||||
async function submitAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!adding || !addName.trim()) return;
|
||||
const newId = await addPerson(addName);
|
||||
if (newId) {
|
||||
const { kind, anchor } = adding;
|
||||
const body =
|
||||
kind === "parent"
|
||||
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
|
||||
: kind === "child"
|
||||
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
|
||||
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
|
||||
await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body,
|
||||
});
|
||||
}
|
||||
setAdding(null);
|
||||
setAddName("");
|
||||
load();
|
||||
}
|
||||
|
||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">People</h1>
|
||||
if (people.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Start your tree</h1>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={createFirst} className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
className="w-64"
|
||||
placeholder="First person's full name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
<Button type="submit">Add person</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const focus = focusId ? byId.get(focusId) : undefined;
|
||||
if (!focus) {
|
||||
setFocusId(people[0].id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const PersonBox = ({
|
||||
id,
|
||||
muted,
|
||||
}: {
|
||||
id: string;
|
||||
muted?: boolean;
|
||||
}) => {
|
||||
const p = byId.get(id);
|
||||
if (!p) return null;
|
||||
const isFocus = id === focusId;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setFocusId(id)}
|
||||
className={`w-44 rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||
isFocus
|
||||
? "border-bronze bg-bronze/[0.08]"
|
||||
: "border-[var(--border)] bg-[var(--surface)] hover:border-bronze/60"
|
||||
} ${muted ? "opacity-90" : ""}`}
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{p.primary_name ?? "Unnamed"}</div>
|
||||
<div className="text-xs text-[var(--muted)]">{years.get(id) ?? "—"}</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const AddSlot = ({ 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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Add a person</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={addPerson} className="flex gap-2">
|
||||
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
|
||||
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
|
||||
<Button type="submit">Add</Button>
|
||||
</form>
|
||||
<CardContent className="overflow-x-auto p-6">
|
||||
<div className="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>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
||||
{persons.length === 0 ? (
|
||||
<p className="text-[var(--muted)]">No people yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{persons.map((person) => (
|
||||
<li key={person.id}>
|
||||
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
||||
<Card className="transition-colors hover:border-bronze/50">
|
||||
<CardContent className="p-4">
|
||||
{person.primary_name ?? (
|
||||
<span className="text-[var(--muted)]">Unnamed</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Family group: partners + children of the focus */}
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<h2 className="font-serif text-base font-semibold">Spouses & partners</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{partners.map((id) => (
|
||||
<PersonBox key={id} id={id} muted />
|
||||
))}
|
||||
<AddSlot 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>
|
||||
{citeControl("p", { person_id: personId }, personCites)}
|
||||
<div className="flex items-center gap-3">
|
||||
{citeControl("p", { person_id: personId }, personCites)}
|
||||
<Button variant="ghost" size="sm" onClick={removePerson}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -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 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">
|
||||
<Card className="transition-colors hover:border-bronze/50">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<Link href={`/trees/${tree.id}`} className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{tree.name}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-bronze">
|
||||
{tree.visibility}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => remove(tree.id)}
|
||||
className="ml-3 text-[var(--muted)] hover:text-bronze"
|
||||
aria-label="Delete tree"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{deleted.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
|
||||
Recently deleted
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{deleted.map((tree) => (
|
||||
<li key={tree.id}>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<span className="text-[var(--muted)]">{tree.name}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
|
||||
Restore
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Vendored
+239
-4
@@ -186,6 +186,24 @@ export interface paths {
|
||||
get: operations["get_tree_api_v1_trees__tree_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete Tree */
|
||||
delete: operations["delete_tree_api_v1_trees__tree_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/restore": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Restore Tree */
|
||||
post: operations["restore_tree_api_v1_trees__tree_id__restore_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -221,6 +239,24 @@ export interface paths {
|
||||
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete Person */
|
||||
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Restore Person */
|
||||
post: operations["restore_person_api_v1_trees__tree_id__persons__person_id__restore_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -234,7 +270,8 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** List Tree Events */
|
||||
get: operations["list_tree_events_api_v1_trees__tree_id__events_get"];
|
||||
put?: never;
|
||||
/** Create Event */
|
||||
post: operations["create_event_api_v1_trees__tree_id__events_post"];
|
||||
@@ -285,7 +322,8 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** List Relationships */
|
||||
get: operations["list_relationships_api_v1_trees__tree_id__relationships_get"];
|
||||
put?: never;
|
||||
/** Create Relationship */
|
||||
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user