Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d6fbce87e | |||
| 12ba0a0fb6 | |||
| 150d69e5ac | |||
| 053ce357ac | |||
| 269cae556f | |||
| 0df44e7e59 | |||
| 7a5c5f2882 | |||
| 20c7fbd8d6 | |||
| b8405ced07 | |||
| 91a7ce1dc2 | |||
| 8b91326481 |
@@ -97,6 +97,13 @@ async def list_events(
|
|||||||
"""All events in the tree — lets the family view compute birth/death years."""
|
"""All events in the tree — lets the family view compute birth/death years."""
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members get the redacted projection (no living-person dates).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_events(
|
||||||
|
session, viewer_id=viewer_id, tree=tree
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Event)
|
select(Event)
|
||||||
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
@@ -110,6 +117,13 @@ async def list_events_for_person(
|
|||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members only see a full-visibility person's events (redacted → none).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_person_events(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Event)
|
select(Event)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ async def upload_media(
|
|||||||
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
|
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members only see media of a FULL-visibility person (no living-person photos).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_media(
|
||||||
|
session, viewer_id=viewer_id, tree=tree
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Media)
|
select(Media)
|
||||||
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
||||||
@@ -94,6 +101,15 @@ async def get_media(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if media is None:
|
if media is None:
|
||||||
raise NotFound("media not found")
|
raise NotFound("media not found")
|
||||||
|
# Non-members may only see/download media of a FULL-visibility person. 404
|
||||||
|
# (not 403) so the item's existence isn't revealed. This gates media_content.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
if not await public_view_service.can_view_media(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, media=media
|
||||||
|
):
|
||||||
|
raise NotFound("media not found")
|
||||||
return media
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ async def list_names(
|
|||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
await _get_person(session, tree=tree, person_id=person_id)
|
await _get_person(session, tree=tree, person_id=person_id)
|
||||||
|
# Non-members: a redacted/hidden person's real names must not leak.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_person_names(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Name)
|
select(Name)
|
||||||
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ surface can't be used to probe whether a private tree exists.
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.enums import TreeVisibility
|
from app.models.enums import TreeVisibility
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
|
from app.models.media import Media
|
||||||
from app.models.person import Name, Person
|
from app.models.person import Name, Person
|
||||||
from app.models.relationship import Relationship
|
from app.models.relationship import Relationship
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
@@ -234,6 +235,67 @@ async def list_public_person_events(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_relationships_for_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Relationship]:
|
||||||
|
persons = await _persons(session, tree)
|
||||||
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||||
|
if vis.get(person_id) in (None, Visibility.hidden):
|
||||||
|
return []
|
||||||
|
nonhidden = {pid for pid, v in vis.items() if v != Visibility.hidden}
|
||||||
|
rels = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Relationship.person_from_id == person_id,
|
||||||
|
Relationship.person_to_id == person_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [r for r in rels if r.person_from_id in nonhidden and r.person_to_id in nonhidden]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_media(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||||
|
) -> list[Media]:
|
||||||
|
"""Only media linked to a FULL-visibility person. Media without a person (or
|
||||||
|
linked only to an event/source) is not exposed to non-members — a photo of a
|
||||||
|
living person must never leak."""
|
||||||
|
persons = await _persons(session, tree)
|
||||||
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||||
|
full = {pid for pid, v in vis.items() if v == Visibility.full}
|
||||||
|
media = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [m for m in media if m.person_id is not None and m.person_id in full]
|
||||||
|
|
||||||
|
|
||||||
|
async def can_view_media(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, media: Media
|
||||||
|
) -> bool:
|
||||||
|
"""Whether a non-member may see/download a single media item: only when it is
|
||||||
|
linked to a FULL-visibility person."""
|
||||||
|
if media.person_id is None:
|
||||||
|
return False
|
||||||
|
vis = await _person_visibility(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=media.person_id
|
||||||
|
)
|
||||||
|
return vis == Visibility.full
|
||||||
|
|
||||||
|
|
||||||
async def list_public_trees(
|
async def list_public_trees(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ async def list_relationships(
|
|||||||
"""All relationships in the tree — powers the family/pedigree view in one call."""
|
"""All relationships in the tree — powers the family/pedigree view in one call."""
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members: drop relationships touching a hidden person.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_relationships(
|
||||||
|
session, viewer_id=viewer_id, tree=tree
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Relationship)
|
select(Relationship)
|
||||||
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
|
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
|
||||||
@@ -124,6 +131,12 @@ async def list_relationships_for_person(
|
|||||||
) -> list[Relationship]:
|
) -> list[Relationship]:
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_relationships_for_person(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Relationship)
|
select(Relationship)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Authed non-member reads must redact PER-PERSON, not just gate on the tree.
|
||||||
|
|
||||||
|
A logged-in user who is NOT a member of a public tree previously saw living
|
||||||
|
people's dates, real alternate names, and media through the family-view
|
||||||
|
endpoints — only the person *list* was redacted. These tests assert that leak is
|
||||||
|
closed while members still see everything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
LSURNAME = "Authleaksurname"
|
||||||
|
LALIAS = "Authleakalias"
|
||||||
|
LYEAR = "2003"
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(client):
|
||||||
|
owner = auth(await register(client, "anm-owner@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "Pub", "visibility": "public"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
old = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Olde", "surname": "Gone", "is_living": False},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
young = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Youngauth", "surname": LSURNAME, "is_living": True},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
for pid, year in ((old, "1855"), (young, LYEAR)):
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": pid, "date_value": year},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||||
|
json={"name_type": "alias", "given": LALIAS},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
om = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
files={"file": ("o.txt", b"old-photo", "text/plain")},
|
||||||
|
data={"person_id": old},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
ym = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
files={"file": ("y.txt", b"young-photo", "text/plain")},
|
||||||
|
data={"person_id": young},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
return owner, tid, old, young, om, ym
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authed_nonmember_does_not_see_living_pii(client):
|
||||||
|
owner, tid, old, young, om, ym = await _setup(client)
|
||||||
|
stranger = auth(await register(client, "anm-stranger@ex.com"))
|
||||||
|
|
||||||
|
# Living person's events dropped; deceased kept.
|
||||||
|
events = (await client.get(f"/api/v1/trees/{tid}/events", headers=stranger)).json()
|
||||||
|
assert any(e["person_id"] == old for e in events)
|
||||||
|
assert not any(e["person_id"] == young for e in events)
|
||||||
|
|
||||||
|
# Per-person living: names + events empty.
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=stranger)
|
||||||
|
).json() == []
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons/{young}/events", headers=stranger)
|
||||||
|
).json() == []
|
||||||
|
|
||||||
|
# The living surname/alias/birth-year must not appear in any of these.
|
||||||
|
for path in (
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
):
|
||||||
|
body = (await client.get(path, headers=stranger)).text
|
||||||
|
assert LSURNAME not in body, path
|
||||||
|
assert LALIAS not in body, path
|
||||||
|
assert LYEAR not in body, path
|
||||||
|
|
||||||
|
# Media: living person's media hidden from the list and undownloadable;
|
||||||
|
# deceased person's media is fine.
|
||||||
|
media_ids = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=stranger)).json()}
|
||||||
|
assert om in media_ids
|
||||||
|
assert ym not in media_ids
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=stranger)
|
||||||
|
).status_code == 404
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/media/{om}/content", headers=stranger)
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_member_still_sees_everything(client):
|
||||||
|
owner, tid, old, young, om, ym = await _setup(client)
|
||||||
|
|
||||||
|
events = (await client.get(f"/api/v1/trees/{tid}/events", headers=owner)).json()
|
||||||
|
assert any(e["person_id"] == young for e in events)
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=owner)
|
||||||
|
).json() != []
|
||||||
|
member_media = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=owner)).json()}
|
||||||
|
assert ym in member_media
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=owner)
|
||||||
|
).status_code == 200
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PublicTreeChart } from "@/components/public-tree-chart";
|
||||||
|
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
type Tree = components["schemas"]["PublicTreeRead"];
|
||||||
|
|
||||||
|
// Public, no-login view of a tree. Everything here is already redacted by the
|
||||||
|
// /api/v1/public surface (living people show as "Living person").
|
||||||
|
export default function PublicTreePage() {
|
||||||
|
const { treeId } = useParams<{ treeId: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [tree, setTree] = useState<Tree | null>(null);
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
|
const [focusId, setFocusId] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const t = await api.GET("/api/v1/public/trees/{tree_id}", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!t.data) {
|
||||||
|
setStatus("notfound");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [p, e, r] = await Promise.all([
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const ppl = p.data ?? [];
|
||||||
|
const home = t.data.home_person_id;
|
||||||
|
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||||
|
setTree(t.data);
|
||||||
|
setPeople(ppl);
|
||||||
|
setEvents(e.data ?? []);
|
||||||
|
setRels(r.data ?? []);
|
||||||
|
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
|
setStatus("ready");
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [treeId]);
|
||||||
|
|
||||||
|
const years = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
|
||||||
|
for (const p of people) {
|
||||||
|
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
|
||||||
|
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
|
||||||
|
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
|
||||||
|
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, ""));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [people, events]);
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const sorted = [...people].sort((a, b) =>
|
||||||
|
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||||||
|
);
|
||||||
|
return (q ? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) : sorted).slice(
|
||||||
|
0,
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
}, [people, search]);
|
||||||
|
|
||||||
|
if (status === "loading") return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
if (status === "notfound")
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Not available</h1>
|
||||||
|
<p className="text-[var(--muted)]">
|
||||||
|
This tree isn’t public, or the link is wrong.{" "}
|
||||||
|
<Link href="/login" className="text-bronze hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>{" "}
|
||||||
|
if it’s yours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mx-auto w-full max-w-5xl">
|
||||||
|
<h1 className="font-serif text-3xl font-semibold">{tree?.name}</h1>
|
||||||
|
{tree?.description && <p className="mt-1 text-[var(--muted)]">{tree.description}</p>}
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||||
|
{people.length} {people.length === 1 ? "person" : "people"} · living people are hidden
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart spans the full canvas (the layout removes max-width for /p/<id>). */}
|
||||||
|
{focusId && people.length > 0 && (
|
||||||
|
<PublicTreeChart
|
||||||
|
people={people}
|
||||||
|
rels={rels}
|
||||||
|
events={events}
|
||||||
|
focusId={focusId}
|
||||||
|
onFocus={setFocusId}
|
||||||
|
onOpen={(id) => router.push(`/p/${treeId}/persons/${id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-3">
|
||||||
|
<h2 className="font-serif text-base font-semibold">All people</h2>
|
||||||
|
<Input
|
||||||
|
className="w-72"
|
||||||
|
placeholder="Search people…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mx-auto w-full max-w-5xl overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
|
||||||
|
) : (
|
||||||
|
shown.map((p, i) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/p/${treeId}/persons/${p.id}`}
|
||||||
|
className={`flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-bronze/[0.05] ${
|
||||||
|
i > 0 ? "border-t border-[var(--border)]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
|
||||||
|
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Name = components["schemas"]["NameRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
|
||||||
|
// Public, no-login person view. The /api/v1/public surface returns only what an
|
||||||
|
// anonymous viewer may see: deceased people in full, living people redacted.
|
||||||
|
export default function PublicPersonPage() {
|
||||||
|
const { treeId, personId } = useParams<{ treeId: string; personId: string }>();
|
||||||
|
const [person, setPerson] = useState<Person | null>(null);
|
||||||
|
const [names, setNames] = useState<Name[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const pr = await api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!pr.data) {
|
||||||
|
setStatus("notfound");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [nm, ev, ppl, rl] = await Promise.all([
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/names", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
}),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/events", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
}),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setPerson(pr.data);
|
||||||
|
setNames(nm.data ?? []);
|
||||||
|
setEvents(ev.data ?? []);
|
||||||
|
setPeople(ppl.data ?? []);
|
||||||
|
setRels(rl.data ?? []);
|
||||||
|
setStatus("ready");
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [treeId, personId]);
|
||||||
|
|
||||||
|
const nameOf = useMemo(
|
||||||
|
() => (id: string) => people.find((p) => p.id === id)?.primary_name ?? "Unknown",
|
||||||
|
[people],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
|
||||||
|
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
|
||||||
|
const partners = rels.filter(
|
||||||
|
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
|
||||||
|
);
|
||||||
|
const otherEnd = (r: Relationship) =>
|
||||||
|
r.person_from_id === personId ? r.person_to_id : r.person_from_id;
|
||||||
|
|
||||||
|
if (status === "loading") return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
if (status === "notfound")
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Not available</h1>
|
||||||
|
<p className="text-[var(--muted)]">This person isn’t publicly visible.</p>
|
||||||
|
<Link href={`/p/${treeId}`} className="text-sm text-bronze hover:underline">
|
||||||
|
← Back to the tree
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const relGroup = (label: string, items: Relationship[]) =>
|
||||||
|
items.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{items.map((r) => (
|
||||||
|
<li key={r.id} className="text-sm">
|
||||||
|
<Link
|
||||||
|
href={`/p/${treeId}/persons/${otherEnd(r)}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{nameOf(otherEnd(r))}
|
||||||
|
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link href={`/p/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
||||||
|
← Back to the tree
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="font-serif text-3xl font-semibold">{person?.primary_name ?? "Unnamed"}</h1>
|
||||||
|
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
{events.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-2 p-5">
|
||||||
|
<h2 className="font-serif text-base font-semibold">Events</h2>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e.id} className="flex justify-between gap-3">
|
||||||
|
<span className="capitalize">{e.event_type}</span>
|
||||||
|
<span className="text-[var(--muted)]">
|
||||||
|
{e.date_value ?? e.date_start ?? ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{names.length > 1 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-2 p-5">
|
||||||
|
<h2 className="font-serif text-base font-semibold">Names</h2>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{names.map((n) => (
|
||||||
|
<li key={n.id} className="flex justify-between gap-3">
|
||||||
|
<span>{[n.given, n.surname].filter(Boolean).join(" ") || "—"}</span>
|
||||||
|
<span className="text-[var(--muted)]">{n.name_type}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(parents.length > 0 || children.length > 0 || partners.length > 0) && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid gap-4 p-5 sm:grid-cols-3">
|
||||||
|
{relGroup("Parents", parents)}
|
||||||
|
{relGroup("Partners", partners)}
|
||||||
|
{relGroup("Children", children)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { PublicHeader } from "@/components/public-header";
|
||||||
|
|
||||||
|
// Public viewing surface — no auth, no app sidebar. The tree page (/p/<id>)
|
||||||
|
// wants the whole canvas like the member tree view; the person detail page
|
||||||
|
// reads better in a centered column.
|
||||||
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const fullWidth = /^\/p\/[^/]+$/.test(pathname);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<PublicHeader />
|
||||||
|
<main className={fullWidth ? "w-full px-4 py-6 md:px-6" : "mx-auto max-w-5xl px-4 py-8"}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
// Allow crawlers on the public surface; keep the authenticated app out of the
|
||||||
|
// index. (Per-tree noindex for `unlisted`/`site_members` pages needs server
|
||||||
|
// rendering — tracked as a follow-up; those trees aren't linked or listed, so
|
||||||
|
// they aren't discoverable by crawl in the meantime.)
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: ["/", "/p/"],
|
||||||
|
disallow: ["/trees", "/settings", "/import", "/login", "/register"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ export default function TreesPage() {
|
|||||||
<option value="unlisted">Unlisted</option>
|
<option value="unlisted">Unlisted</option>
|
||||||
<option value="public">Public</option>
|
<option value="public">Public</option>
|
||||||
</select>
|
</select>
|
||||||
|
{tree.visibility && tree.visibility !== "private" && (
|
||||||
|
<a
|
||||||
|
href={`/p/${tree.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Open the public, no-login view"
|
||||||
|
className="shrink-0 text-xs text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
Public page ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => remove(tree.id)}
|
onClick={() => remove(tree.id)}
|
||||||
className="text-[var(--muted)] hover:text-bronze"
|
className="text-[var(--muted)] hover:text-bronze"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user