11 Commits

Author SHA1 Message Date
justin 05d2773e25 Cleanup: infer a missing sex from a known-sex spouse (preview → approve)
Unset sex renders blue (male-colored), which is misleading next to a confirmed
male partner. Add a Cleanup action that proposes the opposite sex for an unset
partner of someone whose sex is set (couples are opposite-sex in practice — a
confirmed-male husband ⇒ a female wife). People whose known partners disagree
are skipped as ambiguous.

It's a preview the user reviews and approves in the Cleanup tool (reusing the
existing gender apply path + audit) — not an autonomous write. Backend:
guess_gender_by_spouse + GET /cleanup/gender/from-spouse. Frontend: an "Infer
from spouse" button feeding the existing proposal list. Test covers
propose-opposite, skip-no-partner, skip-already-set, apply, and re-preview.

Full suite 73 passed; frontend build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 10:59:08 -04:00
justin 768c68cbe0 Merge pull request 'Public tree view: add generation depth controls (shared with member view)' (#51) from public-tree-depth-controls into main
build-frontend / build (push) Successful in 1m29s
2026-06-09 10:35:44 -04:00
justin 7d6fbce87e Public tree view: add generation depth controls (shared with member view)
The public tree chart was fixed at 3 ancestors / 2 descendants. Add the same
Generations controls the member view has (slider + number stepper + "All" per
direction), applied live around the focused person.

Extracts the member page's inline DepthControl into a shared
components/depth-control.tsx and uses it in both, so they stay in sync. The
public chart gains anc/prog depth state + an apply effect (setAncestryDepth/
setProgenyDepth + updateTree) mirroring the member behavior.

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:35:43 -04:00
justin 12ba0a0fb6 Merge pull request 'Public tree view: full-width canvas like the member view' (#50) from public-tree-fullwidth into main
build-frontend / build (push) Successful in 1m29s
2026-06-09 10:29:20 -04:00
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
justin 053ce357ac Merge pull request 'Public view: add tree chart + homepage Explore links' (#49) from public-tree-chart-and-explore-link into main
build-frontend / build (push) Successful in 1m25s
2026-06-09 09:44:24 -04:00
justin 269cae556f Public view: add tree chart + homepage Explore links
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>
2026-06-09 09:44:23 -04:00
justin 0df44e7e59 Merge pull request 'Visibility phase 5: public /explore directory + search' (#48) from visibility-phase5-explore into main
build-frontend / build (push) Successful in 1m28s
2026-06-09 09:34:21 -04:00
justin 7a5c5f2882 Visibility phase 5: public /explore directory + search
A no-login directory of shared trees, backed by GET /api/v1/public/trees:
- /explore: searchable grid of public trees; debounced name search. Because the
  backend adds `site_members` trees when a valid session is present, signed-in
  users see more with no client-side branching.
- PublicHeader extracted and shared by /p and /explore (logo, Explore, Sign in).
- "Explore" entry added to the authed sidebar.

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 09:34:20 -04:00
justin 20c7fbd8d6 Merge pull request 'Visibility phase 4: no-login public viewer pages + robots' (#47) from visibility-phase4-public-pages into main
build-frontend / build (push) Successful in 1m28s
2026-06-09 09:31:58 -04:00
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
19 changed files with 987 additions and 63 deletions
+10
View File
@@ -67,6 +67,16 @@ async def guess_gender(
return [GenderProposal(**r) for r in rows] return [GenderProposal(**r) for r in rows]
@router.get("/{tree_id}/cleanup/gender/from-spouse", response_model=list[GenderProposal])
async def guess_gender_from_spouse(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[GenderProposal]:
"""Infer a missing sex from a partner whose sex is set (opposite-sex couple)."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.guess_gender_by_spouse(session, actor=current, tree=tree)
return [GenderProposal(**r) for r in rows]
@router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult) @router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
async def apply_gender( async def apply_gender(
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
+48
View File
@@ -12,8 +12,10 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import RelationshipType
from app.models.event import Event from app.models.event import Event
from app.models.person import Name, Person from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.tree import Tree from app.models.tree import Tree
from app.models.user import User from app.models.user import User
from app.services import gedcom, privacy from app.services import gedcom, privacy
@@ -182,6 +184,52 @@ async def guess_gender_by_name(
return out return out
async def guess_gender_by_spouse(
session: AsyncSession, *, actor: User, tree: Tree
) -> list[dict]:
"""Infer the sex of a person who has none set from a partner whose sex IS set
(couples in a tree are opposite-sex in practice — e.g. a confirmed-male
husband implies a female wife). People whose known partners disagree are
ambiguous and skipped; the result is a preview to review, not an auto-write."""
await _require_editor(session, actor=actor, tree=tree)
persons = await _persons(session, tree.id)
gender = {p.id: p.gender for p in persons}
names = await _primary_name_by_person(session, tree.id)
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.partnership,
)
)
).scalars().all()
opp = {"male": "female", "female": "male"}
proposals: dict[uuid.UUID, set[str]] = {}
for r in rels:
for me_id, other_id in (
(r.person_from_id, r.person_to_id),
(r.person_to_id, r.person_from_id),
):
if gender.get(me_id):
continue # this person already has a sex
other_sex = str(gender.get(other_id) or "")
if other_sex in opp:
proposals.setdefault(me_id, set()).add(opp[other_sex])
out: list[dict] = []
for pid, sexes in proposals.items():
if len(sexes) != 1:
continue # partners of differing known sex → ambiguous
nm = names.get(pid)
if nm is None:
continue
out.append(
{"person_id": str(pid), "name": _display(nm), "proposed_gender": next(iter(sexes))}
)
out.sort(key=lambda r: r["name"])
return out
async def apply_gender( async def apply_gender(
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict] session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
) -> int: ) -> int:
+38
View File
@@ -51,6 +51,44 @@ async def test_deceased_preview_and_apply(client):
assert old not in [r["person_id"] for r in prev2] assert old not in [r["person_id"] for r in prev2]
async def test_gender_from_spouse_preview_and_apply(client):
h, tid = await _tree(client, "cl-spouse@example.com")
husband = (
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Otto", "surname": "Frey", "gender": "male"},
headers=h,
)
).json()["id"]
wife = await _person(client, h, tid, "Bea", "Frey") # no sex
loner = await _person(client, h, tid, "Nyx", "Alone") # no sex, no partner
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": husband, "person_to_id": wife},
headers=h,
)
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
by = {r["person_id"]: r["proposed_gender"] for r in prev}
assert by.get(wife) == "female" # opposite of the confirmed-male husband
assert loner not in by # no known-sex partner → not proposed
assert husband not in by # already has a sex
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/gender",
json={"updates": [{"person_id": wife, "gender": "female"}]},
headers=h,
)
assert r.status_code == 200 and r.json()["updated"] == 1
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{wife}", headers=h)
).json()["gender"] == "female"
# Once set, the wife is no longer proposed.
prev2 = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
assert wife not in [r["person_id"] for r in prev2]
GED = b"""0 HEAD GED = b"""0 HEAD
0 @I1@ INDI 0 @I1@ INDI
1 NAME Josias /Moody/ 1 NAME Josias /Moody/
+10
View File
@@ -0,0 +1,10 @@
import { PublicHeader } from "@/components/public-header";
export default function ExploreLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<PublicHeader />
<main className="mx-auto max-w-5xl px-4 py-8">{children}</main>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { useEffect, 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 Tree = components["schemas"]["PublicTreeRead"];
// Public directory of trees. The backend returns `public` to everyone and adds
// `site_members` trees when the request carries a valid session — so signed-in
// users see more here without any client-side branching.
export default function ExplorePage() {
const [trees, setTrees] = useState<Tree[]>([]);
const [search, setSearch] = useState("");
const [ready, setReady] = useState(false);
useEffect(() => {
const q = search.trim();
const t = setTimeout(async () => {
const { data } = await api.GET("/api/v1/public/trees", {
params: { query: q ? { q } : {} },
});
setTrees(data ?? []);
setReady(true);
}, 200);
return () => clearTimeout(t);
}, [search]);
return (
<div className="space-y-6">
<div>
<h1 className="font-serif text-3xl font-semibold">Explore public trees</h1>
<p className="mt-1 text-[var(--muted)]">
Browse family trees shared on this site. Living people are always hidden.
</p>
</div>
<Input
className="w-72"
placeholder="Search trees by name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{!ready ? (
<p className="text-[var(--muted)]">Loading</p>
) : trees.length === 0 ? (
<p className="text-[var(--muted)]">No public trees{search.trim() ? " match that search" : " yet"}.</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{trees.map((t) => (
<li key={t.id}>
<Link href={`/p/${t.id}`}>
<Card className="h-full transition-colors hover:border-bronze/50">
<CardContent className="p-4">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{t.name}</span>
<span className="shrink-0 text-xs uppercase tracking-wide text-[var(--muted)]">
{t.visibility === "site_members" ? "Members" : "Public"}
</span>
</div>
{t.description && (
<p className="mt-1 line-clamp-2 text-sm text-[var(--muted)]">{t.description}</p>
)}
</CardContent>
</Card>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
"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>
);
}
@@ -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<Person | null>(null);
const [names, setNames] = useState<Name[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
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 <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 person isnt publicly visible.</p>
<Link href={`/p/${treeId}`} className="text-sm text-bronze hover:underline">
Back to the tree
</Link>
</div>
);
const relGroup = (label: string, items: Relationship[]) =>
items.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
<ul className="mt-1 space-y-1">
{items.map((r) => (
<li key={r.id} className="text-sm">
<Link
href={`/p/${treeId}/persons/${otherEnd(r)}`}
className="hover:underline"
>
{nameOf(otherEnd(r))}
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
</Link>
</li>
))}
</ul>
</div>
);
return (
<div className="space-y-6">
<Link href={`/p/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
Back to the tree
</Link>
<h1 className="font-serif text-3xl font-semibold">{person?.primary_name ?? "Unnamed"}</h1>
<div className="grid gap-5 sm:grid-cols-2">
{events.length > 0 && (
<Card>
<CardContent className="space-y-2 p-5">
<h2 className="font-serif text-base font-semibold">Events</h2>
<ul className="space-y-1 text-sm">
{events.map((e) => (
<li key={e.id} className="flex justify-between gap-3">
<span className="capitalize">{e.event_type}</span>
<span className="text-[var(--muted)]">
{e.date_value ?? e.date_start ?? ""}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{names.length > 1 && (
<Card>
<CardContent className="space-y-2 p-5">
<h2 className="font-serif text-base font-semibold">Names</h2>
<ul className="space-y-1 text-sm">
{names.map((n) => (
<li key={n.id} className="flex justify-between gap-3">
<span>{[n.given, n.surname].filter(Boolean).join(" ") || "—"}</span>
<span className="text-[var(--muted)]">{n.name_type}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
{(parents.length > 0 || children.length > 0 || partners.length > 0) && (
<Card>
<CardContent className="grid gap-4 p-5 sm:grid-cols-3">
{relGroup("Parents", parents)}
{relGroup("Partners", partners)}
{relGroup("Children", children)}
</CardContent>
</Card>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { usePathname } from "next/navigation";
import { PublicHeader } from "@/components/public-header";
// Public viewing surface — no auth, no app sidebar. The tree page (/p/<id>)
// wants the whole canvas like the member tree view; the person detail page
// reads better in a centered column.
export default function PublicLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const fullWidth = /^\/p\/[^/]+$/.test(pathname);
return (
<div className="min-h-screen">
<PublicHeader />
<main className={fullWidth ? "w-full px-4 py-6 md:px-6" : "mx-auto max-w-5xl px-4 py-8"}>
{children}
</main>
</div>
);
}
+8
View File
@@ -31,6 +31,9 @@ export default function Home() {
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" /> <img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link> </Link>
<nav className="flex items-center gap-5 text-sm"> <nav className="flex items-center gap-5 text-sm">
<Link href="/explore" className="text-[var(--muted)] hover:text-[var(--foreground)]">
Explore
</Link>
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]"> <Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
Trees Trees
</Link> </Link>
@@ -66,6 +69,11 @@ export default function Home() {
Sign in Sign in
</Button> </Button>
</Link> </Link>
<Link href="/explore">
<Button size="lg" variant="ghost">
Explore public trees
</Button>
</Link>
</div> </div>
</div> </div>
+15
View File
@@ -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"],
},
};
}
+16 -2
View File
@@ -109,6 +109,15 @@ export default function CleanupPage() {
setGenSel(new Set((data ?? []).map((g) => g.person_id))); setGenSel(new Set((data ?? []).map((g) => g.person_id)));
} }
async function guessGenderFromSpouse() {
setGenMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/from-spouse", {
params: { path: { tree_id: treeId } },
});
setGender(data ?? []);
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
}
async function applyGender() { async function applyGender() {
const updates = (gender ?? []) const updates = (gender ?? [])
.filter((g) => genSel.has(g.person_id)) .filter((g) => genSel.has(g.person_id))
@@ -246,10 +255,15 @@ export default function CleanupPage() {
<Button variant="outline" onClick={guessGender}> <Button variant="outline" onClick={guessGender}>
Guess from first name Guess from first name
</Button> </Button>
<Button variant="outline" onClick={guessGenderFromSpouse}>
Infer from spouse
</Button>
</div> </div>
<p className="text-xs text-[var(--muted)]"> <p className="text-xs text-[var(--muted)]">
Guess from first name uses a built-in name dictionary for people with no sex set; Guess from first name uses a built-in name dictionary for people with no sex set.
ambiguous names (Marion, Frances, ) are left for you to decide. Infer from spouse sets the opposite sex for an unset partner of someone whose sex is
known (e.g. a confirmed-male husband a female wife) review before applying, since
it assumes opposite-sex couples.
</p> </p>
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>} {genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
{gender && ( {gender && (
+1 -61
View File
@@ -12,6 +12,7 @@ import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { FanChart } from "@/components/fan-chart"; import { FanChart } from "@/components/fan-chart";
import { DepthControl } from "@/components/depth-control";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"]; type Relationship = components["schemas"]["RelationshipRead"];
@@ -280,67 +281,6 @@ export default function TreePage() {
</button> </button>
); );
// Slider + number stepper + "All" for one generation direction.
const DepthControl = ({
label,
icon,
value,
all,
onValue,
onAll,
disabled,
}: {
label: string;
icon: string;
value: number;
all: boolean;
onValue: (v: number) => void;
onAll: (b: boolean) => void;
disabled?: boolean;
}) => (
<div className={`flex items-center gap-2 ${disabled ? "opacity-40" : ""}`}>
<span className="flex w-24 items-center gap-1 text-xs text-[var(--muted)]">
<span aria-hidden>{icon}</span> {label}
</span>
<input
type="range"
min={0}
max={12}
step={1}
value={all ? 12 : value}
disabled={disabled || all}
onChange={(e) => onValue(Number(e.target.value))}
className="w-28 accent-bronze"
aria-label={`${label} generations`}
/>
{all ? (
<span className="w-10 text-center text-sm font-medium text-bronze">All</span>
) : (
<input
type="number"
min={0}
max={99}
value={value}
disabled={disabled}
onChange={(e) => onValue(Math.max(0, Math.min(99, Number(e.target.value) || 0)))}
className="h-7 w-12 rounded-md border border-[var(--border)] bg-[var(--surface)] px-1 text-center text-sm"
/>
)}
<button
type="button"
disabled={disabled}
onClick={() => onAll(!all)}
title={`Show all ${label.toLowerCase()}`}
className={`rounded-md px-2 py-1 text-xs transition-colors ${
all
? "bg-bronze text-paper"
: "border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
All
</button>
</div>
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
+11
View File
@@ -109,6 +109,17 @@ export default function TreesPage() {
<option value="unlisted">Unlisted</option> <option value="unlisted">Unlisted</option>
<option value="public">Public</option> <option value="public">Public</option>
</select> </select>
{tree.visibility && tree.visibility !== "private" && (
<a
href={`/p/${tree.id}`}
target="_blank"
rel="noreferrer"
title="Open the public, no-login view"
className="shrink-0 text-xs text-bronze hover:underline"
>
Public page
</a>
)}
<button <button
onClick={() => remove(tree.id)} onClick={() => remove(tree.id)}
className="text-[var(--muted)] hover:text-bronze" className="text-[var(--muted)] hover:text-bronze"
+2
View File
@@ -4,6 +4,7 @@ import {
Archive, Archive,
ArrowDownUp, ArrowDownUp,
BookText, BookText,
Compass,
FolderTree, FolderTree,
Image as ImageIcon, Image as ImageIcon,
LogOut, LogOut,
@@ -92,6 +93,7 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
</Link> </Link>
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} /> <Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
<Item href="/explore" label="Explore" icon={Compass} active={pathname === "/explore"} />
<Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} /> <Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} />
{treeId && ( {treeId && (
+66
View File
@@ -0,0 +1,66 @@
"use client";
// Slider + number stepper + "All" toggle for one generation direction
// (ancestors or descendants). Shared by the member and public tree views.
export function DepthControl({
label,
icon,
value,
all,
onValue,
onAll,
disabled,
}: {
label: string;
icon: string;
value: number;
all: boolean;
onValue: (v: number) => void;
onAll: (b: boolean) => void;
disabled?: boolean;
}) {
return (
<div className={`flex items-center gap-2 ${disabled ? "opacity-40" : ""}`}>
<span className="flex w-24 items-center gap-1 text-xs text-[var(--muted)]">
<span aria-hidden>{icon}</span> {label}
</span>
<input
type="range"
min={0}
max={12}
step={1}
value={all ? 12 : value}
disabled={disabled || all}
onChange={(e) => onValue(Number(e.target.value))}
className="w-28 accent-bronze"
aria-label={`${label} generations`}
/>
{all ? (
<span className="w-10 text-center text-sm font-medium text-bronze">All</span>
) : (
<input
type="number"
min={0}
max={99}
value={value}
disabled={disabled}
onChange={(e) => onValue(Math.max(0, Math.min(99, Number(e.target.value) || 0)))}
className="h-7 w-12 rounded-md border border-[var(--border)] bg-[var(--surface)] px-1 text-center text-sm"
/>
)}
<button
type="button"
disabled={disabled}
onClick={() => onAll(!all)}
title={`Show all ${label.toLowerCase()}`}
className={`rounded-md px-2 py-1 text-xs transition-colors ${
all
? "bg-bronze text-paper"
: "border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
All
</button>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
// Slim header for the public (no-auth) surface: /p/* and /explore.
export function PublicHeader() {
return (
<header className="border-b border-[var(--border)]">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<Link href="/" aria-label="Provenance — home" className="flex items-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link href="/explore" className="text-[var(--muted)] hover:text-[var(--foreground)]">
Explore
</Link>
<Link href="/login" className="text-bronze hover:underline">
Sign in
</Link>
</nav>
</div>
</header>
);
}
+221
View File
@@ -0,0 +1,221 @@
"use client";
// Vendored family-chart styles (the package blocks the CSS subpath export).
import "../app/trees/[id]/tree/chart.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { components } from "@/lib/api/schema";
import { DepthControl } from "@/components/depth-control";
type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
function splitName(name: string | null | undefined): [string, string] {
const t = (name ?? "").trim().split(/\s+/).filter(Boolean);
if (t.length <= 1) return [name ?? "", ""];
return [t.slice(0, -1).join(" "), t[t.length - 1]];
}
/**
* Read-only family-chart hourglass for the public surface. Same renderer the
* member tree view uses (incl. the cycle-sanitisation that keeps a bad graph
* from blowing the stack), fed by already-redacted public data. Clicking a card
* recenters; `onOpen` links out to the person's public page.
*/
export function PublicTreeChart({
people,
rels,
events,
focusId,
onFocus,
onOpen,
}: {
people: Person[];
rels: Relationship[];
events: Event[];
focusId: string | null;
onFocus: (id: string) => void;
onOpen?: (id: string) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null);
// Generations to show around the focus, each settable (or "all"). ALL_DEPTH is
// a number bigger than any real lineage; the chart only renders real people.
const [ancDepth, setAncDepth] = useState(3);
const [progDepth, setProgDepth] = useState(2);
const [ancAll, setAncAll] = useState(false);
const [progAll, setProgAll] = useState(false);
const ALL_DEPTH = 100;
const effAnc = ancAll ? ALL_DEPTH : ancDepth;
const effProg = progAll ? ALL_DEPTH : progDepth;
const parentsOf = useCallback(
(id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
[rels],
);
const partnersOf = useCallback(
(id: string) =>
rels
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id))
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)),
[rels],
);
const years = useMemo(() => {
const m = new Map<string, string>();
for (const ev of events) {
if (ev.person_id && ev.event_type === "birth" && !m.has(ev.person_id)) {
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
if (y) m.set(ev.person_id, y);
}
}
return m;
}, [events]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
const nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "person", [byId]);
// Build the chart when the data changes (not on focus — recenter handles that).
useEffect(() => {
if (people.length === 0 || !containerRef.current) return;
let cancelled = false;
(async () => {
const alive = new Set(people.map((pp) => pp.id));
const ok = (ids: string[], self: string) =>
[...new Set(ids)].filter((id) => alive.has(id) && id !== self);
const parentsMap = new Map<string, string[]>();
const childrenMap = new Map<string, string[]>();
const isAncestorOf = (ancestor: string, of: string): boolean => {
const stack = [...(parentsMap.get(of) ?? [])];
const seen = new Set<string>();
while (stack.length) {
const n = stack.pop()!;
if (n === ancestor) return true;
if (seen.has(n)) continue;
seen.add(n);
for (const p of parentsMap.get(n) ?? []) stack.push(p);
}
return false;
};
for (const pp of people) {
const accepted: string[] = [];
for (const par of ok(parentsOf(pp.id), pp.id)) {
if (isAncestorOf(pp.id, par)) continue;
accepted.push(par);
parentsMap.set(pp.id, accepted);
childrenMap.set(par, [...(childrenMap.get(par) ?? []), pp.id]);
}
parentsMap.set(pp.id, accepted);
}
const data = people.map((pp) => {
const [fn, ln] = splitName(pp.primary_name);
return {
id: pp.id,
data: {
"first name": fn || "Unnamed",
"last name": ln,
birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
},
rels: {
spouses: ok(partnersOf(pp.id), pp.id),
parents: parentsMap.get(pp.id) ?? [],
children: childrenMap.get(pp.id) ?? [],
},
};
});
const f3 = await import("family-chart");
if (cancelled || !containerRef.current) return;
try {
containerRef.current.innerHTML = "";
const chart = f3.createChart(containerRef.current, data);
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
chart.setOrientationHorizontal();
chart.setAncestryDepth?.(effAnc);
chart.setProgenyDepth?.(effProg);
chart.setAfterUpdate?.(() => {
const md = chart.getMainDatum?.();
const id = md?.id ?? md?.data?.id;
if (id) onFocus(id);
});
chartRef.current = chart;
if (focusId) chart.updateMainId(focusId);
chart.updateTree({ initial: true });
} catch (err) {
console.error("public tree render failed", err);
if (containerRef.current) containerRef.current.innerHTML = "";
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [people, rels, events]);
// Recenter when the focus changes without rebuilding the chart.
useEffect(() => {
if (focusId && chartRef.current) {
chartRef.current.updateMainId?.(focusId);
chartRef.current.updateTree?.();
}
}, [focusId]);
// Apply depth changes to the live chart without a full rebuild.
useEffect(() => {
if (!chartRef.current) return;
chartRef.current.setAncestryDepth?.(effAnc);
chartRef.current.setProgenyDepth?.(effProg);
chartRef.current.updateTree?.();
}, [effAnc, effProg]);
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5">
<span className="text-sm font-medium">Generations</span>
<DepthControl
label="Ancestors"
icon="↑"
value={ancDepth}
all={ancAll}
onValue={(v) => {
setAncAll(false);
setAncDepth(v);
}}
onAll={setAncAll}
/>
<DepthControl
label="Descendants"
icon="↓"
value={progDepth}
all={progAll}
onValue={(v) => {
setProgAll(false);
setProgDepth(v);
}}
onAll={setProgAll}
/>
</div>
<div
ref={containerRef}
className="f3 rounded-xl border border-[var(--border)]"
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-[var(--muted)]">
Drag to pan · scroll to zoom · click a person to recenter.
</p>
{focusId && onOpen && (
<button onClick={() => onOpen(focusId)} className="text-sm text-bronze hover:underline">
Open {nameOf(focusId)}
</button>
)}
</div>
</div>
);
}
+51
View File
@@ -734,6 +734,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/cleanup/gender/from-spouse": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Guess Gender From Spouse
* @description Infer a missing sex from a partner whose sex is set (opposite-sex couple).
*/
get: operations["guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/cleanup/gender": { "/api/v1/trees/{tree_id}/cleanup/gender": {
parameters: { parameters: {
query?: never; query?: never;
@@ -3687,6 +3707,37 @@ export interface operations {
}; };
}; };
}; };
guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_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"]["GenderProposal"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
apply_gender_api_v1_trees__tree_id__cleanup_gender_post: { apply_gender_api_v1_trees__tree_id__cleanup_gender_post: {
parameters: { parameters: {
query?: never; query?: never;
+48
View File
@@ -2915,6 +2915,54 @@
} }
} }
}, },
"/api/v1/trees/{tree_id}/cleanup/gender/from-spouse": {
"get": {
"tags": [
"cleanup"
],
"summary": "Guess Gender From Spouse",
"description": "Infer a missing sex from a partner whose sex is set (opposite-sex couple).",
"operationId": "guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_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/GenderProposal"
},
"title": "Response Guess Gender From Spouse Api V1 Trees Tree Id Cleanup Gender From Spouse Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/cleanup/gender": { "/api/v1/trees/{tree_id}/cleanup/gender": {
"post": { "post": {
"tags": [ "tags": [