Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 660fe7b37f | |||
| 5485dd2077 | |||
| 05d2773e25 | |||
| 768c68cbe0 | |||
| 7d6fbce87e | |||
| 12ba0a0fb6 | |||
| 150d69e5ac | |||
| 053ce357ac | |||
| 269cae556f | |||
| 0df44e7e59 | |||
| 7a5c5f2882 |
@@ -67,6 +67,16 @@ async def guess_gender(
|
||||
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)
|
||||
async def apply_gender(
|
||||
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
|
||||
|
||||
@@ -48,6 +48,11 @@ class Settings(BaseSettings):
|
||||
purge_after_days: int = 30 # soft-deleted rows older than this are purged
|
||||
|
||||
# --- 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")
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 587
|
||||
|
||||
@@ -113,6 +113,8 @@ async def login(
|
||||
user = await _local_provider.authenticate(session, identifier=email, secret=password)
|
||||
if user is 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)
|
||||
record_audit(
|
||||
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()
|
||||
if record is None or record.revoked_at is not None or record.expires_at <= _now():
|
||||
return None
|
||||
return (
|
||||
user = (
|
||||
await session.execute(
|
||||
select(User).where(User.id == record.user_id, User.deleted_at.is_(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:
|
||||
|
||||
@@ -12,8 +12,10 @@ import uuid
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import RelationshipType
|
||||
from app.models.event import Event
|
||||
from app.models.person import Name, Person
|
||||
from app.models.relationship import Relationship
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.services import gedcom, privacy
|
||||
@@ -182,6 +184,52 @@ async def guess_gender_by_name(
|
||||
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(
|
||||
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
|
||||
) -> int:
|
||||
|
||||
@@ -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.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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -51,6 +51,44 @@ async def test_deceased_preview_and_apply(client):
|
||||
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
|
||||
0 @I1@ INDI
|
||||
1 NAME Josias /Moody/
|
||||
|
||||
@@ -46,6 +46,9 @@ COOKIE_SECURE=false
|
||||
APP_BASE_URL=http://localhost
|
||||
# Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below.
|
||||
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 ---
|
||||
SMTP_HOST=
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
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("");
|
||||
|
||||
@@ -34,14 +39,22 @@ export default function PublicTreePage() {
|
||||
setStatus("notfound");
|
||||
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}/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(p.data ?? []);
|
||||
setPeople(ppl);
|
||||
setEvents(e.data ?? []);
|
||||
setRels(r.data ?? []);
|
||||
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||
setStatus("ready");
|
||||
})();
|
||||
return () => {
|
||||
@@ -89,7 +102,7 @@ export default function PublicTreePage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
{tree?.description && <p className="mt-1 text-[var(--muted)]">{tree.description}</p>}
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
@@ -97,14 +110,29 @@ export default function PublicTreePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className="w-72"
|
||||
placeholder="Search people…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{/* 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}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{shown.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
|
||||
|
||||
+14
-14
@@ -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 }) {
|
||||
const pathname = usePathname();
|
||||
const fullWidth = /^\/p\/[^/]+$/.test(pathname);
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ export default function Home() {
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||
</Link>
|
||||
<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)]">
|
||||
Trees
|
||||
</Link>
|
||||
@@ -66,6 +69,11 @@ export default function Home() {
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/explore">
|
||||
<Button size="lg" variant="ghost">
|
||||
Explore public trees
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -109,6 +109,15 @@ export default function CleanupPage() {
|
||||
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() {
|
||||
const updates = (gender ?? [])
|
||||
.filter((g) => genSel.has(g.person_id))
|
||||
@@ -246,10 +255,15 @@ export default function CleanupPage() {
|
||||
<Button variant="outline" onClick={guessGender}>
|
||||
Guess from first name
|
||||
</Button>
|
||||
<Button variant="outline" onClick={guessGenderFromSpouse}>
|
||||
Infer from spouse
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
“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.
|
||||
“Guess from first name” uses a built-in name dictionary for people with no sex set.
|
||||
“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>
|
||||
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
|
||||
{gender && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FanChart } from "@/components/fan-chart";
|
||||
import { DepthControl } from "@/components/depth-control";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
type Relationship = components["schemas"]["RelationshipRead"];
|
||||
@@ -280,67 +281,6 @@ export default function TreePage() {
|
||||
</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 (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Archive,
|
||||
ArrowDownUp,
|
||||
BookText,
|
||||
Compass,
|
||||
FolderTree,
|
||||
Image as ImageIcon,
|
||||
LogOut,
|
||||
@@ -92,6 +93,7 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
</Link>
|
||||
|
||||
<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"} />
|
||||
|
||||
{treeId && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+51
@@ -734,6 +734,26 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -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": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user