From b8405ced07e246552c99b441f687a6421fc626b7 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Tue, 9 Jun 2026 09:31:56 -0400 Subject: [PATCH] Visibility phase 4: no-login public viewer pages + robots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Paul --- frontend/app/p/[treeId]/page.tsx | 129 ++++++++++++++ .../p/[treeId]/persons/[personId]/page.tsx | 163 ++++++++++++++++++ frontend/app/p/layout.tsx | 21 +++ frontend/app/robots.ts | 15 ++ frontend/app/trees/page.tsx | 11 ++ 5 files changed, 339 insertions(+) create mode 100644 frontend/app/p/[treeId]/page.tsx create mode 100644 frontend/app/p/[treeId]/persons/[personId]/page.tsx create mode 100644 frontend/app/p/layout.tsx create mode 100644 frontend/app/robots.ts diff --git a/frontend/app/p/[treeId]/page.tsx b/frontend/app/p/[treeId]/page.tsx new file mode 100644 index 0000000..75c6dea --- /dev/null +++ b/frontend/app/p/[treeId]/page.tsx @@ -0,0 +1,129 @@ +"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(null); + const [people, setPeople] = useState([]); + const [events, setEvents] = useState([]); + 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(); + 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

Loading…

; + if (status === "notfound") + return ( +
+

Not available

+

+ This tree isn’t public, or the link is wrong.{" "} + + Sign in + {" "} + if it’s yours. +

+
+ ); + + return ( +
+
+

{tree?.name}

+ {tree?.description &&

{tree.description}

} +

+ {people.length} {people.length === 1 ? "person" : "people"} · living people are hidden +

+
+ + setSearch(e.target.value)} + /> + + + + {shown.length === 0 ? ( +
No matches.
+ ) : ( + shown.map((p, i) => ( + 0 ? "border-t border-[var(--border)]" : "" + }`} + > + {p.primary_name ?? "Unnamed"} + {years.get(p.id) ?? ""} + + )) + )} +
+
+
+ ); +} diff --git a/frontend/app/p/[treeId]/persons/[personId]/page.tsx b/frontend/app/p/[treeId]/persons/[personId]/page.tsx new file mode 100644 index 0000000..d5d9856 --- /dev/null +++ b/frontend/app/p/[treeId]/persons/[personId]/page.tsx @@ -0,0 +1,163 @@ +"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"; + +type Person = components["schemas"]["PersonRead"]; +type Name = components["schemas"]["NameRead"]; +type Event = components["schemas"]["EventRead"]; +type Relationship = components["schemas"]["RelationshipRead"]; + +// Public, no-login person view. The /api/v1/public surface returns only what an +// anonymous viewer may see: deceased people in full, living people redacted. +export default function PublicPersonPage() { + const { treeId, personId } = useParams<{ treeId: string; personId: string }>(); + const [person, setPerson] = useState(null); + const [names, setNames] = useState([]); + const [events, setEvents] = useState([]); + const [people, setPeople] = useState([]); + const [rels, setRels] = useState([]); + const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading"); + + useEffect(() => { + let cancelled = false; + (async () => { + const pr = await api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}", { + params: { path: { tree_id: treeId, person_id: personId } }, + }); + if (cancelled) return; + if (!pr.data) { + setStatus("notfound"); + return; + } + const [nm, ev, ppl, rl] = await Promise.all([ + api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/names", { + params: { path: { tree_id: treeId, person_id: personId } }, + }), + api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/events", { + params: { path: { tree_id: treeId, person_id: personId } }, + }), + api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }), + api.GET("/api/v1/public/trees/{tree_id}/relationships", { + params: { path: { tree_id: treeId } }, + }), + ]); + if (cancelled) return; + setPerson(pr.data); + setNames(nm.data ?? []); + setEvents(ev.data ?? []); + setPeople(ppl.data ?? []); + setRels(rl.data ?? []); + setStatus("ready"); + })(); + return () => { + cancelled = true; + }; + }, [treeId, personId]); + + const nameOf = useMemo( + () => (id: string) => people.find((p) => p.id === id)?.primary_name ?? "Unknown", + [people], + ); + + const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId); + const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId); + const partners = rels.filter( + (r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId), + ); + const otherEnd = (r: Relationship) => + r.person_from_id === personId ? r.person_to_id : r.person_from_id; + + if (status === "loading") return

Loading…

; + if (status === "notfound") + return ( +
+

Not available

+

This person isn’t publicly visible.

+ + ← Back to the tree + +
+ ); + + const relGroup = (label: string, items: Relationship[]) => + items.length > 0 && ( +
+

{label}

+
    + {items.map((r) => ( +
  • + + {nameOf(otherEnd(r))} + {r.qualifier ? · {r.qualifier} : null} + +
  • + ))} +
+
+ ); + + return ( +
+ + ← Back to the tree + + +

{person?.primary_name ?? "Unnamed"}

+ +
+ {events.length > 0 && ( + + +

Events

+
    + {events.map((e) => ( +
  • + {e.event_type} + + {e.date_value ?? e.date_start ?? ""} + +
  • + ))} +
+
+
+ )} + + {names.length > 1 && ( + + +

Names

+
    + {names.map((n) => ( +
  • + {[n.given, n.surname].filter(Boolean).join(" ") || "—"} + {n.name_type} +
  • + ))} +
+
+
+ )} +
+ + {(parents.length > 0 || children.length > 0 || partners.length > 0) && ( + + + {relGroup("Parents", parents)} + {relGroup("Partners", partners)} + {relGroup("Children", children)} + + + )} +
+ ); +} diff --git a/frontend/app/p/layout.tsx b/frontend/app/p/layout.tsx new file mode 100644 index 0000000..571d26b --- /dev/null +++ b/frontend/app/p/layout.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; + +// Public viewing surface — no auth, no app sidebar. A slim header only. +export default function PublicLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + Provenance + + + Sign in + +
+
+
{children}
+
+ ); +} diff --git a/frontend/app/robots.ts b/frontend/app/robots.ts new file mode 100644 index 0000000..de5bc36 --- /dev/null +++ b/frontend/app/robots.ts @@ -0,0 +1,15 @@ +import type { MetadataRoute } from "next"; + +// Allow crawlers on the public surface; keep the authenticated app out of the +// index. (Per-tree noindex for `unlisted`/`site_members` pages needs server +// rendering — tracked as a follow-up; those trees aren't linked or listed, so +// they aren't discoverable by crawl in the meantime.) +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: ["/", "/p/"], + disallow: ["/trees", "/settings", "/import", "/login", "/register"], + }, + }; +} diff --git a/frontend/app/trees/page.tsx b/frontend/app/trees/page.tsx index a68ad0a..fc37318 100644 --- a/frontend/app/trees/page.tsx +++ b/frontend/app/trees/page.tsx @@ -109,6 +109,17 @@ export default function TreesPage() { + {tree.visibility && tree.visibility !== "private" && ( + + Public page ↗ + + )}