Compare commits
11 Commits
20c7fbd8d6
...
660fe7b37f
| 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]
|
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,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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";
|
"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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
Reference in New Issue
Block a user