Files
provenance/frontend/app/p/[treeId]/page.tsx
T
justin 150d69e5ac Public tree view: full-width canvas like the member view
The public layout forced max-w-5xl on every /p page, so the tree chart was
cramped. Mirror the member shell: the public layout now drops the max-width for
the tree page (/p/<id>) only, giving the chart the full canvas (74vh to match
the member view), while the page keeps its heading and people list in a
centered max-w-5xl column. Person detail (/p/<id>/persons/<pid>) and /explore
stay narrow.

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>
2026-06-09 10:29:18 -04:00

158 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 isnt public, or the link is wrong.{" "}
<Link href="/login" className="text-bronze hover:underline">
Sign in
</Link>{" "}
if its yours.
</p>
</div>
);
return (
<div className="space-y-6">
<div className="mx-auto w-full max-w-5xl">
<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>
{/* Chart spans the full canvas (the layout removes max-width for /p/<id>). */}
{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="mx-auto w-full max-w-5xl 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="mx-auto w-full max-w-5xl 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>
);
}