11 Commits

Author SHA1 Message Date
justin 660fe7b37f Security: gate sessions on verified email (opt-in)
Backlog §2.10: registration issued a live session and email_verified_at was
written but never read, so an unverified user had full access and there was no
switch to require verification.

Add REQUIRE_EMAIL_VERIFICATION (default false). When true:
- resolve_session_user returns None for a user whose email_verified_at is null —
  the single read-side gate covering every authenticated request, incl. the
  session minted at registration.
- login raises 403 ("email not verified") instead of issuing a useless token.

Default false on purpose: self-hosts without SMTP, and accounts created before
this gate existed (email_verified_at null), must not be locked out. Operators
enable it once mail works and accounts are verified. Documented in .env.example.

Tests: default-off keeps unverified accounts working; on → register's session
won't resolve (401), login is 403, and after verify-email both work. 75 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 11:22:54 -04:00
justin 5485dd2077 Merge pull request 'Cleanup: infer a missing sex from a known-sex spouse (preview → approve)' (#52) from cleanup-sex-from-spouse into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m28s
2026-06-09 10:59:10 -04:00
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
20 changed files with 730 additions and 89 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
+5
View File
@@ -48,6 +48,11 @@ class Settings(BaseSettings):
purge_after_days: int = 30 # soft-deleted rows older than this are purged purge_after_days: int = 30 # soft-deleted rows older than this are purged
# --- Email (SMTP) --- # --- Email (SMTP) ---
# When true, a user with no verified email gets no active session (login is
# refused and existing sessions stop resolving). Default false so self-hosts
# without SMTP — and accounts created before this gate existed — aren't
# locked out; operators turn it on once mail works and accounts are verified.
require_email_verification: bool = False
mailer: str = Field(default="console", description="console | smtp") mailer: str = Field(default="console", description="console | smtp")
smtp_host: str | None = None smtp_host: str | None = None
smtp_port: int = 587 smtp_port: int = 587
+8 -1
View File
@@ -113,6 +113,8 @@ async def login(
user = await _local_provider.authenticate(session, identifier=email, secret=password) user = await _local_provider.authenticate(session, identifier=email, secret=password)
if user is None: if user is None:
return None return None
if get_settings().require_email_verification and user.email_verified_at is None:
raise Forbidden("email not verified — check your inbox for the verification link")
raw_token, record = _issue_session(session, user) raw_token, record = _issue_session(session, user)
record_audit( record_audit(
session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id
@@ -141,11 +143,16 @@ async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User
).scalar_one_or_none() ).scalar_one_or_none()
if record is None or record.revoked_at is not None or record.expires_at <= _now(): if record is None or record.revoked_at is not None or record.expires_at <= _now():
return None return None
return ( user = (
await session.execute( await session.execute(
select(User).where(User.id == record.user_id, User.deleted_at.is_(None)) select(User).where(User.id == record.user_id, User.deleted_at.is_(None))
) )
).scalar_one_or_none() ).scalar_one_or_none()
# The single read-side enforcement: an unverified user has no active session
# when verification is required. Gates every authenticated request at once.
if user is not None and get_settings().require_email_verification and user.email_verified_at is None:
return None
return user
async def verify_email(session: AsyncSession, *, raw_token: str) -> None: async def verify_email(session: AsyncSession, *, raw_token: str) -> None:
+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:
+41
View File
@@ -104,3 +104,44 @@ async def test_logout_revokes_session(client):
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200 assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204 assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401 assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
async def test_unverified_user_works_by_default(client):
# Default (require_email_verification off): unverified accounts work as before.
token = await register(client, "open@example.com")
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
async def test_verification_gate_blocks_until_verified(client, mailer, monkeypatch):
from app.core.config import get_settings
monkeypatch.setattr(get_settings(), "require_email_verification", True)
reg = await client.post(
"/api/v1/auth/register", json={"email": "gate@example.com", "password": "password123"}
)
assert reg.status_code == 201
token = reg.json()["token"]
# The session issued at registration does not resolve while unverified...
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
# ...and login is refused with 403 (not 401 — credentials are valid).
blocked = await client.post(
"/api/v1/auth/login", json={"email": "gate@example.com", "password": "password123"}
)
assert blocked.status_code == 403
# Verify via the emailed link.
link = mailer.verifications[-1][1]
assert (
await client.post("/api/v1/auth/verify-email", json={"token": token_from_link(link)})
).status_code == 204
# Now login works and the session resolves.
ok = await client.post(
"/api/v1/auth/login", json={"email": "gate@example.com", "password": "password123"}
)
assert ok.status_code == 200
assert (
await client.get("/api/v1/users/me", headers=auth(ok.json()["token"]))
).status_code == 200
+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/
+3
View File
@@ -46,6 +46,9 @@ COOKIE_SECURE=false
APP_BASE_URL=http://localhost APP_BASE_URL=http://localhost
# Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below. # Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below.
MAILER=console MAILER=console
# Require a verified email before an account has an active session. Leave false
# until SMTP works and existing accounts are verified, or you will lock users out.
REQUIRE_EMAIL_VERIFICATION=false
# --- Email (SMTP) — wired in a later phase --- # --- Email (SMTP) — wired in a later phase ---
SMTP_HOST= SMTP_HOST=
+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>
);
}
+39 -11
View File
@@ -1,25 +1,30 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { PublicTreeChart } from "@/components/public-tree-chart";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Event = components["schemas"]["EventRead"]; type Event = components["schemas"]["EventRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Tree = components["schemas"]["PublicTreeRead"]; type Tree = components["schemas"]["PublicTreeRead"];
// Public, no-login view of a tree. Everything here is already redacted by the // Public, no-login view of a tree. Everything here is already redacted by the
// /api/v1/public surface (living people show as "Living person"). // /api/v1/public surface (living people show as "Living person").
export default function PublicTreePage() { export default function PublicTreePage() {
const { treeId } = useParams<{ treeId: string }>(); const { treeId } = useParams<{ treeId: string }>();
const router = useRouter();
const [tree, setTree] = useState<Tree | null>(null); const [tree, setTree] = useState<Tree | null>(null);
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [events, setEvents] = useState<Event[]>([]); 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 [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -34,14 +39,22 @@ export default function PublicTreePage() {
setStatus("notfound"); setStatus("notfound");
return; return;
} }
const [p, e] = await Promise.all([ 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}/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}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
}),
]); ]);
if (cancelled) return; 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); setTree(t.data);
setPeople(p.data ?? []); setPeople(ppl);
setEvents(e.data ?? []); setEvents(e.data ?? []);
setRels(r.data ?? []);
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
setStatus("ready"); setStatus("ready");
})(); })();
return () => { return () => {
@@ -89,7 +102,7 @@ export default function PublicTreePage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="mx-auto w-full max-w-5xl">
<h1 className="font-serif text-3xl font-semibold">{tree?.name}</h1> <h1 className="font-serif text-3xl font-semibold">{tree?.name}</h1>
{tree?.description && <p className="mt-1 text-[var(--muted)]">{tree.description}</p>} {tree?.description && <p className="mt-1 text-[var(--muted)]">{tree.description}</p>}
<p className="mt-1 text-sm text-[var(--muted)]"> <p className="mt-1 text-sm text-[var(--muted)]">
@@ -97,14 +110,29 @@ export default function PublicTreePage() {
</p> </p>
</div> </div>
<Input {/* Chart spans the full canvas (the layout removes max-width for /p/<id>). */}
className="w-72" {focusId && people.length > 0 && (
placeholder="Search people…" <PublicTreeChart
value={search} people={people}
onChange={(e) => setSearch(e.target.value)} rels={rels}
/> events={events}
focusId={focusId}
onFocus={setFocusId}
onOpen={(id) => router.push(`/p/${treeId}/persons/${id}`)}
/>
)}
<Card className="overflow-hidden"> <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"> <CardContent className="p-0">
{shown.length === 0 ? ( {shown.length === 0 ? (
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div> <div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
+14 -14
View File
@@ -1,21 +1,21 @@
import Link from "next/link"; "use client";
// Public viewing surface — no auth, no app sidebar. A slim header only. 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 }) { export default function PublicLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const fullWidth = /^\/p\/[^/]+$/.test(pathname);
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<header className="border-b border-[var(--border)]"> <PublicHeader />
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3"> <main className={fullWidth ? "w-full px-4 py-6 md:px-6" : "mx-auto max-w-5xl px-4 py-8"}>
<Link href="/" aria-label="Provenance — home" className="flex items-center"> {children}
{/* eslint-disable-next-line @next/next/no-img-element */} </main>
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
</Link>
<Link href="/login" className="text-sm text-bronze hover:underline">
Sign in
</Link>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8">{children}</main>
</div> </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>
+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">
+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": [