14 Commits

Author SHA1 Message Date
justin 4788ae7723 Add fuzzy name search (pg_trgm) and living-person protection
Fuzzy search: pg_trgm extension + trigram GIN indexes on name parts and a GET /trees/{id}/persons?q= search ranked by trigram similarity (finds Mueller for 'muller'), privacy-filtered. Living-person protection: the privacy engine now derives possibly-living status (explicit flag, else no death fact + birth within ~100y or unknown) and returns 'redacted' for non-members of public/unlisted trees; the service minimises those records ('Living person', no vitals). Members are unaffected. 31 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 07:55:13 -04:00
justin 51f0066e61 Merge pull request 'Interactive Tree view (pan/zoom genealogy chart)' (#14) from interactive-tree into main
build-frontend / build (push) Successful in 1m21s
2026-06-06 23:07:04 -04:00
justin bfa6c0782a Add an interactive Tree view (pan/zoom genealogy chart)
Researched how FamilySearch/Geni/MyHeritage lay out trees (switchable pedigree/portrait/fan, an interactive canvas with pan/zoom + click-to-recenter, gender colors, birth-death years) and built a real Tree page on the MIT d3 library family-chart instead of a flat list. Ancestors + descendants around a focus person, click any card to recenter, drag to pan, scroll to zoom — scales to large imported trees. Tree is now the first per-tree sidebar item and the default when opening a tree; People keeps the searchable directory + add/edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 23:07:02 -04:00
justin 2f21e767f3 Merge pull request 'Scalable people directory' (#13) from people-directory into main
build-frontend / build (push) Successful in 1m20s
2026-06-06 22:54:10 -04:00
justin f6bcf198ee Make the people index a scalable scrollable directory
A flat wrap of every person didn't scale to imported trees. Replace it with a bounded (max-height, scrollable) searchable directory: clean name + birth–death-year rows, focus highlight, a result count, and a 200-row cap with a 'refine your search' notice so a thousand-person tree stays fast and usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:54:08 -04:00
justin b13fafd624 Merge pull request 'Phase 2: GEDCOM import/export' (#12) from phase2-gedcom into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m22s
2026-06-06 22:46:50 -04:00
justin 631d050540 Add GEDCOM Import/Export UI (defaults to importing into a new tree)
An Import/Export page (sidebar) that defaults to importing into a NEW tree to avoid duplicating existing people, with an explicit 'append to this tree' option (warned), a mapping-report display (counts + skipped tags), and a one-click .ged export download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin d48029a407 Add GEDCOM import/export
A pragmatic GEDCOM parser + mapper: import reads INDI/FAM/SOUR and creates people, names, life events, partnership + qualified parent-child relationships, marriage events, places (deduped), sources, and citations from SOUR refs — returning a mapping report (counts + unmapped tags). Export serializes the tree back to GEDCOM (families derived from the edge model). Import is additive (no merge) and runs inline for now. Round-trip test passes; 29 tests total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin 18dea507d1 Merge pull request 'Pedigree connector lines + 4 grandparents' (#11) from pedigree-connectors into main
build-frontend / build (push) Successful in 1m22s
2026-06-06 22:32:12 -04:00
justin 99a660485e Pedigree: connector lines + correct 4-grandparent structure
Rebuilds the family view's pedigree as a recursive bracket chart with CSS connector lines — focus links to its two parents (2 lines), and each parent links to its two parents (4 lines to grandparents). Fixes the prior ambiguity where grandparent slots weren't tied to a specific parent: now every parent shows its own two parent slots, so a person clearly has up to four grandparents grouped by lineage. Height-robust connectors (each leaf draws its own spine half + stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:32:10 -04:00
justin cf6dcf9ce2 Merge pull request 'Family view + soft-delete/recovery' (#10) from phase1-familyview into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m18s
2026-06-06 22:19:02 -04:00
justin 22bc536978 Rebuild People as a family view (pedigree + family group); add recovery UI
The People page is no longer a flat list: it's a focus-person family view with a pedigree of ancestors (parents + grandparents), a spouse/partner panel, and a children panel — with inline 'add parent/child/spouse' (creates the person + the relationship), click-to-refocus, birth–death years, and a searchable people index. Modeled on how real genealogy tools center on a person and let you walk the graph.

Adds delete/restore UI: a Delete on the person page, per-tree delete + a 'Recently deleted' restore section on the trees list, and a Recovery page (sidebar) for deleted people.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin f2205b93f4 Add soft-delete + recovery and tree-wide graph endpoints
Tree and person soft-delete + restore (owner-only for trees, editor for people) with recovery listings (?deleted=true); the worker already purges past the 30-day window. Adds tree-wide GET /relationships and /events so the family/pedigree view loads the whole graph in a few calls. 27 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin b0c7c8570b Merge pull request 'App-shell UI overhaul + media stream endpoint' (#9) from ui-shell into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m20s
2026-06-06 21:56:26 -04:00
34 changed files with 4051 additions and 135 deletions
+2
View File
@@ -6,6 +6,7 @@ from app.api.v1 import (
auth, auth,
citations, citations,
events, events,
gedcom,
media, media,
persons, persons,
relationships, relationships,
@@ -24,3 +25,4 @@ api_router.include_router(relationships.router)
api_router.include_router(sources.router) api_router.include_router(sources.router)
api_router.include_router(citations.router) api_router.include_router(citations.router)
api_router.include_router(media.router) api_router.include_router(media.router)
api_router.include_router(gedcom.router)
+9
View File
@@ -20,6 +20,15 @@ async def create_event(
return EventRead.model_validate(event) return EventRead.model_validate(event)
@router.get("/{tree_id}/events", response_model=list[EventRead])
async def list_tree_events(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[EventRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
events = await event_service.list_events(session, viewer_id=current.id, tree=tree)
return [EventRead.model_validate(e) for e in events]
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead]) @router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
async def list_person_events( async def list_person_events(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+37
View File
@@ -0,0 +1,37 @@
import uuid
from fastapi import APIRouter, File, Response, UploadFile
from app.api.deps import CurrentUser, SessionDep
from app.schemas.gedcom import ImportReport
from app.services import gedcom, tree_service
router = APIRouter(prefix="/trees", tags=["gedcom"])
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
async def import_gedcom(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
file: UploadFile = File(...),
) -> ImportReport:
# NOTE: additive — records are created as new; existing people are not merged.
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = (await file.read()).decode("utf-8", errors="replace")
report = await gedcom.import_gedcom(session, actor=current, tree=tree, text=text)
return ImportReport(**report)
@router.get("/{tree_id}/gedcom/export")
async def export_gedcom(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = await gedcom.export_gedcom(session, viewer_id=current.id, tree=tree)
safe = "".join(c for c in tree.name if c.isalnum() or c in " -_").strip() or "tree"
return Response(
content=text,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{safe}.ged"'},
)
+33 -1
View File
@@ -36,13 +36,45 @@ async def create_person(
@router.get("/{tree_id}/persons", response_model=list[PersonRead]) @router.get("/{tree_id}/persons", response_model=list[PersonRead])
async def list_persons( async def list_persons(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
deleted: bool = False,
q: str | None = None,
) -> list[PersonRead]: ) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
if q:
persons = await person_service.search_persons(
session, viewer_id=current.id, tree=tree, query=q
)
elif deleted:
persons = await person_service.list_deleted_persons(
session, viewer_id=current.id, tree=tree
)
else:
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
return [PersonRead.model_validate(p) for p in persons] return [PersonRead.model_validate(p) for p in persons]
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id)
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
async def restore_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.restore_person(
session, actor=current, tree=tree, person_id=person_id
)
return PersonRead.model_validate(person)
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead) @router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def get_person( async def get_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+9
View File
@@ -24,6 +24,15 @@ async def create_relationship(
return RelationshipRead.model_validate(relationship) return RelationshipRead.model_validate(relationship)
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
async def list_relationships(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[RelationshipRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree)
return [RelationshipRead.model_validate(r) for r in rels]
@router.get( @router.get(
"/{tree_id}/persons/{person_id}/relationships", "/{tree_id}/persons/{person_id}/relationships",
response_model=list[RelationshipRead], response_model=list[RelationshipRead],
+17 -1
View File
@@ -22,7 +22,12 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse
@router.get("", response_model=list[TreeRead]) @router.get("", response_model=list[TreeRead])
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]: async def list_my_trees(
session: SessionDep, current: CurrentUser, deleted: bool = False
) -> list[TreeRead]:
if deleted:
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
else:
trees = await tree_service.list_trees_for_user(session, user=current) trees = await tree_service.list_trees_for_user(session, user=current)
return [TreeRead.model_validate(t) for t in trees] return [TreeRead.model_validate(t) for t in trees]
@@ -31,3 +36,14 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
return TreeRead.model_validate(tree) return TreeRead.model_validate(tree)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
@router.post("/{tree_id}/restore", response_model=TreeRead)
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
return TreeRead.model_validate(tree)
+17 -1
View File
@@ -7,7 +7,7 @@ aliases) so name changes over time are first-class.
import uuid import uuid
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy import Enum as SAEnum from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -33,6 +33,22 @@ class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "names" __tablename__ = "names"
# Trigram indexes for fuzzy name search (Mueller/Müller/Muller). Requires the
# pg_trgm extension (enabled in the accompanying migration).
__table_args__ = (
Index(
"ix_names_given_trgm",
"given",
postgresql_using="gin",
postgresql_ops={"given": "gin_trgm_ops"},
),
Index(
"ix_names_surname_trgm",
"surname",
postgresql_using="gin",
postgresql_ops={"surname": "gin_trgm_ops"},
),
)
person_id: Mapped[uuid.UUID] = mapped_column( person_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("persons.id", ondelete="CASCADE"), index=True ForeignKey("persons.id", ondelete="CASCADE"), index=True
+6
View File
@@ -0,0 +1,6 @@
from pydantic import BaseModel
class ImportReport(BaseModel):
counts: dict[str, int]
unmapped_tags: list[str]
+14
View File
@@ -91,6 +91,20 @@ async def create_event(
return event return event
async def list_events(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Event]:
"""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):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Event)
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
.order_by(Event.date_start.nulls_last(), Event.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_events_for_person( async def list_events_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Event]: ) -> list[Event]:
+451
View File
@@ -0,0 +1,451 @@
"""GEDCOM import/export.
A pragmatic parser + mapper for the common subset of GEDCOM (5.5.1 / 7 share
the line grammar): INDI, FAM, SOUR. Import maps records into a tree and returns
a mapping report (counts + unmapped tags); export serializes the tree back to
GEDCOM. Runs inline for now — large files should move to the worker later.
"""
import re
import uuid
from collections import defaultdict
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ParentChildQualifier, RelationshipType
from app.models.event import Event
from app.models.person import Name, Person
from app.models.place import Place
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden
# GEDCOM event tag -> our event_type (INDI-level).
INDI_EVENTS = {
"BIRT": "birth", "DEAT": "death", "BAPM": "baptism", "CHR": "christening",
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
"NATU": "naturalization", "BAPL": "baptism",
}
# FAM-level events.
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
class GedcomNode:
__slots__ = ("level", "tag", "value", "xref", "children")
def __init__(self, level: int, tag: str, value: str = "", xref: str | None = None):
self.level = level
self.tag = tag
self.value = value
self.xref = xref
self.children: list[GedcomNode] = []
def first(self, tag: str) -> "GedcomNode | None":
return next((c for c in self.children if c.tag == tag), None)
def all(self, tag: str) -> list["GedcomNode"]:
return [c for c in self.children if c.tag == tag]
def text(self, tag: str, default: str | None = None) -> str | None:
n = self.first(tag)
return n.value if n is not None else default
def parse_records(text: str) -> list[GedcomNode]:
roots: list[GedcomNode] = []
stack: list[GedcomNode] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = raw.lstrip("").rstrip()
if not line.strip():
continue
parts = line.split(" ", 1)
try:
level = int(parts[0])
except ValueError:
continue
rest = parts[1] if len(parts) > 1 else ""
xref: str | None = None
if rest.startswith("@"):
end = rest.find("@", 1)
if end != -1:
xref = rest[: end + 1]
rest = rest[end + 1:].strip()
tparts = rest.split(" ", 1)
tag = tparts[0]
value = tparts[1] if len(tparts) > 1 else ""
while stack and stack[-1].level >= level:
stack.pop()
parent = stack[-1] if stack else None
if tag in ("CONC", "CONT") and parent is not None:
parent.value += ("" if tag == "CONC" else "\n") + value
continue
node = GedcomNode(level, tag, value, xref)
if parent is None:
roots.append(node)
else:
parent.children.append(node)
stack.append(node)
return roots
def _parse_name(value: str) -> tuple[str | None, str | None]:
if "/" in value:
given, _, rest = value.partition("/")
surname = rest.split("/", 1)[0]
return given.strip() or None, surname.strip() or None
return value.strip() or None, None
def _year(date_value: str | None) -> str | None:
if not date_value:
return None
m = re.search(r"\b(\d{3,4})\b", date_value)
return m.group(1) if m else None
def _date_start(date_value: str | None) -> date | None:
y = _year(date_value)
if not y:
return None
try:
return date(int(y), 1, 1)
except ValueError:
return None
def _sex(value: str | None) -> str | None:
if not value:
return None
v = value.strip().upper()
return {"M": "male", "F": "female"}.get(v, value.strip().lower() or None)
async def import_gedcom(
session: AsyncSession, *, actor: User, tree: Tree, text: str
) -> dict:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
roots = parse_records(text)
counts = defaultdict(int)
unmapped: set[str] = set()
place_cache: dict[str, uuid.UUID] = {}
source_map: dict[str, uuid.UUID] = {}
person_map: dict[str, uuid.UUID] = {}
async def place_id(name: str | None) -> uuid.UUID | None:
if not name:
return None
if name in place_cache:
return place_cache[name]
p = Place(tree_id=tree.id, name=name)
session.add(p)
await session.flush()
place_cache[name] = p.id
counts["places"] += 1
return p.id
# Sources first (so citations can reference them).
for rec in roots:
if rec.tag == "SOUR" and rec.xref:
src = Source(
tree_id=tree.id,
title=rec.text("TITL") or rec.text("ABBR") or "Untitled source",
author=rec.text("AUTH"),
publication_info=rec.text("PUBL"),
citation_text=rec.text("TEXT"),
)
session.add(src)
await session.flush()
source_map[rec.xref] = src.id
counts["sources"] += 1
async def add_citations(holder: GedcomNode, **target) -> None:
for s in holder.all("SOUR"):
sid = source_map.get(s.value.strip())
if sid is None:
continue
session.add(
Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target)
)
counts["citations"] += 1
# Individuals.
for rec in roots:
if rec.tag != "INDI" or not rec.xref:
continue
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")))
session.add(person)
await session.flush()
person_map[rec.xref] = person.id
counts["persons"] += 1
for i, nm in enumerate(rec.all("NAME")):
given, surname = _parse_name(nm.value)
session.add(
Name(
tree_id=tree.id,
person_id=person.id,
name_type="birth",
given=given,
surname=surname,
display_name=nm.value or None,
is_primary=(i == 0),
sort_order=i,
)
)
counts["names"] += 1
await add_citations(rec, person_id=person.id)
for child in rec.children:
if child.tag in INDI_EVENTS:
dv = child.text("DATE")
ev = Event(
tree_id=tree.id,
person_id=person.id,
event_type=INDI_EVENTS[child.tag],
date_value=dv,
date_start=_date_start(dv),
place_id=await place_id(child.text("PLAC")),
)
session.add(ev)
await session.flush()
counts["events"] += 1
await add_citations(child, event_id=ev.id)
elif child.tag in ("NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID"):
continue
else:
unmapped.add(child.tag)
# Families -> partnerships, parent-child edges, marriage events.
for rec in roots:
if rec.tag != "FAM":
continue
counts["families"] += 1
husb = person_map.get((rec.text("HUSB") or "").strip())
wife = person_map.get((rec.text("WIFE") or "").strip())
partnership_id: uuid.UUID | None = None
if husb and wife:
rel = Relationship(
tree_id=tree.id,
type=RelationshipType.partnership,
person_from_id=husb,
person_to_id=wife,
)
session.add(rel)
await session.flush()
partnership_id = rel.id
counts["relationships"] += 1
for fe in rec.children:
if fe.tag in FAM_EVENTS and partnership_id is not None:
dv = fe.text("DATE")
ev = Event(
tree_id=tree.id,
relationship_id=partnership_id,
event_type=FAM_EVENTS[fe.tag],
date_value=dv,
date_start=_date_start(dv),
place_id=await place_id(fe.text("PLAC")),
)
session.add(ev)
await session.flush()
counts["events"] += 1
for chil in rec.all("CHIL"):
cp = person_map.get(chil.value.strip())
if cp is None:
continue
for parent in (husb, wife):
if parent and parent != cp:
session.add(
Relationship(
tree_id=tree.id,
type=RelationshipType.parent_child,
person_from_id=parent,
person_to_id=cp,
qualifier=ParentChildQualifier.biological,
)
)
counts["relationships"] += 1
record_audit(
session,
action="import",
entity_type="Gedcom",
tree_id=tree.id,
actor_user_id=actor.id,
after=dict(counts),
)
await session.commit()
return {"counts": dict(counts), "unmapped_tags": sorted(unmapped)}
def _ged_date(value: str | None) -> str | None:
return value.strip() if value else None
async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> str:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
persons = list(
(
await session.execute(
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
)
).scalars().all()
)
names = list(
(
await session.execute(
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
)
).scalars().all()
)
events = list(
(
await session.execute(
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
)
).scalars().all()
)
rels = list(
(
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
)
)
).scalars().all()
)
sources = list(
(
await session.execute(
select(Source).where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
)
).scalars().all()
)
places = {
p.id: p
for p in (
await session.execute(select(Place).where(Place.tree_id == tree.id))
).scalars().all()
}
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
gender_by_id = {p.id: p.gender for p in persons}
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
names_by_person[n.person_id].append(n)
events_by_person: dict[uuid.UUID, list[Event]] = defaultdict(list)
events_by_rel: dict[uuid.UUID, list[Event]] = defaultdict(list)
for e in events:
if e.person_id:
events_by_person[e.person_id].append(e)
elif e.relationship_id:
events_by_rel[e.relationship_id].append(e)
# Build families from parent-child + partnership edges (group by parent set).
parents_of: dict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
for r in rels:
if r.type == RelationshipType.parent_child:
parents_of[r.person_to_id].add(r.person_from_id)
fams: dict[frozenset, dict] = {}
for child, ps in parents_of.items():
key = frozenset(ps)
fams.setdefault(key, {"parents": set(ps), "children": [], "rel_id": None})
fams[key]["children"].append(child)
for r in rels:
if r.type == RelationshipType.partnership:
key = frozenset({r.person_from_id, r.person_to_id})
fam = fams.setdefault(
key,
{"parents": {r.person_from_id, r.person_to_id}, "children": [], "rel_id": None},
)
fam["rel_id"] = r.id
fam_list = list(fams.values())
fxref = {id(f): f"@F{i + 1}@" for i, f in enumerate(fam_list)}
# person -> the families they are a spouse in / a child in
spouse_fams: dict[uuid.UUID, list[str]] = defaultdict(list)
child_fams: dict[uuid.UUID, str] = {}
for f in fam_list:
x = fxref[id(f)]
for pid in f["parents"]:
spouse_fams[pid].append(x)
for cid in f["children"]:
child_fams[cid] = x
out: list[str] = ["0 HEAD", "1 SOUR Provenance", "1 GEDC", "2 VERS 5.5.1", "1 CHAR UTF-8"]
for p in persons:
out.append(f"0 {pxref[p.id]} INDI")
for n in names_by_person.get(p.id, []):
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
out.append(f"1 NAME {display}")
sex = {"male": "M", "female": "F"}.get(p.gender or "")
if sex:
out.append(f"1 SEX {sex}")
for e in events_by_person.get(p.id, []):
tag = EVENT_TO_GED.get(e.event_type)
if not tag:
continue
out.append(f"1 {tag}")
if _ged_date(e.date_value):
out.append(f"2 DATE {e.date_value}")
if e.place_id and e.place_id in places:
out.append(f"2 PLAC {places[e.place_id].name}")
if p.id in child_fams:
out.append(f"1 FAMC {child_fams[p.id]}")
for x in spouse_fams.get(p.id, []):
out.append(f"1 FAMS {x}")
for f in fam_list:
x = fxref[id(f)]
out.append(f"0 {x} FAM")
ps = list(f["parents"])
# HUSB/WIFE by recorded gender where possible.
males = [pid for pid in ps if gender_by_id.get(pid) == "male"]
females = [pid for pid in ps if gender_by_id.get(pid) == "female"]
husb = males[0] if males else (ps[0] if ps else None)
wife = females[0] if females else next((pid for pid in ps if pid != husb), None)
if husb:
out.append(f"1 HUSB {pxref[husb]}")
if wife:
out.append(f"1 WIFE {pxref[wife]}")
for cid in f["children"]:
out.append(f"1 CHIL {pxref[cid]}")
if f["rel_id"]:
for e in events_by_rel.get(f["rel_id"], []):
tag = EVENT_TO_GED.get(e.event_type)
if not tag:
continue
out.append(f"1 {tag}")
if _ged_date(e.date_value):
out.append(f"2 DATE {e.date_value}")
for s in sources:
out.append(f"0 {sxref[s.id]} SOUR")
if s.title:
out.append(f"1 TITL {s.title}")
if s.author:
out.append(f"1 AUTH {s.author}")
if s.publication_info:
out.append(f"1 PUBL {s.publication_info}")
out.append("0 TRLR")
return "\n".join(out) + "\n"
+145 -9
View File
@@ -4,8 +4,9 @@ person through the privacy engine. Each returned Person gets a transient
""" """
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import PersonPrivacy from app.models.enums import PersonPrivacy
@@ -24,6 +25,14 @@ def _format_name(name: Name) -> str | None:
return joined or name.display_name return joined or name.display_name
def _redact(person: Person) -> None:
"""Minimise a possibly-living person for a non-member view (transient only —
never committed)."""
person.primary_name = "Living person"
person.gender = None
person.is_living = True
async def _attach_primary_name(session: AsyncSession, person: Person) -> None: async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
stmt = ( stmt = (
select(Name) select(Name)
@@ -103,15 +112,89 @@ async def get_person(
if person is None: if person is None:
raise NotFound("person not found") raise NotFound("person not found")
# Run the single person through the privacy engine (redaction lands Phase 2). # Run the single person through the privacy engine (redaction lands Phase 2).
if ( vis = await privacy.person_visibility(
await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person) session, user_id=viewer_id, tree=tree, person=person
== Visibility.hidden )
): if vis == Visibility.hidden:
raise NotFound("person not found") raise NotFound("person not found")
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person) await _attach_primary_name(session, person)
return person return person
async def delete_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
person.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
async def restore_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("deleted person not found")
person.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def list_deleted_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Person)
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
.order_by(Person.deleted_at.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
for person in persons:
await _attach_primary_name(session, person)
return persons
async def list_persons( async def list_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]: ) -> list[Person]:
@@ -127,13 +210,66 @@ async def list_persons(
visible: list[Person] = [] visible: list[Person] = []
for person in persons: for person in persons:
if ( vis = await privacy.person_visibility(
await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person session, user_id=viewer_id, tree=tree, person=person
) )
== Visibility.hidden if vis == Visibility.hidden:
):
continue continue
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person) await _attach_primary_name(session, person)
visible.append(person) visible.append(person)
return visible return visible
async def search_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
q = query.strip()
if not q:
return []
like = f"%{q}%"
score = func.greatest(
func.similarity(func.coalesce(Name.given, ""), q),
func.similarity(func.coalesce(Name.surname, ""), q),
)
sub = (
select(Name.person_id.label("pid"), func.max(score).label("score"))
.where(
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
or_(
Name.given.op("%")(q),
Name.surname.op("%")(q),
Name.given.ilike(like),
Name.surname.ilike(like),
),
)
.group_by(Name.person_id)
.order_by(func.max(score).desc())
.limit(limit)
.subquery()
)
stmt = (
select(Person)
.join(sub, sub.c.pid == Person.id)
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
.order_by(sub.c.score.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
out: list[Person] = []
for person in persons:
vis = await privacy.person_visibility(
session, user_id=viewer_id, tree=tree, person=person
)
if vis == Visibility.hidden:
continue
if vis == Visibility.redacted:
_redact(person)
else:
await _attach_primary_name(session, person)
out.append(person)
return out
+49 -2
View File
@@ -8,14 +8,20 @@ tree's visibility, the per-person override, and (Phase 2) living-person status.
import enum import enum
import uuid import uuid
from datetime import UTC, datetime
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 MembershipRole, PersonPrivacy, TreeVisibility from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
from app.models.event import Event
from app.models.person import Person from app.models.person import Person
from app.models.tree import Tree, TreeMembership from app.models.tree import Tree, TreeMembership
# A person with no death fact whose birth is within this window (or unknown) is
# treated as possibly living and redacted from non-members (ARCHITECTURE §6).
LIVING_RECENCY_YEARS = 100
class Visibility(enum.StrEnum): class Visibility(enum.StrEnum):
full = "full" full = "full"
@@ -48,15 +54,56 @@ async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre
return role in (MembershipRole.owner, MembershipRole.editor) return role in (MembershipRole.owner, MembershipRole.editor)
async def is_possibly_living(session: AsyncSession, person: Person) -> bool:
"""True if the person should be treated as living: explicit flag, or (absent
a death fact) a birth within the recency window or an unknown birth."""
if person.is_living is True:
return True
if person.is_living is False:
return False
death = (
await session.execute(
select(Event.id)
.where(
Event.person_id == person.id,
Event.event_type == "death",
Event.deleted_at.is_(None),
)
.limit(1)
)
).scalar_one_or_none()
if death is not None:
return False
birth = (
await session.execute(
select(Event.date_start)
.where(
Event.person_id == person.id,
Event.event_type == "birth",
Event.date_start.is_not(None),
Event.deleted_at.is_(None),
)
.order_by(Event.date_start)
.limit(1)
)
).scalar_one_or_none()
if birth is None:
return True # unknown birth → treat as possibly living
return (datetime.now(UTC).year - birth.year) < LIVING_RECENCY_YEARS
async def person_visibility( async def person_visibility(
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
) -> Visibility: ) -> Visibility:
if not await can_view_tree(session, user_id=user_id, tree=tree): if not await can_view_tree(session, user_id=user_id, tree=tree):
return Visibility.hidden return Visibility.hidden
if await get_membership_role(session, user_id, tree.id) is not None: if await get_membership_role(session, user_id, tree.id) is not None:
return Visibility.full return Visibility.full # members see everyone in their tree
# Non-member viewing a public/unlisted tree: # Non-member viewing a public/unlisted tree:
if person.privacy == PersonPrivacy.private: if person.privacy == PersonPrivacy.private:
return Visibility.hidden return Visibility.hidden
# TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6). if person.privacy == PersonPrivacy.public:
return Visibility.full # explicit per-person opt-in
if await is_possibly_living(session, person):
return Visibility.redacted # living people are protected by default
return Visibility.full return Visibility.full
@@ -73,6 +73,20 @@ async def create_relationship(
return relationship return relationship
async def list_relationships(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Relationship]:
"""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):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Relationship)
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
.order_by(Relationship.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_relationships_for_person( async def list_relationships_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Relationship]: ) -> list[Relationship]:
+53
View File
@@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
""" """
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -59,3 +60,55 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
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")
return tree return tree
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
"""Load a tree (including soft-deleted) and require the actor be its owner."""
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
if tree is None:
raise NotFound("tree not found")
role = await privacy.get_membership_role(session, actor.id, tree.id)
if role is not MembershipRole.owner:
raise Forbidden("only the owner can delete or restore a tree")
return tree
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is None:
tree.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is not None:
tree.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
return tree
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
stmt = (
select(Tree)
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None))
.order_by(Tree.deleted_at.desc())
)
return list((await session.execute(stmt)).scalars().all())
@@ -0,0 +1,33 @@
"""pg_trgm extension + trigram name indexes for fuzzy search
Revision ID: 9a2b1c7d4e10
Revises: 7fc7024ef432
Create Date: 2026-06-07
"""
from collections.abc import Sequence
from alembic import op
revision: str = "9a2b1c7d4e10"
down_revision: str | None = "7fc7024ef432"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_names_given_trgm "
"ON names USING gin (given gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_names_surname_trgm "
"ON names USING gin (surname gin_trgm_ops)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_names_surname_trgm")
op.execute("DROP INDEX IF EXISTS ix_names_given_trgm")
# Leave the pg_trgm extension in place; other features may rely on it.
+2
View File
@@ -11,6 +11,7 @@ import os
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — register all models on Base.metadata import app.models # noqa: F401 — register all models on Base.metadata
@@ -72,6 +73,7 @@ async def client():
engine = create_async_engine(TEST_DATABASE_URL) engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
+77
View File
@@ -0,0 +1,77 @@
"""GEDCOM import + export round-trip."""
from tests.conftest import auth, register
SAMPLE = b"""0 HEAD
1 CHAR UTF-8
0 @I1@ INDI
1 NAME John /Smith/
1 SEX M
1 BIRT
2 DATE 1850
2 PLAC Boston, Massachusetts
0 @I2@ INDI
1 NAME Mary /Jones/
1 SEX F
0 @I3@ INDI
1 NAME Junior /Smith/
1 BIRT
2 DATE 1872
0 @F1@ FAM
1 HUSB @I1@
1 WIFE @I2@
1 CHIL @I3@
1 MARR
2 DATE 1870
0 TRLR
"""
async def _tree(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "Imported"}, headers=h)).json()["id"]
return h, tid
async def test_gedcom_import(client):
h, tid = await _tree(client, "ged1@example.com")
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("sample.ged", SAMPLE, "text/plain")},
headers=h,
)
assert resp.status_code == 200, resp.text
counts = resp.json()["counts"]
assert counts["persons"] == 3
assert counts["families"] == 1
# partnership (1) + parent_child from both parents to the child (2)
assert counts["relationships"] == 3
assert counts["events"] == 3 # 2 births + 1 marriage
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert len(people) == 3
rels = (await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)).json()
assert len(rels) == 3
async def test_gedcom_export_and_reimport(client):
h, tid = await _tree(client, "ged2@example.com")
await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("sample.ged", SAMPLE, "text/plain")},
headers=h,
)
exported = await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)
assert exported.status_code == 200
text = exported.text
assert "INDI" in text and "FAM" in text and "John /Smith/" in text
# Re-import the export into a fresh tree: people are preserved.
tid2 = (await client.post("/api/v1/trees", json={"name": "Round"}, headers=h)).json()["id"]
resp = await client.post(
f"/api/v1/trees/{tid2}/gedcom/import",
files={"file": ("rt.ged", text.encode(), "text/plain")},
headers=h,
)
assert resp.json()["counts"]["persons"] == 3
assert resp.json()["counts"]["relationships"] == 3
+36
View File
@@ -0,0 +1,36 @@
"""Living-person protection: living people are redacted from non-members."""
from tests.conftest import auth, register
async def test_living_person_redacted_for_non_members(client):
owner = auth(await register(client, "pub-owner@example.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=owner
)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Old", "surname": "Ancestor", "is_living": False},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": "Young", "surname": "Living", "is_living": True},
headers=owner,
)
other = auth(await register(client, "pub-viewer@example.com"))
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=other)).json()
names = {p["primary_name"] for p in people}
assert "Old Ancestor" in names # deceased is visible
assert "Living person" in names # living is redacted
assert "Young Living" not in names # the real living name is hidden
# The redacted person leaks no gender.
living = next(p for p in people if p["primary_name"] == "Living person")
assert living["gender"] is None
# The owner (a member) sees real names.
owner_people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=owner)).json()
assert "Young Living" in {p["primary_name"] for p in owner_people}
+54
View File
@@ -0,0 +1,54 @@
"""Soft-delete + recovery for trees and people."""
from tests.conftest import auth, register
async def test_tree_delete_and_restore(client):
h = auth(await register(client, "rec1@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
# Delete -> gone from active lists, present in the recovery list.
assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0
# A soft-deleted tree is no longer visible (404 to the would-be viewer).
gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h)
assert gone.status_code == 404
deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json()
assert len(deleted) == 1 and deleted[0]["id"] == tree_id
# Restore -> back in active lists.
assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1
assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200
async def test_only_owner_can_delete_tree(client):
owner = auth(await register(client, "rec-owner@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
other = auth(await register(client, "rec-other@example.com"))
blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other)
assert blocked.status_code in (403, 404)
async def test_person_delete_and_restore(client):
h = auth(await register(client, "rec2@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
person_id = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h
)
).json()["id"]
assert (
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
).status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
deleted = (
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
).json()
assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada"
assert (
await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h)
).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1
+24
View File
@@ -0,0 +1,24 @@
"""Fuzzy name search (pg_trgm)."""
from tests.conftest import auth, register
async def test_fuzzy_name_search(client):
h = auth(await register(client, "search@example.com"))
tid = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
for given, surname in [("Hans", "Mueller"), ("John", "Smith"), ("Anna", "Vogel")]:
await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": given, "surname": surname},
headers=h,
)
# Trigram fuzziness: "muller" should find "Mueller" (not a substring match).
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "muller"}, headers=h)
assert r.status_code == 200
names = [p["primary_name"] or "" for p in r.json()]
assert any("Mueller" in n for n in names)
# Substring search still works.
r2 = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "smi"}, headers=h)
assert any("Smith" in (p["primary_name"] or "") for p in r2.json())
+52
View File
@@ -54,3 +54,55 @@ h3,
::selection { ::selection {
background: color-mix(in srgb, var(--color-bronze) 22%, transparent); background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
} }
/* Pedigree bracket connectors (ancestors grow rightward). Each leaf draws its
own half of the vertical spine + a horizontal stub, so lines stay correct
regardless of box heights: focus → 2 parents, each parent → 2 grandparents. */
.ped-person {
display: flex;
align-items: center;
}
.ped-self {
flex-shrink: 0;
}
.ped-branch {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-left: 2.5rem;
}
.ped-branch::before {
content: "";
position: absolute;
left: -2.5rem;
top: 50%;
width: 2.5rem;
border-top: 1px solid var(--border);
}
.ped-leaf {
position: relative;
padding-left: 1.5rem;
}
.ped-leaf::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 1.5rem;
border-top: 1px solid var(--border);
}
.ped-leaf::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
}
.ped-leaf:first-child::after {
top: 50%;
}
.ped-leaf:last-child::after {
bottom: 50%;
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
export default function GedcomPage() {
const params = useParams<{ id: string }>();
const treeId = params.id;
const [target, setTarget] = useState<"new" | "this">("new");
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const [report, setReport] = useState<Report | null>(null);
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
setReport(null);
setImportedTreeId(null);
let tid = treeId;
if (target === "new") {
const { data } = await api.POST("/api/v1/trees", {
body: { name: newName.trim() || "Imported tree" },
});
if (!data) {
setBusy(false);
return;
}
tid = data.id;
setImportedTreeId(tid);
} else {
setImportedTreeId(treeId);
}
const fd = new FormData();
fd.append("file", file);
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) setReport(await resp.json());
setBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
async function exportGed() {
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, {
credentials: "include",
});
if (!resp.ok) return;
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "tree.ged";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Import &amp; export GEDCOM</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="target"
checked={target === "new"}
onChange={() => setTarget("new")}
/>
Import into a <strong>new tree</strong> (recommended)
</label>
{target === "new" && (
<Input
className="max-w-xs"
placeholder="New tree name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="target"
checked={target === "this"}
onChange={() => setTarget("this")}
/>
Import into <strong>this tree</strong> (appends)
</label>
{target === "this" && (
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
Importing appends everyone in the file as new records it does not merge with
people already in this tree, so duplicates are possible.
</p>
)}
<input ref={fileRef} type="file" accept=".ged,.gedcom,text/plain" onChange={onFile} className="hidden" />
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
{busy ? "Importing…" : "Choose GEDCOM file"}
</Button>
{report && (
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<div className="font-medium">Import complete</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
{Object.entries(report.counts).map(([k, v]) => (
<span key={k}>
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
</span>
))}
</div>
{report.unmapped_tags.length > 0 && (
<div className="text-xs text-[var(--muted)]">
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
</div>
)}
{importedTreeId && (
<Link
href={`/trees/${importedTreeId}`}
className="inline-block text-sm text-bronze hover:underline"
>
Open the imported tree
</Link>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Export this tree</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Download this tree as a GEDCOM file people, relationships, events, and sources.
</p>
<Button variant="outline" onClick={exportGed}>
Download .ged
</Button>
</CardContent>
</Card>
</div>
);
}
+315 -43
View File
@@ -2,35 +2,62 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, 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 { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
export default function TreeDetailPage() { function splitName(full: string): { given: string | null; surname: string | null } {
const t = full.trim().split(/\s+/).filter(Boolean);
if (t.length === 0) return { given: null, surname: null };
if (t.length === 1) return { given: t[0], surname: null };
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
}
type AddKind = "parent" | "child" | "partner";
export default function FamilyViewPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const treeId = params.id; const treeId = params.id;
const [persons, setPersons] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [given, setGiven] = useState(""); const [rels, setRels] = useState<Relationship[]>([]);
const [surname, setSurname] = useState(""); const [events, setEvents] = useState<Event[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name.
// `key` keeps each empty slot's inline form independent (a person has 2
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
const [addName, setAddName] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
}); });
if (response.status === 401) { if (p.response.status === 401) {
router.push("/login"); router.push("/login");
return; return;
} }
setPersons(data ?? []); const [r, e] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
]);
const ppl = p.data ?? [];
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
setReady(true); setReady(true);
}, [router, treeId]); }, [router, treeId]);
@@ -38,61 +65,306 @@ export default function TreeDetailPage() {
load(); load();
}, [load]); }, [load]);
async function addPerson(e: React.FormEvent) { const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
e.preventDefault(); const parentsOf = (id: string) =>
if (!given.trim() && !surname.trim()) return; rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", { const childrenOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
const partnersOf = (id: string) =>
rels
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
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]);
async function addPerson(name: string): Promise<string | null> {
const { given, surname } = splitName(name);
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body: { given: given || null, surname: surname || null }, body: { given, surname },
}); });
if (!error) { return data?.id ?? null;
setGiven(""); }
setSurname("");
async function createFirst(e: React.FormEvent) {
e.preventDefault();
if (!firstName.trim()) return;
const id = await addPerson(firstName);
setFirstName("");
if (id) setFocusId(id);
load(); load();
} }
async function submitAdd(e: React.FormEvent) {
e.preventDefault();
if (!adding || !addName.trim()) return;
const newId = await addPerson(addName);
if (newId) {
const { kind, anchor } = adding;
const body =
kind === "parent"
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
: kind === "child"
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
setAdding(null);
setAddName("");
load();
} }
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (people.length === 0) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-semibold">People</h1> <h1 className="text-2xl font-semibold">Start your tree</h1>
<Card> <Card>
<CardHeader> <CardContent className="p-6">
<CardTitle className="text-base">Add a person</CardTitle> <form onSubmit={createFirst} className="flex flex-wrap gap-2">
</CardHeader> <Input
<CardContent> className="w-64"
<form onSubmit={addPerson} className="flex gap-2"> placeholder="First person's full name"
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} /> value={firstName}
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} /> onChange={(e) => setFirstName(e.target.value)}
<Button type="submit">Add</Button> />
<Button type="submit">Add person</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div>
);
}
<div> const focus = focusId ? byId.get(focusId) : undefined;
<h2 className="mb-2 text-lg font-semibold">People</h2> if (!focus) {
{persons.length === 0 ? ( setFocusId(people[0].id);
<p className="text-[var(--muted)]">No people yet.</p> return null;
}
const PersonBox = ({
id,
muted,
}: {
id: string;
muted?: boolean;
}) => {
const p = byId.get(id);
if (!p) return null;
const isFocus = id === focusId;
return (
<button
onClick={() => setFocusId(id)}
className={`w-44 rounded-lg border px-3 py-2 text-left transition-colors ${
isFocus
? "border-bronze bg-bronze/[0.08]"
: "border-[var(--border)] bg-[var(--surface)] hover:border-bronze/60"
} ${muted ? "opacity-90" : ""}`}
>
<div className="truncate text-sm font-medium">{p.primary_name ?? "Unnamed"}</div>
<div className="text-xs text-[var(--muted)]">{years.get(id) ?? "—"}</div>
</button>
);
};
const AddSlot = ({
formKey,
kind,
anchor,
label,
}: {
formKey: string;
kind: AddKind;
anchor: string;
label: string;
}) =>
adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1">
<Input
autoFocus
className="h-9"
placeholder="Full name"
value={addName}
onChange={(e) => setAddName(e.target.value)}
/>
<div className="flex gap-1">
<Button type="submit" size="sm">
Add
</Button>
<button
type="button"
onClick={() => setAdding(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</div>
</form>
) : ( ) : (
<ul className="space-y-2"> <button
{persons.map((person) => ( onClick={() => {
<li key={person.id}> setAdding({ key: formKey, kind, anchor });
<Link href={`/trees/${treeId}/persons/${person.id}`}> setAddName("");
<Card className="transition-colors hover:border-bronze/50"> }}
<CardContent className="p-4"> className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
{person.primary_name ?? ( >
<span className="text-[var(--muted)]">Unnamed</span> + {label}
)} </button>
);
// Recursive ancestor chart (grows rightward): a node is its box plus a
// two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 =
// focus, capped at grandparents (depth 2).
const renderNode = (
slotPersonId: string | null,
childId: string,
keyPrefix: string,
depth: number,
): React.ReactNode => {
const box = slotPersonId ? (
<PersonBox id={slotPersonId} muted={depth > 0} />
) : (
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
);
if (!slotPersonId || depth >= 2) {
return <div className="ped-person">{box}</div>;
}
const ps = parentsOf(slotPersonId);
return (
<div className="ped-person">
<div className="ped-self">{box}</div>
<div className="ped-branch">
<div className="ped-leaf">
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
</div>
<div className="ped-leaf">
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
</div>
</div>
</div>
);
};
const partners = partnersOf(focus.id);
const children = childrenOf(focus.id);
const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
);
const matches = search
? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(search.toLowerCase()))
: sorted;
const shown = matches.slice(0, 200); // cap DOM nodes; refine search to narrow
return (
<div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Family view</h1>
<Link
href={`/trees/${treeId}/persons/${focus.id}`}
className="text-sm text-bronze hover:underline"
>
Open {focus.primary_name ?? "person"}
</Link>
</div>
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
<Card>
<CardContent className="overflow-x-auto p-6">
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
</CardContent> </CardContent>
</Card> </Card>
</Link>
</li> {/* Family group: partners + children of the focus */}
<div className="grid gap-5 sm:grid-cols-2">
<Card>
<CardContent className="space-y-3 p-6">
<h2 className="font-serif text-base font-semibold">Spouses &amp; partners</h2>
<div className="flex flex-wrap gap-3">
{partners.map((id) => (
<PersonBox key={id} id={id} muted />
))} ))}
</ul> <AddSlot
formKey={`partner-${focus.id}`}
kind="partner"
anchor={focus.id}
label="add spouse"
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3 p-6">
<h2 className="font-serif text-base font-semibold">Children</h2>
<div className="flex flex-wrap gap-3">
{children.map((id) => (
<PersonBox key={id} id={id} muted />
))}
<AddSlot
formKey={`child-${focus.id}`}
kind="child"
anchor={focus.id}
label="add child"
/>
</div>
</CardContent>
</Card>
</div>
{/* Scrollable, searchable people directory (scales to large trees) */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="font-serif text-base font-semibold">People ({people.length})</h2>
<Input
className="w-64"
placeholder="Search by name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Card className="overflow-hidden">
<div className="max-h-96 overflow-y-auto">
{shown.length === 0 ? (
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
) : (
shown.map((p, i) => (
<button
key={p.id}
onClick={() => setFocusId(p.id)}
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
i > 0 ? "border-t border-[var(--border)]" : ""
} ${p.id === focusId ? "bg-bronze/[0.08]" : "hover:bg-bronze/[0.05]"}`}
>
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">
{years.get(p.id) ?? ""}
</span>
</button>
))
)} )}
</div> </div>
{matches.length > shown.length && (
<div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]">
Showing {shown.length} of {matches.length} refine your search to narrow.
</div>
)}
</Card>
</div>
</div> </div>
); );
} }
@@ -206,6 +206,13 @@ export default function PersonDetailPage() {
load(); load();
} }
async function removePerson() {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
});
router.push(`/trees/${treeId}`);
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>; if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
@@ -311,7 +318,12 @@ export default function PersonDetailPage() {
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1> <h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)} {citeControl("p", { person_id: personId }, personCites)}
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
</div>
</div> </div>
<Card> <Card>
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Person = components["schemas"]["PersonRead"];
export default function RecoveryPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [people, setPeople] = useState<Person[]>([]);
const [ready, setReady] = useState(false);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { deleted: true } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setPeople(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/restore", {
params: { path: { tree_id: treeId, person_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Recently deleted</h1>
<p className="text-sm text-[var(--muted)]">
Deleted people are recoverable for 30 days, then permanently purged.
</p>
{people.length === 0 ? (
<p className="text-[var(--muted)]">Nothing here.</p>
) : (
<ul className="space-y-2">
{people.map((p) => (
<li key={p.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{p.primary_name ?? "Unnamed"}</span>
<Button variant="outline" size="sm" onClick={() => restore(p.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
+880
View File
@@ -0,0 +1,880 @@
.f3 {
--female-color: rgb(196, 138, 146);
--male-color: rgb(120, 159, 172);
--genderless-color: lightgray;
--background-color: rgb(33, 33, 33);
--text-color: #fff;
font-family: 'Roboto', sans-serif;
}
.f3 * {
box-sizing: border-box;
}
.f3 .cursor-pointer {
cursor: pointer;
}
.f3 svg.main_svg {
width: 100%;
height: 100%;
}
.f3 svg.main_svg text {
fill: currentColor;
}
.f3 rect.card-female, .f3 .card-female .card-body-rect, .f3 .card-female .text-overflow-mask {
fill: var(--female-color);
}
.f3 rect.card-male, .f3 .card-male .card-body-rect, .f3 .card-male .text-overflow-mask {
fill: var(--male-color);
}
.f3 .card-genderless .card-body-rect, .f3 .card-genderless .text-overflow-mask {
fill: var(--genderless-color);
}
.f3 .card_add .card-body-rect {
fill: #3b5560;
stroke-width: 4px;
stroke: #fff;
cursor: pointer;
}
.f3 g.card_add text {
fill: #fff;
}
.f3 .card-main-outline {
stroke: currentColor;
stroke-width: 3px;
}
.f3 .card_family_tree rect {
transition: 0.3s;
}
.f3 .card_family_tree:hover rect {
transform: scale(1.1);
}
.f3 .card_add_relative {
cursor: pointer;
color: #fff;
transition: 0.3s;
}
.f3 .card_add_relative circle {
fill: rgba(0, 0, 0, 0);
}
.f3 .card_add_relative:hover {
color: black;
}
.f3 .card_edit.pencil_icon {
color: #fff;
transition: 0.3s;
}
.f3 .card_edit.pencil_icon:hover {
color: black;
}
.f3 .card_break_link, .f3 .link_upper, .f3 .link_lower, .f3 .link_particles {
transform-origin: 50% 50%;
transition: 1s;
}
.f3 .card_break_link {
color: #fff;
}
.f3 .card_break_link.closed .link_upper {
transform: translate(-140.5px, 655.6px);
}
.f3 .card_break_link.closed .link_upper g {
transform: rotate(-58deg);
}
.f3 .card_break_link.closed .link_particles {
transform: scale(0);
}
.f3 .input-field input {
height: 2.5rem !important;
}
.f3 .input-field > label:not(.label-icon).active {
-webkit-transform: translateY(-8px) scale(0.8);
transform: translateY(-8px) scale(0.8);
}
.f3.f3-cont {
width:100%;
height:900px;
max-height:70vh;
background-color: var(--background-color);
color: var(--text-color);
}
.f3 {
position: relative;
display: flex;
}
/* form-info */
.f3-form input[type="text"],
.f3-form textarea,
.f3-form select {
width: 100%;
padding: 8px 12px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
background: var(--background-color);
color: currentColor;
}
.f3-form input[type="text"]:focus,
.f3-form textarea:focus,
.f3-form select:focus {
box-shadow: 0 0 5px rgba(76, 175, 80, 0.2);
}
.f3-form button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin: 10px 0;
transition: background-color 0.3s ease-in-out, border-color 0.3s ease-in-out, color 0.3s ease-in-out;
}
.f3-form button[type="submit"] {
background-color: #4CAF50;
color: white;
}
.f3-cancel-btn {
background-color: #ccc;
}
.f3-form .f3-delete-btn {
background-color: transparent;
border: 1px solid #f44336;
color: #f44336;
width: 100%;
padding: 5px 10px;
}
.f3-delete-btn:hover {
background-color: #da190b;
border-color: #da190b;
color: #fff;
}
.f3-delete-btn:disabled {
opacity: 0.5;
background-color: transparent;
color: #f44336;
cursor: not-allowed;
}
.f3-form .f3-remove-relative-btn {
background-color: transparent;
border: 1px solid currentColor;
color: currentColor;
width: 100%;
padding: 5px 10px;
}
.f3-remove-relative-btn:hover, .f3-remove-relative-btn.active {
background-color: var(--text-color);
border-color: var(--text-color);
color: var(--background-color);
}
.f3-radio-group {
margin: 15px 0;
}
.f3-radio-group label {
margin-right: 15px;
cursor: pointer;
}
.f3-radio-group input[type="radio"] {
margin-right: 5px;
}
.f3-info-field-label, .f3-form-field label {
font-weight: bold;
font-size: 12px;
display: block;
opacity: 0.8;
}
.f3-info-field-value {
font-weight: normal;
display: block;
border: none;
outline: none;
border-bottom: 1px solid rgba(255,255,255,0.2);
padding-bottom: 1px;
margin-bottom: 10px;
min-height: 18px;
}
.f3-form-buttons {
text-align: right;
}
.f3-form-title {
text-align: center;
}
.f3-form.non-editable .f3-form-buttons,
.f3-form.non-editable .f3-delete-btn,
.f3-form.non-editable .f3-remove-relative-btn,
.f3-form.non-editable .f3-link-existing-relative {
display: none;
}
.f3-close-btn {
cursor: pointer;
position: absolute;
left: 10px;
top: 8px;
font-size: 30px;
color: var(--text-color);
}
.f3-edit-btn {
position: relative;
top: -1px;
width: 24px;
height: 24px;
cursor: pointer;
display: inline-block;
}
.f3-add-relative-btn {
cursor: pointer;
width: 27px;
height: 27px;
margin-right: 5px;
display: inline-block;
}
/* card-html */
.f3 div.card {
cursor: pointer;
color: var(--text-color);
position: relative;
line-height: 1.2;
}
.f3 div.card-image-circle {
border-radius: 50%;
padding: 5px;
width: 90px;
height: 90px;
}
.f3 div.card-image-circle div.card-label {
position: absolute;
bottom: -10px;
left: 50%;
transform: translate(-50%, 50%);
max-width: 150%;
min-height: 22px;
text-align: center;
background-color: rgba(0, 0, 0, 0.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 3px;
padding: 0 5px;
}
.f3 div.card-image-circle img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-image-circle svg {
width: 100%;
height: 100%;
padding: 5px;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-image-circle img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.f3 div.card-rect {
padding: 5px;
border-radius: 3px;
width: 120px;
min-height: 70px;
overflow: hidden;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
}
.f3 div.card-image-rect {
width: 200px;
min-height: 70px;
display: flex;
align-items: center;
border-radius: 5px;
}
.f3 div.card-image-rect .person-icon {
height: 70px;
width: 70px;
object-fit: cover;
flex: 0 0 auto;
padding: 5px;
margin-right: 10px;
}
.f3 div.card-image-rect img {
height: 70px;
width: 70px;
object-fit: cover;
flex: 0 0 auto;
padding: 5px;
margin-right: 10px;
border-radius: 8px;
}
.f3 div.card-image-rect svg {
object-fit: cover;
width: 100%;
height: 100%;
padding: 5px;
border-radius: 7px;
}
.f3 div.card-image-rect div.card-label {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
}
.f3 div.mini-tree {
text-align: right;
position: absolute;
top: -15px;
right: -2px;
z-index: -1;
}
.f3 div.mini-tree svg {
width: 55px;
}
.f3 .f3-card-duplicate-tag {
position: absolute;
top: 2px;
right: 2px;
color: rgb(255, 251, 220);
background-color: rgba(255, 251, 220, 0);
border-radius: 50%;
padding: 2px;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
}
.f3 .f3-card-duplicate-hover div.card-inner {
transform: translate(0, -2px);
outline: 4px solid rgb(255, 251, 220);
}
.f3 .f3-card-duplicate-hover .f3-card-duplicate-tag {
background-color: rgba(255, 251, 220, .8);
color: #000;
}
.f3 .f3-remove-relative-active .card {
background-color: var(--background-color);
}
.f3 .f3-remove-relative-active .card-inner {
transition: border 0.2s ease-in-out, opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
opacity: .75;
}
.f3 .f3-remove-relative-active .card:hover .card-inner {
opacity: .25;
}
.f3 .f3-remove-relative-active .card-male.card-depth--1:hover .card-inner {
transform: translate(-8px, -8px);
}
.f3 .f3-remove-relative-active .card.card-female.card-depth--1:hover .card-inner {
transform: translate(8px, -8px);
}
.f3 .f3-remove-relative-active .card.card-female.card-depth-0:hover .card-inner {
transform: translate(8px, 0);
}
.f3 .f3-remove-relative-active .card.card-male.card-depth-0:hover .card-inner {
transform: translate(-8px, 0);
}
.f3 .f3-remove-relative-active .card.card-depth-1:hover .card-inner {
transform: translate(0, 8px);
}
.f3 .f3-remove-relative-active .card.card-main .card-inner {
transform: translate(0, 0)!important;
opacity: 1!important;
}
.f3 div.card > div {
transition: transform 0.2s ease-in-out;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.8);
}
.f3 .card-inner {
outline: 0px solid rgba(255, 255, 255, 1);
transition: outline 0.5s ease-in-out;
}
.f3 div.card-female .card-inner, .f3 div.card-female .person-icon svg {
background-color: var(--female-color);
}
.f3 div.card-male .card-inner, .f3 div.card-male .person-icon svg {
background-color: var(--male-color);
}
.f3 div.card-genderless .card-inner, .f3 div.card-genderless .person-icon svg {
background-color: var(--genderless-color);
}
.f3 div.card-new-rel .card-inner, .f3 div.card-new-rel .person-icon svg {
background-color: var(--background-color);
}
.f3 div.card-to-add .card-inner {
background-color: var(--background-color);
border: 1px solid;
}
.f3 div.card-to-add .card-inner .card-label {
margin: 0 auto;
}
.f3 div.card-to-add .person-icon {
display: none;
}
.f3 div.card-new-rel .card-inner {
border-width: 1px;
border-style: dashed;
outline: 0px !important;
}
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-to-add.card-female .card-inner {
border-color: var(--female-color);
color: var(--female-color);
}
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-to-add.card-male .card-inner {
color: var(--male-color);
border-color: var(--male-color);
}
.f3 div.card-unknown .card-inner {
background-color: var(--background-color);
border: 1px solid;
}
.f3 div.card-unknown .card-inner .card-label {
margin: 0 auto;
}
.f3 div.card-unknown .person-icon {
display: none;
}
.f3 div.card-new-rel .card-inner {
border-width: 1px;
border-style: dashed;
outline: 0px !important;
}
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-unknown.card-female .card-inner {
border-color: var(--female-color);
color: var(--female-color);
}
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-unknown.card-male .card-inner {
color: var(--male-color);
border-color: var(--male-color);
}
.f3 div.card:hover > div {
transform: translate(0, -2px);
}
.f3 div.card-main .card-inner, .f3 div.card:hover .card-inner {
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.8);
}
.f3 div.card-main .card-inner {
outline: 4px solid rgba(220, 220, 220, 1);
}
.f3 div.card-inner.f3-path-to-main {
outline: 4px solid rgba(255, 255, 255, 1);
}
.f3 .link {
transition: stroke-width 0.2s ease-in-out;
}
.f3 .link.f3-path-to-main {
stroke-width: 4px;
}
.f3-form-cont {
position: relative;
z-index: 6;
right: 0;
top: 0;
width: 0;
height: 100%;
background-color: var(--background-color);
overflow: auto;
flex: 0 0 auto;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5);
}
.f3-form-cont.opened {
width: 350px;
}
.f3-form {
padding: 20px;
}
.f3-form hr {
border-style: solid;
border-width: thin 0 0 0;
opacity: 0.15;
}
.f3-nav-cont {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
}
.f3-history-controls {
padding: 8px 5px 7px 9px;
display: inline-block;
position: relative;
z-index: 2;
}
.f3-back-button, .f3-forward-button {
width: 30px;
height: 30px;
transition: opacity 0.3s ease;
cursor: pointer;
display: inline-block;
background-color: transparent;
border: none;
margin-right: 10px;
color: currentColor;
}
.f3-history-controls svg {
height: 100%;
}
.f3-back-button.disabled, .f3-forward-button.disabled {
opacity: 0.5;
}
.f3-modal {
display: none;
position: absolute;
z-index: 10;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.4);
}
.f3-modal-content {
position: relative;
background-color: var(--background-color);
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
border-radius: 5px;
width: 500px;
max-width: 90%;
}
.f3-modal-close {
color: #aaa;
position: absolute;
right: 10px;
top: 7px;
font-size: 28px;
font-weight: bold;
}
.f3-modal-close:hover,
.f3-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.f3-popup {
position: fixed;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.8);
}
.f3-popup-content {
position: relative;
background-color: var(--background-color);
border: 1px solid #888;
border-radius: 5px;
overflow: hidden;
width: 100%;
height: 100%;
}
.f3-popup-nav {
height: 20px;
}
.f3-popup-content-inner {
width: 100%;
height: 100%;
}
.f3-popup-close {
color: #aaa;
position: absolute;
z-index: 4;
right: 6px;
top: 1px;
font-size: 28px;
font-weight: bold;
line-height: 1;
}
.f3-popup-close:hover,
.f3-popup-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.f3-btn {
position: relative;
cursor: pointer;
padding: 5px 10px;
overflow: hidden;
border-width: 0;
outline: none;
border-radius: 3px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .6);
background-color: var(--text-color);
color: var(--background-color);
transition: background-color .3s;
font-size: 14px;
}
.f3-btn:hover, .f3-btn:focus {
background-color: var(--background-color);
color: var(--text-color);
}
.f3-female-bg {
background-color: var(--female-color);
}
.f3-male-bg {
background-color: var(--male-color);
}
.f3-genderless-bg {
background-color: var(--genderless-color);
}
.f3-female-color {
color: var(--female-color);
}
.f3-male-color {
color: var(--male-color);
}
.f3-genderless-color {
color: var(--genderless-color);
}
.f3-autocomplete-cont {
position: relative;
display: inline-block;
z-index: 2;
font-size: 14px;
width: 200px;
}
.f3-autocomplete input {
border: 1px solid rgba(255, 255, 255, 0.2);
background-color: var(--background-color);
color: var(--text-color);
padding: 10px;
width: 100%;
}
.f3-autocomplete input:focus {
outline: none;
}
.f3-autocomplete-toggle {
position: absolute;
right: 10px;
top: 10px;
cursor: pointer;
color: var(--text-color);
transition: color 0.3s ease-in-out;
width: 20px;
}
.f3-autocomplete-items {
border: 1px solid rgba(255, 255, 255, 0.2);
border-top: none;
overflow-y: auto;
max-height: 0;
background-color: var(--background-color);
transition: max-height 0.3s ease-in-out;
}
.f3-autocomplete.active .f3-autocomplete-items {
max-height: 300px;
}
.f3-autocomplete-item > div {
padding: 10px;
cursor: pointer;
background-color: var(--background-color);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
}
.f3-autocomplete-item > div:hover, .f3-autocomplete-item.f3-selected > div {
background-color: var(--text-color);
color: var(--background-color);
}
.f3-autocomplete-active {
background-color: DodgerBlue !important;
color: #ffffff;
}
.f3-kinship-info {
padding: 10px 20px;
}
.f3-kinship-info .f3-info-field {
color:#b3b01e
}
.f3-kinship-info-icon {
cursor:pointer;
display:inline-block;
width:18px;
height:18px;
color:#04a4f4;
position:relative;
top:4px;
left:2px;
}
.f3-kinship-info .f3 {
width:100%;
height: 100%;
position:relative;
background-color:rgb(33,33,33);
color:#fff;
}
.f3 .f3-kinship-info .card-kinship-self {
min-height: 0px;
width: 60px;
height: 60px;
border-radius: 50%;
background-color: var(--background-color) !important;
border: solid 3px;
color: #437fae;
font-weight: bold;
}
.f3 .f3-kinship-info .card-kinship-self.f3-real-label {
width: 150px;
height: 50px;
border-radius: 50px;
}
.f3 .f3-kinship-info .card-kinship-rel {
min-height: 0px;
width: 150px;
height: 50px;
border-radius: 50px;
background-color: #1d3456 !important;
font-weight: bold;
}
.f3 .f3-kinship-info .card-kinship-default {
min-height: 0px;
width: 150px;
height: 50px;
border-radius: 50px;
background-color: var(--background-color) !important;
border: solid 1px;
}
.f3-kinship-labels-toggle {
position: absolute;
top: 0;
left: 0;
z-index: 10;
font-size: 12px;
}
.f3-kinship-labels-toggle label {
cursor: pointer;
color: #fff;
font-weight: bold;
text-align: center;
padding: 2px 5px;
}
.f3-kinship-labels-toggle input[type="checkbox"] {
cursor: pointer;
margin-left: 5px;
margin-right: 5px;
margin-top: 5px;
margin-bottom: 5px;
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
// Vendored from family-chart/dist/styles (the package blocks the CSS subpath export).
import "./chart.css";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
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]];
}
export default function TreePage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const containerRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
useEffect(() => {
let cancelled = false;
(async () => {
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
if (p.response.status === 401) {
router.push("/login");
return;
}
const [r, e] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
]);
const people = p.data ?? [];
const rels: Relationship[] = r.data ?? [];
const events: Event[] = e.data ?? [];
if (people.length === 0) {
if (!cancelled) setStatus("empty");
return;
}
const parentsOf = (id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id);
const childrenOf = (id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id);
const partnersOf = (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));
const birthYear = new Map<string, string>();
for (const ev of events) {
if (ev.person_id && ev.event_type === "birth" && !birthYear.has(ev.person_id)) {
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
if (y) birthYear.set(ev.person_id, y);
}
}
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: birthYear.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
},
rels: {
spouses: partnersOf(pp.id),
parents: parentsOf(pp.id),
children: childrenOf(pp.id),
},
};
});
if (cancelled || !containerRef.current) return;
try {
const f3 = await import("family-chart");
containerRef.current.innerHTML = "";
const chart = f3.createChart(containerRef.current, data);
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
chart.updateTree({ initial: true });
if (!cancelled) setStatus("ready");
} catch {
if (!cancelled) setStatus("error");
}
})().catch(() => {
if (!cancelled) setStatus("error");
});
return () => {
cancelled = true;
};
}, [router, treeId]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-2xl font-semibold">Tree</h1>
<span className="text-sm text-[var(--muted)]">
Drag to pan · scroll to zoom · click a person to recenter
</span>
</div>
{status === "empty" && (
<p className="text-[var(--muted)]">
No people yet add some under People, or import a GEDCOM.
</p>
)}
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
<div
ref={containerRef}
className="f3 rounded-xl border border-[var(--border)]"
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
/>
</div>
);
}
+52 -18
View File
@@ -7,7 +7,7 @@ import { useCallback, useEffect, 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 { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Tree = components["schemas"]["TreeRead"]; type Tree = components["schemas"]["TreeRead"];
@@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"];
export default function TreesPage() { export default function TreesPage() {
const router = useRouter(); const router = useRouter();
const [trees, setTrees] = useState<Tree[]>([]); const [trees, setTrees] = useState<Tree[]>([]);
const [deleted, setDeleted] = useState<Tree[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -25,6 +26,8 @@ export default function TreesPage() {
return; return;
} }
setTrees(data ?? []); setTrees(data ?? []);
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
setDeleted(del.data ?? []);
setReady(true); setReady(true);
}, [router]); }, [router]);
@@ -42,24 +45,26 @@ export default function TreesPage() {
} }
} }
async function remove(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } });
load();
}
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<h1 className="text-2xl font-semibold">Your trees</h1> <h1 className="text-2xl font-semibold">Your trees</h1>
<Card> <Card>
<CardHeader> <CardContent className="p-5">
<CardTitle className="text-base">New tree</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createTree} className="flex gap-2"> <form onSubmit={createTree} className="flex gap-2">
<Input <Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
placeholder="Family name" <Button type="submit">Create tree</Button>
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit">Create</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
@@ -67,23 +72,52 @@ export default function TreesPage() {
{trees.length === 0 ? ( {trees.length === 0 ? (
<p className="text-[var(--muted)]">No trees yet create your first one above.</p> <p className="text-[var(--muted)]">No trees yet create your first one above.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="grid gap-3 sm:grid-cols-2">
{trees.map((tree) => ( {trees.map((tree) => (
<li key={tree.id}> <li key={tree.id}>
<Link href={`/trees/${tree.id}`}>
<Card className="transition-colors hover:border-bronze/50"> <Card className="transition-colors hover:border-bronze/50">
<CardContent className="flex items-center justify-between p-4"> <CardContent className="flex items-center justify-between p-4">
<span className="font-medium">{tree.name}</span> <Link href={`/trees/${tree.id}/tree`} className="min-w-0 flex-1">
<span className="text-xs uppercase tracking-wide text-bronze"> <div className="truncate font-medium">{tree.name}</div>
<div className="text-xs uppercase tracking-wide text-bronze">
{tree.visibility} {tree.visibility}
</span> </div>
</Link>
<button
onClick={() => remove(tree.id)}
className="ml-3 text-[var(--muted)] hover:text-bronze"
aria-label="Delete tree"
>
×
</button>
</CardContent> </CardContent>
</Card> </Card>
</Link>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{deleted.length > 0 && (
<div className="space-y-3">
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
Recently deleted
</h2>
<ul className="space-y-2">
{deleted.map((tree) => (
<li key={tree.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{tree.name}</span>
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</div>
)}
</div> </div>
); );
} }
+28 -1
View File
@@ -1,6 +1,15 @@
"use client"; "use client";
import { BookText, FolderTree, Image as ImageIcon, LogOut, Users } from "lucide-react"; import {
Archive,
ArrowDownUp,
BookText,
FolderTree,
Image as ImageIcon,
LogOut,
Network,
Users,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -69,6 +78,12 @@ export function AppSidebar() {
<div className="truncate px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]"> <div className="truncate px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
{treeName ?? "Tree"} {treeName ?? "Tree"}
</div> </div>
<Item
href={`/trees/${treeId}/tree`}
label="Tree"
icon={Network}
active={pathname.startsWith(`/trees/${treeId}/tree`)}
/>
<Item <Item
href={`/trees/${treeId}`} href={`/trees/${treeId}`}
label="People" label="People"
@@ -87,6 +102,18 @@ export function AppSidebar() {
icon={ImageIcon} icon={ImageIcon}
active={pathname.startsWith(`/trees/${treeId}/media`)} active={pathname.startsWith(`/trees/${treeId}/media`)}
/> />
<Item
href={`/trees/${treeId}/gedcom`}
label="Import / Export"
icon={ArrowDownUp}
active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
/>
<Item
href={`/trees/${treeId}/recovery`}
label="Recovery"
icon={Archive}
active={pathname.startsWith(`/trees/${treeId}/recovery`)}
/>
</div> </div>
)} )}
+353 -4
View File
@@ -186,6 +186,24 @@ export interface paths {
get: operations["get_tree_api_v1_trees__tree_id__get"]; get: operations["get_tree_api_v1_trees__tree_id__get"];
put?: never; put?: never;
post?: never; post?: never;
/** Delete Tree */
delete: operations["delete_tree_api_v1_trees__tree_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Restore Tree */
post: operations["restore_tree_api_v1_trees__tree_id__restore_post"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -221,6 +239,24 @@ export interface paths {
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"]; get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
put?: never; put?: never;
post?: never; post?: never;
/** Delete Person */
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Restore Person */
post: operations["restore_person_api_v1_trees__tree_id__persons__person_id__restore_post"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -234,7 +270,8 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
get?: never; /** List Tree Events */
get: operations["list_tree_events_api_v1_trees__tree_id__events_get"];
put?: never; put?: never;
/** Create Event */ /** Create Event */
post: operations["create_event_api_v1_trees__tree_id__events_post"]; post: operations["create_event_api_v1_trees__tree_id__events_post"];
@@ -285,7 +322,8 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
get?: never; /** List Relationships */
get: operations["list_relationships_api_v1_trees__tree_id__relationships_get"];
put?: never; put?: never;
/** Create Relationship */ /** Create Relationship */
post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"]; post: operations["create_relationship_api_v1_trees__tree_id__relationships_post"];
@@ -452,10 +490,49 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/gedcom/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Import Gedcom */
post: operations["import_gedcom_api_v1_trees__tree_id__gedcom_import_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/gedcom/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Export Gedcom */
get: operations["export_gedcom_api_v1_trees__tree_id__gedcom_export_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/** Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post */
Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
/** File */
file: string;
};
/** Body_upload_media_api_v1_trees__tree_id__media_post */ /** Body_upload_media_api_v1_trees__tree_id__media_post */
Body_upload_media_api_v1_trees__tree_id__media_post: { Body_upload_media_api_v1_trees__tree_id__media_post: {
/** File */ /** File */
@@ -604,6 +681,15 @@ export interface components {
/** Detail */ /** Detail */
detail?: components["schemas"]["ValidationError"][]; detail?: components["schemas"]["ValidationError"][];
}; };
/** ImportReport */
ImportReport: {
/** Counts */
counts: {
[key: string]: number;
};
/** Unmapped Tags */
unmapped_tags: string[];
};
/** LoginRequest */ /** LoginRequest */
LoginRequest: { LoginRequest: {
/** Email */ /** Email */
@@ -1169,7 +1255,9 @@ export interface operations {
}; };
list_my_trees_api_v1_trees_get: { list_my_trees_api_v1_trees_get: {
parameters: { parameters: {
query?: never; query?: {
deleted?: boolean;
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -1185,6 +1273,15 @@ export interface operations {
"application/json": components["schemas"]["TreeRead"][]; "application/json": components["schemas"]["TreeRead"][];
}; };
}; };
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
}; };
}; };
create_tree_api_v1_trees_post: { create_tree_api_v1_trees_post: {
@@ -1251,7 +1348,7 @@ export interface operations {
}; };
}; };
}; };
list_persons_api_v1_trees__tree_id__persons_get: { delete_tree_api_v1_trees__tree_id__delete: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -1261,6 +1358,68 @@ export interface operations {
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_tree_api_v1_trees__tree_id__restore_post: {
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"]["TreeRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_persons_api_v1_trees__tree_id__persons_get: {
parameters: {
query?: {
deleted?: boolean;
};
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: { responses: {
/** @description Successful Response */ /** @description Successful Response */
200: { 200: {
@@ -1349,6 +1508,99 @@ export interface operations {
}; };
}; };
}; };
delete_person_api_v1_trees__tree_id__persons__person_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PersonRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_tree_events_api_v1_trees__tree_id__events_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"]["EventRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_event_api_v1_trees__tree_id__events_post: { create_event_api_v1_trees__tree_id__events_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1446,6 +1698,37 @@ export interface operations {
}; };
}; };
}; };
list_relationships_api_v1_trees__tree_id__relationships_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"]["RelationshipRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_relationship_api_v1_trees__tree_id__relationships_post: { create_relationship_api_v1_trees__tree_id__relationships_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1895,4 +2178,70 @@ export interface operations {
}; };
}; };
}; };
import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ImportReport"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
export_gedcom_api_v1_trees__tree_id__gedcom_export_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": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
} }
+453 -25
View File
@@ -281,29 +281,6 @@
} }
}, },
"/api/v1/trees": { "/api/v1/trees": {
"get": {
"tags": [
"trees"
],
"summary": "List My Trees",
"operationId": "list_my_trees_api_v1_trees_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/TreeRead"
},
"type": "array",
"title": "Response List My Trees Api V1 Trees Get"
}
}
}
}
}
},
"post": { "post": {
"tags": [ "tags": [
"trees" "trees"
@@ -311,14 +288,14 @@
"summary": "Create Tree", "summary": "Create Tree",
"operationId": "create_tree_api_v1_trees_post", "operationId": "create_tree_api_v1_trees_post",
"requestBody": { "requestBody": {
"required": true,
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/TreeCreate" "$ref": "#/components/schemas/TreeCreate"
} }
} }
}, }
"required": true
}, },
"responses": { "responses": {
"201": { "201": {
@@ -342,6 +319,51 @@
} }
} }
} }
},
"get": {
"tags": [
"trees"
],
"summary": "List My Trees",
"operationId": "list_my_trees_api_v1_trees_get",
"parameters": [
{
"name": "deleted",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Deleted"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TreeRead"
},
"title": "Response List My Trees Api V1 Trees Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
} }
}, },
"/api/v1/trees/{tree_id}": { "/api/v1/trees/{tree_id}": {
@@ -385,6 +407,83 @@
} }
} }
} }
},
"delete": {
"tags": [
"trees"
],
"summary": "Delete Tree",
"operationId": "delete_tree_api_v1_trees__tree_id__delete",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/restore": {
"post": {
"tags": [
"trees"
],
"summary": "Restore Tree",
"operationId": "restore_tree_api_v1_trees__tree_id__restore_post",
"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": {
"$ref": "#/components/schemas/TreeRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
} }
}, },
"/api/v1/trees/{tree_id}/persons": { "/api/v1/trees/{tree_id}/persons": {
@@ -455,6 +554,16 @@
"format": "uuid", "format": "uuid",
"title": "Tree Id" "title": "Tree Id"
} }
},
{
"name": "deleted",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Deleted"
}
} }
], ],
"responses": { "responses": {
@@ -486,6 +595,50 @@
} }
}, },
"/api/v1/trees/{tree_id}/persons/{person_id}": { "/api/v1/trees/{tree_id}/persons/{person_id}": {
"delete": {
"tags": [
"persons"
],
"summary": "Delete Person",
"operationId": "delete_person_api_v1_trees__tree_id__persons__person_id__delete",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "person_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Person Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"get": { "get": {
"tags": [ "tags": [
"persons" "persons"
@@ -538,6 +691,59 @@
} }
} }
}, },
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
"post": {
"tags": [
"persons"
],
"summary": "Restore Person",
"operationId": "restore_person_api_v1_trees__tree_id__persons__person_id__restore_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "person_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Person Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/events": { "/api/v1/trees/{tree_id}/events": {
"post": { "post": {
"tags": [ "tags": [
@@ -589,6 +795,51 @@
} }
} }
} }
},
"get": {
"tags": [
"events"
],
"summary": "List Tree Events",
"operationId": "list_tree_events_api_v1_trees__tree_id__events_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/EventRead"
},
"title": "Response List Tree Events Api V1 Trees Tree Id Events Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
} }
}, },
"/api/v1/trees/{tree_id}/persons/{person_id}/events": { "/api/v1/trees/{tree_id}/persons/{person_id}/events": {
@@ -745,6 +996,51 @@
} }
} }
} }
},
"get": {
"tags": [
"relationships"
],
"summary": "List Relationships",
"operationId": "list_relationships_api_v1_trees__tree_id__relationships_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/RelationshipRead"
},
"title": "Response List Relationships Api V1 Trees Tree Id Relationships Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
} }
}, },
"/api/v1/trees/{tree_id}/persons/{person_id}/relationships": { "/api/v1/trees/{tree_id}/persons/{person_id}/relationships": {
@@ -1383,10 +1679,118 @@
} }
} }
} }
},
"/api/v1/trees/{tree_id}/gedcom/import": {
"post": {
"tags": [
"gedcom"
],
"summary": "Import Gedcom",
"operationId": "import_gedcom_api_v1_trees__tree_id__gedcom_import_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImportReport"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/gedcom/export": {
"get": {
"tags": [
"gedcom"
],
"summary": "Export Gedcom",
"operationId": "export_gedcom_api_v1_trees__tree_id__gedcom_export_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": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
"schemas": { "schemas": {
"Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"
},
"Body_upload_media_api_v1_trees__tree_id__media_post": { "Body_upload_media_api_v1_trees__tree_id__media_post": {
"properties": { "properties": {
"file": { "file": {
@@ -1954,6 +2358,30 @@
"type": "object", "type": "object",
"title": "HTTPValidationError" "title": "HTTPValidationError"
}, },
"ImportReport": {
"properties": {
"counts": {
"additionalProperties": {
"type": "integer"
},
"type": "object",
"title": "Counts"
},
"unmapped_tags": {
"items": {
"type": "string"
},
"type": "array",
"title": "Unmapped Tags"
}
},
"type": "object",
"required": [
"counts",
"unmapped_tags"
],
"title": "ImportReport"
},
"LoginRequest": { "LoginRequest": {
"properties": { "properties": {
"email": { "email": {
+429
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"family-chart": "^0.9.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "^15.1.0", "next": "^15.1.0",
"openapi-fetch": "^0.13.0", "openapi-fetch": "^0.13.0",
@@ -1111,12 +1112,390 @@
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true "dev": true
}, },
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"engines": {
"node": ">= 10"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true "dev": true
}, },
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1134,6 +1513,14 @@
} }
} }
}, },
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1156,6 +1543,14 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/family-chart": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/family-chart/-/family-chart-0.9.0.tgz",
"integrity": "sha512-+JdLr1Oo+YFnQWUXgdnk4nCMTbe1MXKdpbx3KEBXPeq2oX+2v5ccmrcK39CZ761/zQfgSHFZ2cT/+gbaeeACcA==",
"dependencies": {
"d3": "^7.9.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1181,6 +1576,17 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/index-to-position": { "node_modules/index-to-position": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
@@ -1193,6 +1599,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1734,6 +2148,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+11 -10
View File
@@ -10,23 +10,24 @@
"gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false" "gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false"
}, },
"dependencies": { "dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"openapi-fetch": "^0.13.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"tailwind-merge": "^2.6.0", "family-chart": "^0.9.0",
"lucide-react": "^0.469.0" "lucide-react": "^0.469.0",
"next": "^15.1.0",
"openapi-fetch": "^0.13.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.0", "@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0", "openapi-typescript": "^7.5.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"openapi-typescript": "^7.5.0" "tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
} }
} }
+2
View File
@@ -0,0 +1,2 @@
declare module "family-chart";
declare module "family-chart/dist/styles/family-chart.css";