Files
provenance/frontend/app/p/[treeId]/page.tsx
T
justin b8405ced07 Visibility phase 4: no-login public viewer pages + robots
Adds the public viewing surface in the UI — shareable, no-login pages backed by
the redaction-safe /api/v1/public API:

- /p/[treeId]: tree name + searchable people directory (living people show as
  "Living person"; counts; links to person pages).
- /p/[treeId]/persons/[personId]: person detail — events, alternate names, and
  parents/partners/children as links to other public person pages.
- app/p/layout.tsx: slim public header (logo + Sign in), no app sidebar.
- robots.ts: allow /p/, disallow the authenticated app sections.
- Trees list: a "Public page ↗" link on every non-private tree so the owner can
  grab the shareable URL.

Client-rendered (same-origin fetch via Caddy). Follow-up (needs a frontend
SSR→backend base URL + a compose/env deploy step, so not auto-applied by
Watchtower): true server-rendering for SEO, a dynamic sitemap of public trees,
and per-page noindex for unlisted/site_members.

tsc clean; next build passes (both routes dynamic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:31:56 -04:00

130 lines
4.6 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 } 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";
type Person = components["schemas"]["PersonRead"];
type Event = components["schemas"]["EventRead"];
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 [tree, setTree] = useState<Tree | null>(null);
const [people, setPeople] = useState<Person[]>([]);
const [events, setEvents] = useState<Event[]>([]);
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] = 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 } } }),
]);
if (cancelled) return;
setTree(t.data);
setPeople(p.data ?? []);
setEvents(e.data ?? []);
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>
<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>
<Input
className="w-72"
placeholder="Search people…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<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>
);
}