10 Commits

Author SHA1 Message Date
justin cd4ccb4ac8 Show a sex symbol after the name on the person page
A blue ♂ (male) or pink ♀ (female) symbol now follows the person's name in the
detail header, using the same gender tints as the tree cards. Nothing shows when
sex is unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:16:38 -04:00
justin e8839b15a0 Full light/dark theme toggle; brand-aware connector lines
- Theme is now class-based (.dark on <html>) with a System/Light/Dark toggle in
  the sidebar, persisted to localStorage and applied pre-paint by an inline
  script (no flash). Replaces the prefers-color-scheme-only behavior, so a phone
  on a light OS theme can still choose dark and vice versa.
- New brand-derived --line token (Ink at 55%): a dark line on the light paper,
  light on dark. The family-chart tree connectors had the library's default
  white stroke and were invisible in light mode — now they use --line, as do
  the pedigree brackets and the fan-chart sectors.
- Light/dark tokens use the exact brand palette (Ink/Muted flip; Bronze/Paper
  constant).

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:48:59 -04:00
justin 548e883d82 Merge pull request 'Discoverable Add Person + inline create-new when linking relatives' (#28) from create-person-ux into main
build-frontend / build (push) Successful in 1m27s
2026-06-07 11:30:16 -04:00
justin 37ac49767e Make creating a person obvious; inline "create new" when linking relatives
- Family view gets a prominent "+ Add person" button that creates a person and
  opens their page to fill in details (previously you could only add a person
  via the empty-state form or by linking from another person).
- The person page's relationship picker (PersonCombobox) now offers
  "+ Create '<typed name>'" when the person doesn't exist yet: it creates them,
  links them in the chosen role (parent/child/partner/sibling), and jumps to
  their new page to edit — no more create-then-go-back-and-link.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:30:14 -04:00
justin 9b04bcefba Merge pull request 'Account export / restore-into-new-tree / delete' (#27) from account-export-restore-delete into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m27s
2026-06-07 11:26:06 -04:00
justin e9b2436ce0 Account export / restore-into-new-tree / delete
New account_service + endpoints under /users/me:
- GET /me/export — zip of every owned tree (account.json + media blobs).
- POST /me/import — restore a backup into NEW trees (ids remapped, media
  re-uploaded); non-destructive, never touches existing data.
- DELETE /me — soft-delete the user, their owned trees, and revoke sessions;
  guarded by retyping the account email.

Settings page wires all three (export download, restore upload, delete with
typed-email confirmation). No migration — uses existing tables + soft-delete.

52 backend tests pass (export→restore round-trip + delete guards); frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:26:04 -04:00
justin 8903e480cf Merge pull request 'Link media to people (person page + media page)' (#26) from media-person-linking into main
build-frontend / build (push) Successful in 1m24s
2026-06-07 11:19:27 -04:00
justin d27cc5dddc Link media to people (person page + media page)
The Media model already carried person_id/event_id/source_id and the upload
route already accepted person_id — this surfaces it in the UI:

- Person page: a Media card lists media linked to that person, uploads new
  files already linked ("Upload & link"), links existing unlinked media, and
  unlinks.
- Media page: each item gets a person picker to link/unlink.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:19:25 -04:00
justin 943f459b91 Merge pull request 'Shared marriage events; deterministic parent ordering' (#25) from marriage-event-parent-order into main
build-frontend / build (push) Successful in 1m23s
2026-06-07 11:15:55 -04:00
justin 5106538934 Shared marriage events; deterministic parent ordering
- Partnership life events (marriage/divorce/engagement) now attach to the
  couple's relationship, not each person. The add-event form asks for the
  spouse, finds-or-creates the partnership, and writes ONE event on it — shown
  on both partners' pages ("· with <spouse>"), entered once. Event values
  (RELI/OCCU detail) now render too.
- Family-view pedigree orders parents deterministically (father on top, mother
  below, stable fallback when gender is unknown) instead of by which link was
  created first.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:15:54 -04:00
16 changed files with 1267 additions and 45 deletions
+37 -3
View File
@@ -1,8 +1,8 @@
from fastapi import APIRouter
from fastapi import APIRouter, File, Form, Response, UploadFile
from app.api.deps import CurrentUser, SessionDep
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.user import UserRead, UserSelfPersonUpdate
from app.services import user_service
from app.services import account_service, user_service
router = APIRouter(prefix="/users", tags=["users"])
@@ -21,3 +21,37 @@ async def set_self_person(
session, user=current, person_id=data.self_person_id
)
return UserRead.model_validate(user)
@router.get("/me/export")
async def export_account(
session: SessionDep, current: CurrentUser, store: ObjectStoreDep
) -> Response:
"""Download a full backup (JSON + media) of every tree the user owns."""
data = await account_service.export_account(session, store, user=current)
return Response(
content=data,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="provenance-export.zip"'},
)
@router.post("/me/import")
async def import_account(
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
file: UploadFile = File(...),
) -> dict:
"""Restore a previously-exported backup into new trees (non-destructive)."""
raw = await file.read()
return await account_service.import_account(session, store, user=current, raw_zip=raw)
@router.delete("/me", status_code=204)
async def delete_account(
session: SessionDep, current: CurrentUser, confirm_email: str = Form(...)
) -> None:
"""Delete the account: the user, their owned trees, and their sessions.
Requires retyping the account email as a guard."""
await account_service.delete_account(session, user=current, confirm_email=confirm_email)
+352
View File
@@ -0,0 +1,352 @@
"""Account-level data portability: export the signed-in user's owned trees as a
zip (JSON + media bytes), restore such a zip into a brand-new tree
(non-destructive), and delete the account.
The export format is a zip containing ``account.json`` plus ``media/<id>`` blobs.
Restore always creates new trees and remaps ids, so it can't clobber existing
data.
"""
import hashlib
import io
import json
import uuid
import zipfile
from datetime import UTC, date, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.objectstore.base import ObjectStore
from app.models.auth import Session as SessionModel
from app.models.enums import MembershipRole
from app.models.event import Event
from app.models.media import Media
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, TreeMembership
from app.models.user import User
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
EXPORT_VERSION = 1
_DROP = {"created_at", "updated_at", "deleted_at", "tree_id"}
# Media columns rebuilt on import (storage is re-keyed, checksum recomputed).
_MEDIA_DROP = _DROP | {"uploader_id", "storage_key", "byte_size", "checksum_sha256"}
_DATE_FIELDS = {"date_start", "date_end"}
def _row(obj, drop: set[str]) -> dict:
out: dict = {}
for col in obj.__table__.columns.keys(): # noqa: SIM118
if col in drop:
continue
out[col] = getattr(obj, col)
return out
async def _entities(session: AsyncSession, model, tree_id: uuid.UUID):
stmt = select(model).where(model.tree_id == tree_id, model.deleted_at.is_(None))
return list((await session.execute(stmt)).scalars().all())
async def export_account(session: AsyncSession, store: ObjectStore, *, user: User) -> bytes:
"""Build a zip of every tree the user owns: account.json + media blobs."""
trees = list(
(
await session.execute(
select(Tree).where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
)
).scalars().all()
)
payload: dict = {
"version": EXPORT_VERSION,
"user": {"email": user.email, "display_name": user.display_name},
"trees": [],
}
media_blobs: list[tuple[str, bytes]] = []
for tree in trees:
media_rows = await _entities(session, Media, tree.id)
media_out = []
for m in media_rows:
ref = f"media/{m.id}"
rec = _row(m, _MEDIA_DROP)
rec["_file"] = ref
media_out.append(rec)
try:
media_blobs.append((ref, await store.get_object(key=m.storage_key)))
except Exception: # noqa: BLE001 — a missing blob shouldn't abort the export
rec["_file"] = None
payload["trees"].append({
"tree": {
"name": tree.name,
"description": tree.description,
"visibility": tree.visibility,
"home_person_id": tree.home_person_id,
},
"places": [_row(p, _DROP) for p in await _entities(session, Place, tree.id)],
"persons": [_row(p, _DROP) for p in await _entities(session, Person, tree.id)],
"names": [_row(n, _DROP) for n in await _entities(session, Name, tree.id)],
"relationships": [
_row(r, _DROP) for r in await _entities(session, Relationship, tree.id)
],
"events": [_row(e, _DROP) for e in await _entities(session, Event, tree.id)],
"sources": [_row(s, _DROP) for s in await _entities(session, Source, tree.id)],
"citations": [_row(c, _DROP) for c in await _entities(session, Citation, tree.id)],
"media": media_out,
})
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("account.json", json.dumps(payload, default=str, indent=2))
for ref, blob in media_blobs:
zf.writestr(ref, blob)
return buf.getvalue()
def _as_uuid(v) -> uuid.UUID | None:
return uuid.UUID(v) if v else None
def _as_date(v) -> date | None:
return date.fromisoformat(v) if v else None
async def import_account(
session: AsyncSession, store: ObjectStore, *, user: User, raw_zip: bytes
) -> dict:
"""Restore an exported zip into NEW trees owned by the user. Non-destructive:
every record gets a fresh id; nothing existing is touched."""
try:
zf = zipfile.ZipFile(io.BytesIO(raw_zip))
payload = json.loads(zf.read("account.json"))
except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as e:
raise NotFound("not a valid Provenance export") from e
counts: dict[str, int] = {"trees": 0, "persons": 0, "events": 0, "media": 0}
for tdata in payload.get("trees", []):
t = tdata.get("tree", {})
tree = Tree(
owner_id=user.id,
name=(t.get("name") or "Imported tree"),
description=t.get("description"),
visibility=t.get("visibility") or "private",
)
session.add(tree)
await session.flush()
session.add(
TreeMembership(tree_id=tree.id, user_id=user.id, role=MembershipRole.owner)
)
counts["trees"] += 1
# id remaps from the export's ids to the freshly created ones.
pmap: dict[str, uuid.UUID] = {}
rmap: dict[str, uuid.UUID] = {}
smap: dict[str, uuid.UUID] = {}
nmap: dict[str, uuid.UUID] = {}
emap: dict[str, uuid.UUID] = {}
plmap: dict[str, uuid.UUID] = {}
for pl in tdata.get("places", []):
obj = Place(
tree_id=tree.id,
name=pl.get("name") or "",
place_type=pl.get("place_type"),
latitude=pl.get("latitude"),
longitude=pl.get("longitude"),
)
session.add(obj)
await session.flush()
plmap[pl["id"]] = obj.id
for p in tdata.get("persons", []):
obj = Person(
tree_id=tree.id,
gender=p.get("gender"),
is_living=p.get("is_living"),
privacy=p.get("privacy") or "inherit",
notes=p.get("notes"),
)
session.add(obj)
await session.flush()
pmap[p["id"]] = obj.id
counts["persons"] += 1
for n in tdata.get("names", []):
pid = pmap.get(n.get("person_id"))
if pid is None:
continue
obj = Name(
tree_id=tree.id,
person_id=pid,
name_type=n.get("name_type") or "birth",
given=n.get("given"),
surname=n.get("surname"),
prefix=n.get("prefix"),
suffix=n.get("suffix"),
nickname=n.get("nickname"),
display_name=n.get("display_name"),
is_primary=bool(n.get("is_primary")),
sort_order=n.get("sort_order") or 0,
)
session.add(obj)
await session.flush()
nmap[n["id"]] = obj.id
for r in tdata.get("relationships", []):
a = pmap.get(r.get("person_from_id"))
b = pmap.get(r.get("person_to_id"))
if a is None or b is None:
continue
obj = Relationship(
tree_id=tree.id,
type=r.get("type"),
person_from_id=a,
person_to_id=b,
qualifier=r.get("qualifier"),
notes=r.get("notes"),
)
session.add(obj)
await session.flush()
rmap[r["id"]] = obj.id
for e in tdata.get("events", []):
obj = Event(
tree_id=tree.id,
event_type=e.get("event_type") or "other",
person_id=pmap.get(e.get("person_id")),
relationship_id=rmap.get(e.get("relationship_id")),
place_id=plmap.get(e.get("place_id")),
date_value=e.get("date_value"),
date_start=_as_date(e.get("date_start")),
date_end=_as_date(e.get("date_end")),
date_precision=e.get("date_precision"),
calendar=e.get("calendar") or "gregorian",
detail=e.get("detail"),
notes=e.get("notes"),
)
session.add(obj)
await session.flush()
emap[e["id"]] = obj.id
counts["events"] += 1
for s in tdata.get("sources", []):
obj = Source(
tree_id=tree.id,
title=s.get("title") or "Untitled source",
author=s.get("author"),
source_type=s.get("source_type"),
repository=s.get("repository"),
url=s.get("url"),
citation_text=s.get("citation_text"),
publication_info=s.get("publication_info"),
quality_note=s.get("quality_note"),
)
session.add(obj)
await session.flush()
smap[s["id"]] = obj.id
for c in tdata.get("citations", []):
sid = smap.get(c.get("source_id"))
if sid is None:
continue
session.add(
Citation(
tree_id=tree.id,
source_id=sid,
person_id=pmap.get(c.get("person_id")),
event_id=emap.get(c.get("event_id")),
name_id=nmap.get(c.get("name_id")),
relationship_id=rmap.get(c.get("relationship_id")),
page=c.get("page"),
detail=c.get("detail"),
confidence=c.get("confidence"),
)
)
for m in tdata.get("media", []):
ref = m.get("_file")
if not ref:
continue
try:
blob = zf.read(ref)
except KeyError:
continue
media_id = uuid.uuid4()
filename = m.get("original_filename") or "upload"
key = f"{tree.id}/{media_id}/{filename}"
await store.ensure_bucket()
await store.put_object(
key=key,
data=blob,
content_type=m.get("content_type") or "application/octet-stream",
)
session.add(
Media(
id=media_id,
tree_id=tree.id,
uploader_id=user.id,
storage_key=key,
original_filename=filename,
content_type=m.get("content_type") or "application/octet-stream",
byte_size=len(blob),
checksum_sha256=hashlib.sha256(blob).hexdigest(),
title=m.get("title"),
person_id=pmap.get(m.get("person_id")),
event_id=emap.get(m.get("event_id")),
source_id=smap.get(m.get("source_id")),
)
)
counts["media"] += 1
# Remap the home person last, once persons exist.
home = t.get("home_person_id")
if home and home in pmap:
tree.home_person_id = pmap[home]
record_audit(
session,
action="import",
entity_type="Account",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=user.id,
after=counts,
)
await session.commit()
return counts
async def delete_account(session: AsyncSession, *, user: User, confirm_email: str) -> None:
"""Soft-delete the account: the user, the trees they own, and all their
sessions. Requires the user to retype their email as a guard."""
if confirm_email.strip().lower() != user.email.lower():
raise Forbidden("email confirmation does not match")
now = datetime.now(UTC)
await session.execute(
update(Tree)
.where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
.values(deleted_at=now)
)
await session.execute(
update(SessionModel)
.where(SessionModel.user_id == user.id, SessionModel.revoked_at.is_(None))
.values(revoked_at=now)
)
user.deleted_at = now
record_audit(
session,
action="delete",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
)
await session.commit()
+86
View File
@@ -0,0 +1,86 @@
"""Account export -> restore round-trip, and account deletion."""
from tests.conftest import auth, register
async def _seed(client, h):
tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=h)).json()["id"]
p1 = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Lovelace"}, headers=h
)
).json()["id"]
p2 = (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Kid"}, headers=h)
).json()["id"]
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": p1, "person_to_id": p2},
headers=h,
)
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": p1, "date_value": "1815"},
headers=h,
)
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("scan.txt", b"hello", "text/plain")},
data={"title": "Scan", "person_id": p1},
headers=h,
)
return tid
async def test_export_then_restore_roundtrip(client):
h = auth(await register(client, "exp@example.com"))
await _seed(client, h)
export = await client.get("/api/v1/users/me/export", headers=h)
assert export.status_code == 200
assert export.headers["content-type"] == "application/zip"
blob = export.content
assert blob[:2] == b"PK" # zip magic
# Restore into new trees (non-destructive: the original stays).
r = await client.post(
"/api/v1/users/me/import",
files={"file": ("provenance-export.zip", blob, "application/zip")},
headers=h,
)
assert r.status_code == 200, r.text
counts = r.json()
assert counts["trees"] == 1 and counts["persons"] == 2
assert counts["events"] == 1 and counts["media"] == 1
trees = (await client.get("/api/v1/trees", headers=h)).json()
assert len(trees) == 2 # original + restored
# The restored tree has the people, with a working relationship and media.
restored = [t for t in trees if t["name"] == "Fam"][1]["id"]
ppl = (await client.get(f"/api/v1/trees/{restored}/persons", headers=h)).json()
assert {p["primary_name"] for p in ppl} == {"Ada Lovelace", "Kid"}
rels = (await client.get(f"/api/v1/trees/{restored}/relationships", headers=h)).json()
assert len(rels) == 1
med = (await client.get(f"/api/v1/trees/{restored}/media", headers=h)).json()
assert len(med) == 1 and med[0]["title"] == "Scan"
async def test_delete_account_requires_email_then_revokes(client):
token = await register(client, "del@example.com")
h = auth(token)
await _seed(client, h)
# Wrong email is rejected.
bad = await client.request(
"DELETE", "/api/v1/users/me", data={"confirm_email": "nope@example.com"}, headers=h
)
assert bad.status_code == 403
ok = await client.request(
"DELETE", "/api/v1/users/me", data={"confirm_email": "del@example.com"}, headers=h
)
assert ok.status_code == 204
# Session is revoked — the token no longer works.
assert (await client.get("/api/v1/users/me", headers=h)).status_code == 401
+12 -7
View File
@@ -11,23 +11,28 @@
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
}
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant. */
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant.
Theme is class-based (.dark on <html>) so it can be toggled manually; an inline
script in the root layout sets it pre-paint from the saved choice or the OS. */
:root {
--background: #f7f3ec;
--foreground: #1a1a17;
--muted: #6b6862;
--surface: #fffdf9;
--border: #e6ddcc;
/* Connector "lines between people" (pedigree + tree chart). Derived from Ink
(the brand mark color): a dark line on light, light on dark. */
--line: color-mix(in srgb, var(--foreground) 55%, transparent);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
.dark {
--background: #161410;
--foreground: #f2eee6;
--muted: #9a968e;
--surface: #211d17;
--border: #353029;
}
color-scheme: dark;
}
body {
@@ -78,7 +83,7 @@ h3,
left: -2.5rem;
top: 50%;
width: 2.5rem;
border-top: 1px solid var(--border);
border-top: 1px solid var(--line);
}
.ped-leaf {
position: relative;
@@ -90,7 +95,7 @@ h3,
left: 0;
top: 50%;
width: 1.5rem;
border-top: 1px solid var(--border);
border-top: 1px solid var(--line);
}
.ped-leaf::after {
content: "";
@@ -98,7 +103,7 @@ h3,
left: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
border-left: 1px solid var(--line);
}
.ped-leaf:first-child::after {
top: 50%;
+8 -1
View File
@@ -18,9 +18,16 @@ export const metadata: Metadata = {
icons: { icon: "/favicon.svg" },
};
// Sets the theme class before first paint to avoid a flash; reads the saved
// choice ("light"/"dark"/"system") or falls back to the OS preference.
const themeScript = `(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||((!t||t==="system")&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
<html lang="en" className={`${serif.variable} ${sans.variable}`} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-screen antialiased">{children}</body>
</html>
);
+112 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
@@ -8,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export default function SettingsPage() {
const router = useRouter();
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
const [current, setCurrent] = useState("");
@@ -16,10 +18,72 @@ export default function SettingsPage() {
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
// Data export / restore / delete.
const [exporting, setExporting] = useState(false);
const [restoreMsg, setRestoreMsg] = useState<string | null>(null);
const [restoring, setRestoring] = useState(false);
const restoreRef = useRef<HTMLInputElement>(null);
const [deleteConfirm, setDeleteConfirm] = useState("");
const [deleting, setDeleting] = useState(false);
useEffect(() => {
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
}, []);
async function exportData() {
setExporting(true);
const resp = await fetch("/api/v1/users/me/export", { credentials: "include" });
if (resp.ok) {
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "provenance-export.zip";
a.click();
URL.revokeObjectURL(url);
}
setExporting(false);
}
async function restoreData(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (restoreRef.current) restoreRef.current.value = "";
if (!file) return;
setRestoring(true);
setRestoreMsg(null);
const fd = new FormData();
fd.append("file", file);
const resp = await fetch("/api/v1/users/me/import", {
method: "POST",
body: fd,
credentials: "include",
});
setRestoring(false);
if (resp.ok) {
const c = await resp.json();
setRestoreMsg(`Restored ${c.trees} tree(s), ${c.persons} people into new trees.`);
} else {
setRestoreMsg("That doesn't look like a valid Provenance export.");
}
}
async function deleteAccount() {
if (deleteConfirm !== me?.email) return;
setDeleting(true);
const fd = new FormData();
fd.append("confirm_email", deleteConfirm);
const resp = await fetch("/api/v1/users/me", {
method: "DELETE",
body: fd,
credentials: "include",
});
setDeleting(false);
if (resp.ok) {
await api.POST("/api/v1/auth/logout");
router.push("/register");
}
}
async function changePassword(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
@@ -109,10 +173,55 @@ export default function SettingsPage() {
<CardHeader>
<CardTitle className="text-base">Your data</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-5">
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
Full-account export, restore, and account deletion are coming next.
Download a complete backup of every tree you own people, sources, media, and
all as a zip.
</p>
<Button variant="outline" onClick={exportData} disabled={exporting}>
{exporting ? "Preparing…" : "Export all my data"}
</Button>
</div>
<div className="space-y-2 border-t border-[var(--border)] pt-4">
<p className="text-sm text-[var(--muted)]">
Restore a backup. Its imported into <strong>new</strong> trees nothing existing
is touched or overwritten.
</p>
<input ref={restoreRef} type="file" accept=".zip" onChange={restoreData} className="hidden" />
<Button variant="outline" onClick={() => restoreRef.current?.click()} disabled={restoring}>
{restoring ? "Restoring…" : "Restore from backup"}
</Button>
{restoreMsg && <p className="text-sm text-bronze">{restoreMsg}</p>}
</div>
</CardContent>
</Card>
<Card className="border-red-300/60">
<CardHeader>
<CardTitle className="text-base text-red-700">Delete account</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
This deletes your account, the trees you own, and signs you out everywhere. Export
your data first if you might want it. Type <strong>{me?.email}</strong> to confirm.
</p>
<div className="flex max-w-sm flex-col gap-2">
<Input
placeholder="your email"
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
/>
<Button
variant="ghost"
className="bg-red-600 text-white hover:bg-red-700 hover:text-white"
onClick={deleteAccount}
disabled={deleting || deleteConfirm !== me?.email}
>
{deleting ? "Deleting…" : "Permanently delete my account"}
</Button>
</div>
</CardContent>
</Card>
</div>
+29
View File
@@ -9,6 +9,9 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Media = components["schemas"]["MediaRead"];
type Person = components["schemas"]["PersonRead"];
const fieldCls = "h-8 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-xs";
function humanSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
@@ -22,6 +25,7 @@ export default function MediaPage() {
const treeId = params.id;
const [items, setItems] = useState<Media[]>([]);
const [people, setPeople] = useState<Person[]>([]);
const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
@@ -34,10 +38,22 @@ export default function MediaPage() {
router.push("/login");
return;
}
const ppl = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
setItems(data ?? []);
setPeople(ppl.data ?? []);
setReady(true);
}, [router, treeId]);
async function linkPerson(mediaId: string, personId: string) {
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
params: { path: { tree_id: treeId, media_id: mediaId } },
body: { person_id: personId || null },
});
load();
}
useEffect(() => {
load();
}, [load]);
@@ -124,6 +140,19 @@ export default function MediaPage() {
×
</button>
</div>
<select
className={`${fieldCls} mt-2`}
value={m.person_id ?? ""}
onChange={(e) => linkPerson(m.id, e.target.value)}
title="Link to a person"
>
<option value=""> link to person </option>
{people.map((p) => (
<option key={p.id} value={p.id}>
{p.primary_name ?? "Unnamed"}
</option>
))}
</select>
</CardContent>
</Card>
))}
+22 -1
View File
@@ -86,8 +86,18 @@ export default function FamilyViewPage() {
}, [search, treeId]);
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
// Order parents deterministically: father (male) on top, mother below, with a
// stable fallback when gender is unknown (so it doesn't depend on which link
// happened to be created first).
const parentRank = (id: string) => {
const g = byId.get(id)?.gender;
return g === "male" ? 0 : g === "female" ? 1 : 2;
};
const parentsOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
rels
.filter((r) => r.type === "parent_child" && r.person_to_id === id)
.map((r) => r.person_from_id)
.sort((a, b) => parentRank(a) - parentRank(b));
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) =>
@@ -116,6 +126,12 @@ export default function FamilyViewPage() {
return data?.id ?? null;
}
// Create a new (blank) person and open their page to fill in details.
async function newPersonAndGo() {
const id = await addPerson("");
if (id) router.push(`/trees/${treeId}/persons/${id}`);
}
async function createFirst(e: React.FormEvent) {
e.preventDefault();
if (!firstName.trim()) return;
@@ -342,6 +358,10 @@ export default function FamilyViewPage() {
<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>
<div className="flex items-center gap-3">
<Button size="sm" onClick={newPersonAndGo}>
+ Add person
</Button>
<Link
href={`/trees/${treeId}/persons/${focus.id}`}
className="text-sm text-bronze hover:underline"
@@ -349,6 +369,7 @@ export default function FamilyViewPage() {
Open {focus.primary_name ?? "person"}
</Link>
</div>
</div>
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
<Card>
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
@@ -46,6 +46,9 @@ const EVENT_TYPES = [
"residence", "census", "immigration", "emigration", "occupation", "education",
"military service", "naturalization", "other",
];
// These belong to a couple, not a person — they attach to the partnership and
// show on both partners' pages, so they're only entered once.
const PARTNERSHIP_EVENTS = ["marriage", "divorce", "engagement"];
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
@@ -113,10 +116,15 @@ export default function PersonDetailPage() {
const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [citations, setCitations] = useState<Citation[]>([]);
const [media, setMedia] = useState<components["schemas"]["MediaRead"][]>([]);
const [uploadingMedia, setUploadingMedia] = useState(false);
const mediaFileRef = useRef<HTMLInputElement>(null);
const [ready, setReady] = useState(false);
const [evType, setEvType] = useState("birth");
const [evTypeOther, setEvTypeOther] = useState("");
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
const [allEvents, setAllEvents] = useState<Event[]>([]); // tree-wide, for partnership events
const [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState("");
@@ -169,7 +177,7 @@ export default function PersonDetailPage() {
return;
}
setPerson(p.data ?? null);
const [all, nm, mine, tr, ev, rl, src, cit] = await Promise.all([
const [all, nm, mine, tr, ev, rl, src, cit, evAll, med] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
@@ -184,12 +192,16 @@ export default function PersonDetailPage() {
}),
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
]);
setPeople(all.data ?? []);
setNames(nm.data ?? []);
setMe(mine.data ?? null);
setTree(tr.data ?? null);
setEvents(ev.data ?? []);
setAllEvents(evAll.data ?? []);
setMedia(med.data ?? []);
setRels(rl.data ?? []);
setSources(src.data ?? []);
setCitations(cit.data ?? []);
@@ -217,6 +229,23 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
// Partnership events live on the relationship and show on both partners.
const myPartnerRels = rels.filter(
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
);
const myPartnerRelIds = new Set(myPartnerRels.map((r) => r.id));
const relEvents = allEvents.filter(
(e) => e.relationship_id && myPartnerRelIds.has(e.relationship_id),
);
const spouseOfRelEvent = (relId: string | null | undefined) => {
const r = myPartnerRels.find((x) => x.id === relId);
if (!r) return null;
return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
};
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
// Personal events + this person's partnership events, shown together.
const shownEvents = [...events, ...relEvents];
async function addEvent(e: React.FormEvent) {
e.preventDefault();
const event_type = evType === "other" ? evTypeOther.trim() : evType;
@@ -227,9 +256,47 @@ export default function PersonDetailPage() {
dateMonth,
dateYear,
);
let body: components["schemas"]["EventCreate"] = {
event_type,
person_id: personId,
date_value,
date_start,
date_precision,
};
// A partnership event belongs to the couple: attach it to the partnership
// relationship (creating it if needed) so it's entered once and shows on
// both partners.
if (isPartnershipType(event_type)) {
if (!evSpouse) return;
let relId = myPartnerRels.find(
(r) => r.person_from_id === evSpouse || r.person_to_id === evSpouse,
)?.id;
if (!relId) {
const { data, error: relErr } = await api.POST(
"/api/v1/trees/{tree_id}/relationships",
{
params: { path: { tree_id: treeId } },
body: { type: "partnership", person_from_id: personId, person_to_id: evSpouse },
},
);
if (relErr || !data) return;
relId = data.id;
}
body = {
event_type,
relationship_id: relId,
person_id: null,
date_value,
date_start,
date_precision,
};
}
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } },
body: { event_type, person_id: personId, date_value, date_start, date_precision },
body,
});
if (!error) {
setDateDay("");
@@ -237,6 +304,7 @@ export default function PersonDetailPage() {
setDateYear("");
setDateQual("exact");
setEvTypeOther("");
setEvSpouse("");
load();
}
}
@@ -274,28 +342,47 @@ export default function PersonDetailPage() {
}
}
async function addRel(e: React.FormEvent) {
e.preventDefault();
if (!relOther) return;
async function linkRelative(otherId: string): Promise<boolean> {
let body: RelCreate;
if (relKind === "parent") {
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual };
body = { type: "parent_child", person_from_id: otherId, person_to_id: personId, qualifier: relQual };
} else if (relKind === "child") {
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual };
body = { type: "parent_child", person_from_id: personId, person_to_id: otherId, qualifier: relQual };
} else if (relKind === "partner") {
body = { type: "partnership", person_from_id: personId, person_to_id: relOther };
body = { type: "partnership", person_from_id: personId, person_to_id: otherId };
} else {
body = { type: "sibling", person_from_id: personId, person_to_id: relOther };
body = { type: "sibling", person_from_id: personId, person_to_id: otherId };
}
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
if (!error) {
return !error;
}
async function addRel(e: React.FormEvent) {
e.preventDefault();
if (!relOther) return;
if (await linkRelative(relOther)) {
setRelOther("");
load();
}
}
// Create a brand-new person, link them in the chosen role, then jump to their
// page so the user can fill in details immediately.
async function createRelativeAndGo(name: string) {
const toks = name.trim().split(/\s+/).filter(Boolean);
const given = toks.length > 1 ? toks.slice(0, -1).join(" ") : toks[0] ?? name.trim();
const surname = toks.length > 1 ? toks[toks.length - 1] : null;
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
body: { given, surname },
});
if (!data) return;
await linkRelative(data.id);
router.push(`/trees/${treeId}/persons/${data.id}`);
}
async function removeRel(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
params: { path: { tree_id: treeId, relationship_id: id } },
@@ -398,6 +485,31 @@ export default function PersonDetailPage() {
load();
}
async function uploadMediaForPerson(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (mediaFileRef.current) mediaFileRef.current.value = "";
if (!file) return;
setUploadingMedia(true);
const fd = new FormData();
fd.append("file", file);
fd.append("person_id", personId); // link on upload
await fetch(`/api/v1/trees/${treeId}/media`, {
method: "POST",
body: fd,
credentials: "include",
});
setUploadingMedia(false);
load();
}
async function linkMedia(mediaId: string, link: boolean) {
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
params: { path: { tree_id: treeId, media_id: mediaId } },
body: { person_id: link ? personId : null },
});
load();
}
function startEditPerson(current: Person) {
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
@@ -574,7 +686,19 @@ export default function PersonDetailPage() {
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="flex flex-wrap items-center gap-3 text-3xl font-semibold">
<span className="inline-flex items-center gap-2">
{person.primary_name ?? "Unnamed person"}
{person.gender === "male" && (
<span title="Male" aria-label="Male" style={{ color: "rgb(120, 159, 172)" }}>
</span>
)}
{person.gender === "female" && (
<span title="Female" aria-label="Female" style={{ color: "rgb(196, 138, 146)" }}>
</span>
)}
</span>
{isSelf && (
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
This is you
@@ -777,11 +901,11 @@ export default function PersonDetailPage() {
<CardTitle className="text-base">Life events</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{events.length === 0 ? (
{shownEvents.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No events yet.</p>
) : (
<ul className="space-y-2">
{events.map((ev) =>
{shownEvents.map((ev) =>
editId === ev.id ? (
<li key={ev.id}>
<form
@@ -850,9 +974,18 @@ export default function PersonDetailPage() {
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
<span className="font-medium capitalize">{ev.event_type}</span>
{ev.relationship_id ? (
<span className="text-[var(--muted)]">
{" "}
· with {nameOf(spouseOfRelEvent(ev.relationship_id) ?? "")}
</span>
) : null}
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
{ev.detail ? (
<span className="text-[var(--muted)]"> {ev.detail}</span>
) : null}
</span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
@@ -901,6 +1034,23 @@ export default function PersonDetailPage() {
/>
</label>
)}
{isPartnershipType(evType) && (
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
<select
className={fieldCls}
value={evSpouse}
onChange={(e) => setEvSpouse(e.target.value)}
>
<option value=""> choose </option>
{others.map((p) => (
<option key={p.id} value={p.id}>
{p.primary_name ?? "Unnamed"}
</option>
))}
</select>
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">When</span>
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
@@ -977,7 +1127,8 @@ export default function PersonDetailPage() {
people={others}
value={relOther}
onChange={setRelOther}
placeholder="Search for a person…"
onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/>
{(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
@@ -993,6 +1144,87 @@ export default function PersonDetailPage() {
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Media</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{(() => {
const personMedia = media.filter((m) => m.person_id === personId);
const unlinked = media.filter((m) => !m.person_id);
return (
<>
{personMedia.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No media linked to this person yet.</p>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{personMedia.map((m) => (
<div key={m.id} className="overflow-hidden rounded-lg border border-[var(--border)]">
<a href={m.url ?? "#"} target="_blank" rel="noreferrer" className="block">
{m.content_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.url ?? ""}
alt={m.title ?? m.original_filename}
className="aspect-square w-full object-cover"
/>
) : (
<div className="grid aspect-square w-full place-items-center bg-bronze/[0.06] font-serif text-2xl text-bronze">
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
</div>
)}
</a>
<div className="flex items-center justify-between gap-2 p-2">
<span className="truncate text-xs" title={m.original_filename}>
{m.title ?? m.original_filename}
</span>
<button
onClick={() => linkMedia(m.id, false)}
className="shrink-0 text-xs text-[var(--muted)] hover:text-bronze"
>
unlink
</button>
</div>
</div>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<input
ref={mediaFileRef}
type="file"
onChange={uploadMediaForPerson}
className="hidden"
/>
<Button
size="sm"
onClick={() => mediaFileRef.current?.click()}
disabled={uploadingMedia}
>
{uploadingMedia ? "Uploading…" : "Upload & link"}
</Button>
{unlinked.length > 0 && (
<select
className={fieldCls}
defaultValue=""
onChange={(e) => e.target.value && linkMedia(e.target.value, true)}
>
<option value="">Link existing media</option>
{unlinked.map((m) => (
<option key={m.id} value={m.id}>
{m.title ?? m.original_filename}
</option>
))}
</select>
)}
</div>
</>
);
})()}
</CardContent>
</Card>
</div>
);
}
+5
View File
@@ -530,6 +530,11 @@
.f3 .link {
transition: stroke-width 0.2s ease-in-out;
/* Brand-aware connector lines: dark on the light paper, light on dark. The
library's default white stroke is invisible on the light theme. */
fill: none;
stroke: var(--line);
stroke-width: 1.5px;
}
.f3 .link.f3-path-to-main {
+6 -1
View File
@@ -17,6 +17,7 @@ import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { cn } from "@/lib/utils";
import { ThemeToggle } from "@/components/theme-toggle";
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
@@ -136,7 +137,11 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
</div>
)}
<div ref={menuRef} className="relative mt-auto">
<div className="mt-auto">
<ThemeToggle />
</div>
<div ref={menuRef} className="relative">
{menuOpen && (
<div className="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
<Link
+1 -1
View File
@@ -79,7 +79,7 @@ export function FanChart({
<path
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
fill={id ? "var(--surface)" : "transparent"}
stroke="var(--border)"
stroke="var(--line)"
/>
{id && (
<text
+19 -1
View File
@@ -16,12 +16,15 @@ export function PersonCombobox({
people,
value,
onChange,
onCreate,
placeholder = "Search for a person…",
className,
}: {
people: Person[];
value: string;
onChange: (id: string) => void;
/** When set, the dropdown offers a "Create '<typed name>'" action. */
onCreate?: (name: string) => void;
placeholder?: string;
className?: string;
}) {
@@ -77,7 +80,7 @@ export function PersonCombobox({
if (value) onChange(""); // typing invalidates the prior pick
}}
/>
{open && matches.length > 0 && (
{open && (matches.length > 0 || (onCreate && query.trim())) && (
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{matches.map((p) => (
<li key={p.id}>
@@ -96,6 +99,21 @@ export function PersonCombobox({
</button>
</li>
))}
{onCreate && query.trim() && (
<li className={matches.length > 0 ? "border-t border-[var(--border)]" : ""}>
<button
type="button"
onClick={() => {
const name = query.trim();
setOpen(false);
onCreate(name);
}}
className="block w-full px-3 py-2 text-left text-sm text-bronze hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
>
+ Create {query.trim()}
</button>
</li>
)}
</ul>
)}
</div>
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
const ORDER: Theme[] = ["system", "light", "dark"];
const ICON = { system: Monitor, light: Sun, dark: Moon };
const LABEL = { system: "System", light: "Light", dark: "Dark" };
function apply(theme: Theme) {
const dark =
theme === "dark" ||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
}
/** Cycles theme System → Light → Dark, persisting the choice in localStorage. */
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const saved = (localStorage.getItem("theme") as Theme) || "system";
setTheme(saved);
}, []);
// Keep "system" in sync if the OS preference changes while selected.
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => apply("system");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [theme]);
function cycle() {
const next = ORDER[(ORDER.indexOf(theme) + 1) % ORDER.length];
localStorage.setItem("theme", next);
setTheme(next);
apply(next);
}
const Icon = ICON[theme];
return (
<button
onClick={cycle}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-[var(--foreground)]"
title={`Theme: ${LABEL[theme]} (click to change)`}
>
<Icon className="h-4 w-4 shrink-0" />
Theme: {LABEL[theme]}
</button>
);
}
+142 -1
View File
@@ -168,7 +168,12 @@ export interface paths {
get: operations["read_me_api_v1_users_me_get"];
put?: never;
post?: never;
delete?: never;
/**
* Delete Account
* @description Delete the account: the user, their owned trees, and their sessions.
* Requires retyping the account email as a guard.
*/
delete: operations["delete_account_api_v1_users_me_delete"];
options?: never;
head?: never;
patch?: never;
@@ -194,6 +199,46 @@ export interface paths {
patch: operations["set_self_person_api_v1_users_me_self_person_patch"];
trace?: never;
};
"/api/v1/users/me/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Export Account
* @description Download a full backup (JSON + media) of every tree the user owns.
*/
get: operations["export_account_api_v1_users_me_export_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/users/me/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Import Account
* @description Restore a previously-exported backup into new trees (non-destructive).
*/
post: operations["import_account_api_v1_users_me_import_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees": {
parameters: {
query?: never;
@@ -638,6 +683,16 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** Body_delete_account_api_v1_users_me_delete */
Body_delete_account_api_v1_users_me_delete: {
/** Confirm Email */
confirm_email: string;
};
/** Body_import_account_api_v1_users_me_import_post */
Body_import_account_api_v1_users_me_import_post: {
/** File */
file: string;
};
/** Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post */
Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
/** File */
@@ -1624,6 +1679,37 @@ export interface operations {
};
};
};
delete_account_api_v1_users_me_delete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/x-www-form-urlencoded": components["schemas"]["Body_delete_account_api_v1_users_me_delete"];
};
};
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"];
};
};
};
};
set_self_person_api_v1_users_me_self_person_patch: {
parameters: {
query?: never;
@@ -1657,6 +1743,61 @@ export interface operations {
};
};
};
export_account_api_v1_users_me_export_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
import_account_api_v1_users_me_import_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_import_account_api_v1_users_me_import_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_my_trees_api_v1_trees_get: {
parameters: {
query?: {
+124
View File
@@ -312,6 +312,39 @@
}
}
}
},
"delete": {
"tags": [
"users"
],
"summary": "Delete Account",
"description": "Delete the account: the user, their owned trees, and their sessions.\nRequires retyping the account email as a guard.",
"operationId": "delete_account_api_v1_users_me_delete",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/Body_delete_account_api_v1_users_me_delete"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/users/me/self-person": {
@@ -356,6 +389,70 @@
}
}
},
"/api/v1/users/me/export": {
"get": {
"tags": [
"users"
],
"summary": "Export Account",
"description": "Download a full backup (JSON + media) of every tree the user owns.",
"operationId": "export_account_api_v1_users_me_export_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/v1/users/me/import": {
"post": {
"tags": [
"users"
],
"summary": "Import Account",
"description": "Restore a previously-exported backup into new trees (non-destructive).",
"operationId": "import_account_api_v1_users_me_import_post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_import_account_api_v1_users_me_import_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": true,
"type": "object",
"title": "Response Import Account Api V1 Users Me Import Post"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees": {
"post": {
"tags": [
@@ -2608,6 +2705,33 @@
},
"components": {
"schemas": {
"Body_delete_account_api_v1_users_me_delete": {
"properties": {
"confirm_email": {
"type": "string",
"title": "Confirm Email"
}
},
"type": "object",
"required": [
"confirm_email"
],
"title": "Body_delete_account_api_v1_users_me_delete"
},
"Body_import_account_api_v1_users_me_import_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_import_account_api_v1_users_me_import_post"
},
"Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post": {
"properties": {
"file": {