269cae556f
Two gaps from review of the public surface: - The public tree page showed only a list of names. Add the family-chart hourglass (PublicTreeChart) above the directory — the same renderer the member tree view uses, including the cycle-sanitisation that guards against a bad graph, fed by redacted public data. Click a card to recenter; "Open" links to the person's public page. Centers on the tree's home person. - The homepage had no path to /explore. Add an "Explore" nav link and an "Explore public trees" hero button. tsc clean; next build passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
157 lines
5.6 KiB
TypeScript
157 lines
5.6 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
|
||
import { api } from "@/lib/api/client";
|
||
import type { components } from "@/lib/api/schema";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { PublicTreeChart } from "@/components/public-tree-chart";
|
||
|
||
type Person = components["schemas"]["PersonRead"];
|
||
type Event = components["schemas"]["EventRead"];
|
||
type Relationship = components["schemas"]["RelationshipRead"];
|
||
type Tree = components["schemas"]["PublicTreeRead"];
|
||
|
||
// Public, no-login view of a tree. Everything here is already redacted by the
|
||
// /api/v1/public surface (living people show as "Living person").
|
||
export default function PublicTreePage() {
|
||
const { treeId } = useParams<{ treeId: string }>();
|
||
const router = useRouter();
|
||
const [tree, setTree] = useState<Tree | null>(null);
|
||
const [people, setPeople] = useState<Person[]>([]);
|
||
const [events, setEvents] = useState<Event[]>([]);
|
||
const [rels, setRels] = useState<Relationship[]>([]);
|
||
const [focusId, setFocusId] = useState<string | null>(null);
|
||
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
|
||
const [search, setSearch] = useState("");
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
const t = await api.GET("/api/v1/public/trees/{tree_id}", {
|
||
params: { path: { tree_id: treeId } },
|
||
});
|
||
if (cancelled) return;
|
||
if (!t.data) {
|
||
setStatus("notfound");
|
||
return;
|
||
}
|
||
const [p, e, r] = await Promise.all([
|
||
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||
api.GET("/api/v1/public/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
|
||
params: { path: { tree_id: treeId } },
|
||
}),
|
||
]);
|
||
if (cancelled) return;
|
||
const ppl = p.data ?? [];
|
||
const home = t.data.home_person_id;
|
||
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||
setTree(t.data);
|
||
setPeople(ppl);
|
||
setEvents(e.data ?? []);
|
||
setRels(r.data ?? []);
|
||
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||
setStatus("ready");
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [treeId]);
|
||
|
||
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]);
|
||
|
||
const shown = useMemo(() => {
|
||
const q = search.trim().toLowerCase();
|
||
const sorted = [...people].sort((a, b) =>
|
||
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||
);
|
||
return (q ? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) : sorted).slice(
|
||
0,
|
||
300,
|
||
);
|
||
}, [people, search]);
|
||
|
||
if (status === "loading") return <p className="text-[var(--muted)]">Loading…</p>;
|
||
if (status === "notfound")
|
||
return (
|
||
<div className="space-y-2">
|
||
<h1 className="text-2xl font-semibold">Not available</h1>
|
||
<p className="text-[var(--muted)]">
|
||
This tree isn’t public, or the link is wrong.{" "}
|
||
<Link href="/login" className="text-bronze hover:underline">
|
||
Sign in
|
||
</Link>{" "}
|
||
if it’s yours.
|
||
</p>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="font-serif text-3xl font-semibold">{tree?.name}</h1>
|
||
{tree?.description && <p className="mt-1 text-[var(--muted)]">{tree.description}</p>}
|
||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||
{people.length} {people.length === 1 ? "person" : "people"} · living people are hidden
|
||
</p>
|
||
</div>
|
||
|
||
{focusId && people.length > 0 && (
|
||
<PublicTreeChart
|
||
people={people}
|
||
rels={rels}
|
||
events={events}
|
||
focusId={focusId}
|
||
onFocus={setFocusId}
|
||
onOpen={(id) => router.push(`/p/${treeId}/persons/${id}`)}
|
||
/>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
<h2 className="font-serif text-base font-semibold">All people</h2>
|
||
<Input
|
||
className="w-72"
|
||
placeholder="Search people…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<Card className="overflow-hidden">
|
||
<CardContent className="p-0">
|
||
{shown.length === 0 ? (
|
||
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
|
||
) : (
|
||
shown.map((p, i) => (
|
||
<Link
|
||
key={p.id}
|
||
href={`/p/${treeId}/persons/${p.id}`}
|
||
className={`flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-bronze/[0.05] ${
|
||
i > 0 ? "border-t border-[var(--border)]" : ""
|
||
}`}
|
||
>
|
||
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
|
||
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
|
||
</Link>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|