commit b50a48ef0e3097892d3efe7844a5b661fc6e4692
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date: Sat, 2 May 2026 18:25:39 +0000
Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat:
14 files changed, 2589 insertions(+), 0 deletions(-)
diff --git a/.env.example b/.env.example
@@ -0,0 +1 @@
+DATABASE_URL=sqlite:////var/lib/familytree/familytree.db
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,6 @@
+venv/
+__pycache__/
+*.pyc
+*.pyo
+.env
+*.db
diff --git a/README.md b/README.md
@@ -0,0 +1,49 @@
+# Family Tree
+
+Self-hosted family tree builder, served at [familytree.piribauer.ch](https://familytree.piribauer.ch).
+
+## Stack
+
+- Backend: FastAPI (Python 3.10+) with SQLAlchemy and SQLite
+- Frontend: Vanilla JS/HTML/CSS with D3.js for the canvas (Nord theme)
+- Auth: Authentik forward-auth via nginx
+
+## Features
+
+Each user can create their own family tree. Others can be invited by email — invitations are matched against the Authentik email on login and accepted automatically. Trees have three roles: owner, editor, and viewer.
+
+The interface is a zoomable and pannable canvas. Each person is shown as a card; partnerships are drawn as horizontal connectors, parent-child links as vertical lines. Clicking a card opens a detail panel with full info (name, birth/death dates and places, burial place, biography) and a list of partners, children, parents, and siblings. Siblings are derived from shared parent links rather than stored as a direct relationship.
+
+When adding a new person, a connection dialog appears to immediately link them to an existing person as a partner, child, parent, or sibling.
+
+## Installation
+
+```console
+$ python -m venv venv
+$ venv/bin/pip install -r requirements.txt
+```
+
+Create the database directory:
+
+```console
+# mkdir -p /var/lib/familytree
+# chown julian:julian /var/lib/familytree
+```
+
+Create `.env` from `.env.example`, then install the service and nginx config:
+
+```console
+# cp familytree.service /etc/systemd/system/
+# systemctl daemon-reload && systemctl enable --now familytree
+# cp nginx/family /etc/nginx/sites-available/family
+# ln -s /etc/nginx/sites-available/family /etc/nginx/sites-enabled/family
+# nginx -t && systemctl reload nginx
+```
+
+## Schema migrations
+
+SQLAlchemy creates missing tables on startup but does not alter existing ones. Any new columns must be added manually, e.g.:
+
+```console
+$ sqlite3 /var/lib/familytree/familytree.db "ALTER TABLE persons ADD COLUMN notes TEXT;"
+```
diff --git a/app/__init__.py b/app/__init__.py
diff --git a/app/database.py b/app/database.py
@@ -0,0 +1,19 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, DeclarativeBase
+import os
+
+DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////var/lib/familytree/familytree.db")
+engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
+SessionLocal = sessionmaker(bind=engine)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/app/main.py b/app/main.py
@@ -0,0 +1,394 @@
+from fastapi import FastAPI, Request, Depends, HTTPException
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+from sqlalchemy.orm import Session
+from sqlalchemy.exc import IntegrityError
+import os
+
+from .database import get_db, engine
+from .models import Base, Tree, TreeMembership, PendingInvitation, Person, Partnership, ParentLink
+from .schemas import (
+ TreeCreate, TreeRename, InviteRequest, RoleUpdate,
+ PersonCreate, PersonUpdate,
+ PartnershipCreate, PartnershipUpdate,
+ ParentLinkCreate,
+)
+
+Base.metadata.create_all(bind=engine)
+
+app = FastAPI(title="Arbor")
+
+STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
+app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+
+
+@app.middleware("http")
+async def no_cache_static(request: Request, call_next):
+ response = await call_next(request)
+ if request.url.path.startswith("/static/"):
+ response.headers["Cache-Control"] = "no-cache"
+ return response
+
+
+# ── Auth helpers ──────────────────────────────────────────────────────────────
+
+def _get_identity(request: Request) -> tuple[str, str]:
+ username = request.headers.get("x-authentik-username", "")
+ email = request.headers.get("x-authentik-email", "")
+ if not username:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ return username, email
+
+
+def _accept_pending_invitations(db: Session, username: str, email: str) -> None:
+ if not email:
+ return
+ pending = db.query(PendingInvitation).filter(PendingInvitation.invitee_email == email).all()
+ for inv in pending:
+ try:
+ db.add(TreeMembership(tree_id=inv.tree_id, username=username, role=inv.role))
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ db.delete(inv)
+ db.commit()
+
+
+def _require_member(db: Session, tree_id: int, username: str) -> tuple[Tree, TreeMembership]:
+ tree = db.query(Tree).filter(Tree.id == tree_id).first()
+ if not tree:
+ raise HTTPException(status_code=404, detail="Tree not found")
+ m = db.query(TreeMembership).filter(
+ TreeMembership.tree_id == tree_id,
+ TreeMembership.username == username,
+ ).first()
+ if not m:
+ raise HTTPException(status_code=403, detail="Access denied")
+ return tree, m
+
+
+def _require_editor(db: Session, tree_id: int, username: str) -> Tree:
+ tree, m = _require_member(db, tree_id, username)
+ if m.role not in ("owner", "editor"):
+ raise HTTPException(status_code=403, detail="Editor access required")
+ return tree
+
+
+def _require_owner(db: Session, tree_id: int, username: str) -> Tree:
+ tree, m = _require_member(db, tree_id, username)
+ if m.role != "owner":
+ raise HTTPException(status_code=403, detail="Only the owner can do this")
+ return tree
+
+
+def _person_dict(p: Person) -> dict:
+ return {
+ "id": p.id,
+ "tree_id": p.tree_id,
+ "first_name": p.first_name,
+ "last_name": p.last_name,
+ "birth_date": p.birth_date,
+ "death_date": p.death_date,
+ "birth_place": p.birth_place,
+ "burial_place": p.burial_place,
+ "bio": p.bio,
+ }
+
+
+def _partnership_dict(ps: Partnership) -> dict:
+ return {
+ "id": ps.id,
+ "tree_id": ps.tree_id,
+ "person1_id": ps.person1_id,
+ "person2_id": ps.person2_id,
+ "union_date": ps.union_date,
+ "union_end_date": ps.union_end_date,
+ "union_type": ps.union_type,
+ }
+
+
+def _parentlink_dict(pl: ParentLink) -> dict:
+ return {
+ "id": pl.id,
+ "child_id": pl.child_id,
+ "partnership_id": pl.partnership_id,
+ "parent_id": pl.parent_id,
+ }
+
+
+# ── Routes ────────────────────────────────────────────────────────────────────
+
+@app.get("/", include_in_schema=False)
+async def root():
+ return FileResponse(os.path.join(STATIC_DIR, "index.html"))
+
+
+@app.get("/api/me")
+def get_me(request: Request, db: Session = Depends(get_db)):
+ username, email = _get_identity(request)
+ _accept_pending_invitations(db, username, email)
+ return {"username": username, "email": email}
+
+
+@app.get("/api/trees")
+def list_trees(request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ memberships = db.query(TreeMembership).filter(TreeMembership.username == username).all()
+ result = []
+ for m in memberships:
+ tree = m.tree
+ members = [{"username": x.username, "role": x.role} for x in tree.memberships]
+ pending = [{"email": p.invitee_email, "role": p.role} for p in tree.pending_invitations]
+ result.append({
+ "id": tree.id,
+ "name": tree.name,
+ "role": m.role,
+ "is_owner": tree.owner_username == username,
+ "members": members,
+ "pending_invitations": pending if tree.owner_username == username else [],
+ })
+ return result
+
+
+@app.post("/api/trees", status_code=201)
+def create_tree(body: TreeCreate, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ tree = Tree(name=body.name, owner_username=username)
+ db.add(tree)
+ db.flush()
+ db.add(TreeMembership(tree_id=tree.id, username=username, role="owner"))
+ db.commit()
+ db.refresh(tree)
+ return {"id": tree.id, "name": tree.name, "role": "owner"}
+
+
+@app.patch("/api/trees/{tree_id}")
+def rename_tree(tree_id: int, body: TreeRename, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ tree = _require_owner(db, tree_id, username)
+ tree.name = body.name
+ db.commit()
+ return {"id": tree.id, "name": tree.name}
+
+
+@app.delete("/api/trees/{tree_id}", status_code=204)
+def delete_tree(tree_id: int, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ tree = _require_owner(db, tree_id, username)
+ db.delete(tree)
+ db.commit()
+
+
+@app.post("/api/trees/{tree_id}/invite", status_code=201)
+def invite_member(tree_id: int, body: InviteRequest, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_owner(db, tree_id, username)
+ email = body.email.strip().lower()
+ if body.role not in ("editor", "viewer"):
+ raise HTTPException(status_code=422, detail="role must be editor or viewer")
+ try:
+ db.add(PendingInvitation(tree_id=tree_id, invitee_email=email, role=body.role))
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="Already invited")
+ return {"invited": email, "role": body.role}
+
+
+@app.delete("/api/trees/{tree_id}/invitations/{email}", status_code=204)
+def revoke_invitation(tree_id: int, email: str, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_owner(db, tree_id, username)
+ inv = db.query(PendingInvitation).filter(
+ PendingInvitation.tree_id == tree_id,
+ PendingInvitation.invitee_email == email,
+ ).first()
+ if inv:
+ db.delete(inv)
+ db.commit()
+
+
+@app.patch("/api/trees/{tree_id}/members/{member_username}")
+def update_member_role(tree_id: int, member_username: str, body: RoleUpdate, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_owner(db, tree_id, username)
+ if body.role not in ("editor", "viewer"):
+ raise HTTPException(status_code=422, detail="role must be editor or viewer")
+ m = db.query(TreeMembership).filter(
+ TreeMembership.tree_id == tree_id,
+ TreeMembership.username == member_username,
+ ).first()
+ if not m:
+ raise HTTPException(status_code=404)
+ if m.role == "owner":
+ raise HTTPException(status_code=400, detail="Cannot change owner role")
+ m.role = body.role
+ db.commit()
+ return {"username": member_username, "role": m.role}
+
+
+@app.delete("/api/trees/{tree_id}/members/{member_username}", status_code=204)
+def remove_member(tree_id: int, member_username: str, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_owner(db, tree_id, username)
+ if member_username == username:
+ raise HTTPException(status_code=400, detail="Cannot remove yourself as owner")
+ m = db.query(TreeMembership).filter(
+ TreeMembership.tree_id == tree_id,
+ TreeMembership.username == member_username,
+ ).first()
+ if m:
+ db.delete(m)
+ db.commit()
+
+
+@app.delete("/api/trees/{tree_id}/leave", status_code=204)
+def leave_tree(tree_id: int, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ tree, m = _require_member(db, tree_id, username)
+ if tree.owner_username == username:
+ raise HTTPException(status_code=400, detail="Owner cannot leave; delete the tree instead")
+ db.delete(m)
+ db.commit()
+
+
+# ── Graph ─────────────────────────────────────────────────────────────────────
+
+@app.get("/api/trees/{tree_id}/graph")
+def get_graph(tree_id: int, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_member(db, tree_id, username)
+ people = db.query(Person).filter(Person.tree_id == tree_id).all()
+ partnerships = db.query(Partnership).filter(Partnership.tree_id == tree_id).all()
+ person_ids = {p.id for p in people}
+ parent_links = db.query(ParentLink).filter(ParentLink.child_id.in_(person_ids)).all() if person_ids else []
+ return {
+ "people": [_person_dict(p) for p in people],
+ "partnerships": [_partnership_dict(ps) for ps in partnerships],
+ "parent_links": [_parentlink_dict(pl) for pl in parent_links],
+ }
+
+
+# ── People ────────────────────────────────────────────────────────────────────
+
+@app.post("/api/trees/{tree_id}/people", status_code=201)
+def add_person(tree_id: int, body: PersonCreate, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_editor(db, tree_id, username)
+ p = Person(tree_id=tree_id, **body.model_dump())
+ db.add(p)
+ db.commit()
+ db.refresh(p)
+ return _person_dict(p)
+
+
+@app.patch("/api/trees/{tree_id}/people/{person_id}")
+def update_person(tree_id: int, person_id: int, body: PersonUpdate, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_editor(db, tree_id, username)
+ p = db.query(Person).filter(Person.id == person_id, Person.tree_id == tree_id).first()
+ if not p:
+ raise HTTPException(status_code=404)
+ for field, val in body.model_dump(exclude_unset=True).items():
+ setattr(p, field, val)
+ db.commit()
+ db.refresh(p)
+ return _person_dict(p)
+
+
+@app.delete("/api/trees/{tree_id}/people/{person_id}", status_code=204)
+def delete_person(tree_id: int, person_id: int, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_editor(db, tree_id, username)
+ p = db.query(Person).filter(Person.id == person_id, Person.tree_id == tree_id).first()
+ if not p:
+ raise HTTPException(status_code=404)
+ # remove partnerships involving this person
+ db.query(Partnership).filter(
+ (Partnership.person1_id == person_id) | (Partnership.person2_id == person_id)
+ ).delete(synchronize_session=False)
+ db.delete(p)
+ db.commit()
+
+
+# ── Partnerships ──────────────────────────────────────────────────────────────
+
+@app.post("/api/trees/{tree_id}/partnerships", status_code=201)
+def add_partnership(tree_id: int, body: PartnershipCreate, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_editor(db, tree_id, username)
+ for pid in (body.person1_id, body.person2_id):
+ if not db.query(Person).filter(Person.id == pid, Person.tree_id == tree_id).first():
+ raise HTTPException(status_code=404, detail=f"Person {pid} not in tree")
+ if body.union_type not in ("married", "partnered", "separated"):
+ raise HTTPException(status_code=422, detail="Invalid union_type")
+ try:
+ ps = Partnership(tree_id=tree_id, **body.model_dump())
+ db.add(ps)
+ db.commit()
+ db.refresh(ps)
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="Partnership already exists")
+ return _partnership_dict(ps)
+
+
+@app.patch("/api/trees/{tree_id}/partnerships/{partnership_id}")
+def update_partnership(tree_id: int, partnership_id: int, body: PartnershipUpdate, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_editor(db, tree_id, username)
+ ps = db.query(Partnership).filter(Partnership.id == partnership_id, Partnership.tree_id == tree_id).first()
+ if not ps:
+ raise HTTPException(status_code=404)
+ for field, val in body.model_dump(exclude_unset=True).items():
+ setattr(ps, field, val)
+ db.commit()
+ return _partnership_dict(ps)
+
+
+@app.delete("/api/trees/{tree_id}/partnerships/{partnership_id}", status_code=204)
+def delete_partnership(tree_id: int, partnership_id: int, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_editor(db, tree_id, username)
+ ps = db.query(Partnership).filter(Partnership.id == partnership_id, Partnership.tree_id == tree_id).first()
+ if not ps:
+ raise HTTPException(status_code=404)
+ db.delete(ps)
+ db.commit()
+
+
+# ── Parent links ──────────────────────────────────────────────────────────────
+
+@app.post("/api/trees/{tree_id}/parentlinks", status_code=201)
+def add_parent_link(tree_id: int, body: ParentLinkCreate, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_editor(db, tree_id, username)
+ if (body.partnership_id is None) == (body.parent_id is None):
+ raise HTTPException(status_code=422, detail="Provide exactly one of partnership_id or parent_id")
+ if not db.query(Person).filter(Person.id == body.child_id, Person.tree_id == tree_id).first():
+ raise HTTPException(status_code=404, detail="Child not in tree")
+ if body.partnership_id:
+ if not db.query(Partnership).filter(Partnership.id == body.partnership_id, Partnership.tree_id == tree_id).first():
+ raise HTTPException(status_code=404, detail="Partnership not in tree")
+ if body.parent_id:
+ if not db.query(Person).filter(Person.id == body.parent_id, Person.tree_id == tree_id).first():
+ raise HTTPException(status_code=404, detail="Parent not in tree")
+ pl = ParentLink(**body.model_dump())
+ db.add(pl)
+ db.commit()
+ db.refresh(pl)
+ return _parentlink_dict(pl)
+
+
+@app.delete("/api/trees/{tree_id}/parentlinks/{link_id}", status_code=204)
+def delete_parent_link(tree_id: int, link_id: int, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_editor(db, tree_id, username)
+ pl = db.query(ParentLink).filter(ParentLink.id == link_id).first()
+ if not pl:
+ raise HTTPException(status_code=404)
+ child = db.query(Person).filter(Person.id == pl.child_id, Person.tree_id == tree_id).first()
+ if not child:
+ raise HTTPException(status_code=403)
+ db.delete(pl)
+ db.commit()
diff --git a/app/models.py b/app/models.py
@@ -0,0 +1,91 @@
+from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, CheckConstraint
+from sqlalchemy.orm import relationship
+from .database import Base
+
+
+class Tree(Base):
+ __tablename__ = "trees"
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String, nullable=False)
+ owner_username = Column(String, nullable=False)
+
+ memberships = relationship("TreeMembership", back_populates="tree", cascade="all, delete-orphan")
+ pending_invitations = relationship("PendingInvitation", back_populates="tree", cascade="all, delete-orphan")
+ people = relationship("Person", back_populates="tree", cascade="all, delete-orphan")
+ partnerships = relationship("Partnership", back_populates="tree", cascade="all, delete-orphan")
+
+
+class TreeMembership(Base):
+ __tablename__ = "tree_memberships"
+
+ id = Column(Integer, primary_key=True)
+ tree_id = Column(Integer, ForeignKey("trees.id"), nullable=False)
+ username = Column(String, nullable=False)
+ role = Column(String, nullable=False, default="viewer") # owner | editor | viewer
+
+ __table_args__ = (UniqueConstraint("tree_id", "username"),)
+
+ tree = relationship("Tree", back_populates="memberships")
+
+
+class PendingInvitation(Base):
+ __tablename__ = "pending_invitations"
+
+ id = Column(Integer, primary_key=True)
+ tree_id = Column(Integer, ForeignKey("trees.id"), nullable=False)
+ invitee_email = Column(String, nullable=False)
+ role = Column(String, nullable=False, default="viewer")
+
+ __table_args__ = (UniqueConstraint("tree_id", "invitee_email"),)
+
+ tree = relationship("Tree", back_populates="pending_invitations")
+
+
+class Person(Base):
+ __tablename__ = "people"
+
+ id = Column(Integer, primary_key=True)
+ tree_id = Column(Integer, ForeignKey("trees.id"), nullable=False)
+ first_name = Column(String, nullable=False)
+ last_name = Column(String, nullable=True)
+ birth_date = Column(String, nullable=True)
+ death_date = Column(String, nullable=True)
+ birth_place = Column(String, nullable=True)
+ burial_place = Column(String, nullable=True)
+ bio = Column(String, nullable=True)
+
+ tree = relationship("Tree", back_populates="people")
+ parent_links = relationship("ParentLink", foreign_keys="ParentLink.child_id", back_populates="child", cascade="all, delete-orphan")
+
+
+class Partnership(Base):
+ __tablename__ = "partnerships"
+
+ id = Column(Integer, primary_key=True)
+ tree_id = Column(Integer, ForeignKey("trees.id"), nullable=False)
+ person1_id = Column(Integer, ForeignKey("people.id"), nullable=False)
+ person2_id = Column(Integer, ForeignKey("people.id"), nullable=False)
+ union_date = Column(String, nullable=True)
+ union_end_date = Column(String, nullable=True)
+ union_type = Column(String, nullable=False, default="partnered") # married | partnered | separated
+
+ __table_args__ = (UniqueConstraint("person1_id", "person2_id"),)
+
+ tree = relationship("Tree", back_populates="partnerships")
+ person1 = relationship("Person", foreign_keys=[person1_id])
+ person2 = relationship("Person", foreign_keys=[person2_id])
+ parent_links = relationship("ParentLink", foreign_keys="ParentLink.partnership_id", back_populates="partnership", cascade="all, delete-orphan")
+
+
+class ParentLink(Base):
+ __tablename__ = "parent_links"
+
+ id = Column(Integer, primary_key=True)
+ child_id = Column(Integer, ForeignKey("people.id"), nullable=False)
+ partnership_id = Column(Integer, ForeignKey("partnerships.id"), nullable=True)
+ parent_id = Column(Integer, ForeignKey("people.id"), nullable=True)
+
+ child = relationship("Person", foreign_keys=[child_id], back_populates="parent_links")
+ partnership = relationship("Partnership", foreign_keys=[partnership_id], back_populates="parent_links")
+ parent = relationship("Person", foreign_keys=[parent_id])
diff --git a/app/schemas.py b/app/schemas.py
@@ -0,0 +1,58 @@
+from pydantic import BaseModel
+
+
+class TreeCreate(BaseModel):
+ name: str
+
+
+class TreeRename(BaseModel):
+ name: str
+
+
+class InviteRequest(BaseModel):
+ email: str
+ role: str = "viewer"
+
+
+class RoleUpdate(BaseModel):
+ role: str
+
+
+class PersonCreate(BaseModel):
+ first_name: str
+ last_name: str | None = None
+ birth_date: str | None = None
+ death_date: str | None = None
+ birth_place: str | None = None
+ burial_place: str | None = None
+ bio: str | None = None
+
+
+class PersonUpdate(BaseModel):
+ first_name: str | None = None
+ last_name: str | None = None
+ birth_date: str | None = None
+ death_date: str | None = None
+ birth_place: str | None = None
+ burial_place: str | None = None
+ bio: str | None = None
+
+
+class PartnershipCreate(BaseModel):
+ person1_id: int
+ person2_id: int
+ union_date: str | None = None
+ union_end_date: str | None = None
+ union_type: str = "partnered"
+
+
+class PartnershipUpdate(BaseModel):
+ union_date: str | None = None
+ union_end_date: str | None = None
+ union_type: str | None = None
+
+
+class ParentLinkCreate(BaseModel):
+ child_id: int
+ partnership_id: int | None = None
+ parent_id: int | None = None
diff --git a/familytree.service b/familytree.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=Family Tree web app
+After=network.target
+
+[Service]
+Type=simple
+User=julian
+WorkingDirectory=/home/julian/familytree
+EnvironmentFile=/home/julian/familytree/.env
+ExecStart=/home/julian/familytree/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8766 --no-access-log
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/nginx/family b/nginx/family
@@ -0,0 +1,57 @@
+server {
+ server_name familytree.piribauer.ch;
+
+ location /outpost.goauthentik.io {
+ proxy_pass http://127.0.0.1:9000/outpost.goauthentik.io;
+ proxy_set_header Host $host;
+ proxy_set_header X-Original-URL $scheme://$host$request_uri;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass_request_body off;
+ proxy_set_header Content-Length "";
+ add_header Set-Cookie $auth_cookie;
+ auth_request_set $auth_cookie $upstream_http_set_cookie;
+ }
+
+ location @goauthentik_proxy_signin {
+ internal;
+ add_header Set-Cookie $auth_cookie;
+ return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8766;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ auth_request /outpost.goauthentik.io/auth/nginx;
+ error_page 401 = @goauthentik_proxy_signin;
+
+ auth_request_set $auth_cookie $upstream_http_set_cookie;
+ auth_request_set $authentik_username $upstream_http_x_authentik_username;
+ auth_request_set $authentik_email $upstream_http_x_authentik_email;
+
+ add_header Set-Cookie $auth_cookie;
+
+ proxy_set_header X-Authentik-Username $authentik_username;
+ proxy_set_header X-Authentik-Email $authentik_email;
+ }
+
+ listen 443 ssl;
+ ssl_certificate /etc/letsencrypt/live/piribauer.ch/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/piribauer.ch/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+}
+
+server {
+ if ($host = familytree.piribauer.ch) {
+ return 301 https://$host$request_uri;
+ }
+ listen 80;
+ server_name familytree.piribauer.ch;
+ return 404;
+}
diff --git a/requirements.txt b/requirements.txt
@@ -0,0 +1,5 @@
+fastapi>=0.110.0
+uvicorn[standard]>=0.29.0
+sqlalchemy>=2.0.0
+pydantic>=2.0.0
+python-dotenv>=1.0.0
diff --git a/static/app.js b/static/app.js
@@ -0,0 +1,950 @@
+/* ── State ── */
+const state = {
+ username: '',
+ email: '',
+ trees: [],
+ activeTreeId: null,
+ graph: { people: [], partnerships: [], parentLinks: [] },
+ selectedPersonId: null,
+ canEdit: false,
+ _editingPersonId: null,
+ _editingPartnershipId: null,
+ _ctxTreeId: null,
+ _connectingPersonId: null,
+ _siblingMode: false,
+ _siblingModePersonId: null,
+};
+
+/* ── API helpers ── */
+async function api(method, path, body) {
+ const opts = { method, headers: {} };
+ if (body !== undefined) {
+ opts.headers['Content-Type'] = 'application/json';
+ opts.body = JSON.stringify(body);
+ }
+ const r = await fetch(path, opts);
+ if (!r.ok) {
+ const err = await r.json().catch(() => ({ detail: r.statusText }));
+ throw new Error(err.detail || r.statusText);
+ }
+ if (r.status === 204) return null;
+ return r.json();
+}
+
+function showError(msg) {
+ console.error(msg);
+ const el = document.createElement('div');
+ el.style.cssText = 'position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%);background:var(--red);color:#fff;padding:0.6rem 1.2rem;border-radius:8px;font-size:0.875rem;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,0.4);white-space:nowrap';
+ el.textContent = msg;
+ document.body.appendChild(el);
+ setTimeout(() => el.remove(), 4000);
+}
+
+/* ── Boot ── */
+async function boot() {
+ try {
+ const me = await api('GET', '/api/me');
+ state.username = me.username;
+ state.email = me.email;
+ document.getElementById('topbarUsername').textContent = me.username;
+ await loadTrees();
+ } catch(e) {
+ showError('Failed to load: ' + e.message);
+ }
+}
+
+async function loadTrees() {
+ state.trees = await api('GET', '/api/trees');
+ renderSidebar();
+ if (state.activeTreeId) {
+ const still = state.trees.find(t => t.id === state.activeTreeId);
+ if (still) await loadGraph(state.activeTreeId);
+ else selectTree(null);
+ }
+}
+
+/* ── Sidebar ── */
+function renderSidebar() {
+ const list = document.getElementById('treeList');
+ list.innerHTML = '';
+ for (const t of state.trees) {
+ const item = document.createElement('div');
+ item.className = 'tree-item' + (t.id === state.activeTreeId ? ' active' : '');
+ item.dataset.id = t.id;
+ item.innerHTML = `
+ <svg class="tree-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+ <circle cx="12" cy="5" r="2.5"/>
+ <line x1="12" y1="7.5" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+ <line x1="12" y1="11" x2="7" y2="16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+ <line x1="12" y1="11" x2="17" y2="16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+ <circle cx="7" cy="18" r="2"/>
+ <circle cx="17" cy="18" r="2"/>
+ </svg>
+ <span class="tree-item-name">${esc(t.name)}</span>
+ <span class="tree-item-role">${t.role}</span>`;
+ item.addEventListener('click', () => selectTree(t.id));
+ item.addEventListener('contextmenu', e => openCtxMenu(e, t.id));
+ list.appendChild(item);
+ }
+}
+
+async function selectTree(id) {
+ state.activeTreeId = id;
+ closeDetailPanel();
+ renderSidebar();
+ if (!id) { showEmptyState(); return; }
+ try {
+ await loadGraph(id);
+ } catch(e) {
+ showError('Could not load tree: ' + e.message);
+ }
+}
+
+async function loadGraph(treeId) {
+ const data = await api('GET', `/api/trees/${treeId}/graph`);
+ state.graph = {
+ people: data.people,
+ partnerships: data.partnerships,
+ parentLinks: data.parent_links,
+ };
+ const tree = state.trees.find(t => t.id === treeId);
+ state.canEdit = tree && (tree.role === 'owner' || tree.role === 'editor');
+ renderCanvas();
+}
+
+/* ── Canvas / D3 ── */
+let svg, gMain, zoom;
+const NODE_W = 160, NODE_H = 62, GEN_Y = 130, COUPLE_GAP = 40;
+
+function showEmptyState() {
+ document.getElementById('emptyState').style.display = 'flex';
+ document.getElementById('treeCanvas').style.display = 'none';
+ document.getElementById('canvasToolbar').style.display = 'none';
+}
+
+function renderCanvas() {
+ document.getElementById('emptyState').style.display = 'none';
+ document.getElementById('canvasToolbar').style.display = 'flex';
+ document.getElementById('btnAddPerson').style.display = state.canEdit ? '' : 'none';
+
+ const canvasSvg = document.getElementById('treeCanvas');
+ canvasSvg.style.display = 'block';
+
+ if (!svg) {
+ svg = d3.select('#treeCanvas');
+ gMain = svg.append('g').attr('class', 'canvas-root');
+ zoom = d3.zoom().scaleExtent([0.1, 3]).on('zoom', e => gMain.attr('transform', e.transform));
+ svg.call(zoom);
+ }
+
+ gMain.selectAll('*').remove();
+
+ if (state.graph.people.length === 0) {
+ gMain.append('text')
+ .attr('x', 0).attr('y', 0)
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'var(--polar4)')
+ .attr('font-size', 14)
+ .attr('font-family', 'Inter, sans-serif')
+ .text('No people yet — use "Add person" to get started');
+ requestAnimationFrame(fitCanvas);
+ return;
+ }
+
+ const positions = computeLayout(state.graph);
+ drawEdges(positions);
+ drawNodes(positions);
+ // Defer so the browser lays out the SVG before we measure bounding boxes
+ requestAnimationFrame(fitCanvas);
+}
+
+/* ── fitCanvas: reads dimensions from the parent div, not the SVG element ── */
+function fitCanvas() {
+ const area = document.getElementById('canvasArea');
+ const W = area.clientWidth, H = area.clientHeight;
+ if (!W || !H) return;
+ const bb = gMain.node().getBBox();
+ if (!bb.width || !bb.height) {
+ svg.call(zoom.transform, d3.zoomIdentity.translate(W / 2, H / 2));
+ return;
+ }
+ const scale = Math.min(0.9, Math.min(W / (bb.width + 80), H / (bb.height + 80)));
+ const tx = W / 2 - scale * (bb.x + bb.width / 2);
+ const ty = H / 2 - scale * (bb.y + bb.height / 2);
+ svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
+}
+
+function computeLayout({ people, partnerships, parentLinks }) {
+ const pos = {};
+
+ const partnershipChildren = {};
+ const singleParentChildren = {};
+ for (const pl of parentLinks) {
+ if (pl.partnership_id) {
+ if (!partnershipChildren[pl.partnership_id]) partnershipChildren[pl.partnership_id] = [];
+ partnershipChildren[pl.partnership_id].push(pl.child_id);
+ } else if (pl.parent_id) {
+ if (!singleParentChildren[pl.parent_id]) singleParentChildren[pl.parent_id] = [];
+ singleParentChildren[pl.parent_id].push(pl.child_id);
+ }
+ }
+
+ const childIds = new Set(parentLinks.map(pl => pl.child_id));
+ const gen = {};
+ const roots = people.filter(p => !childIds.has(p.id));
+ const queue = roots.map(p => ({ id: p.id, g: 0 }));
+ const visited = new Set();
+
+ while (queue.length) {
+ const { id, g } = queue.shift();
+ if (visited.has(id)) continue;
+ visited.add(id);
+ gen[id] = g;
+ for (const ps of partnerships) {
+ if (ps.person1_id === id || ps.person2_id === id) {
+ for (const cid of (partnershipChildren[ps.id] || [])) {
+ if (!visited.has(cid)) queue.push({ id: cid, g: g + 1 });
+ }
+ }
+ }
+ for (const cid of (singleParentChildren[id] || [])) {
+ if (!visited.has(cid)) queue.push({ id: cid, g: g + 1 });
+ }
+ }
+ for (const p of people) { if (gen[p.id] === undefined) gen[p.id] = 0; }
+
+ const byGen = {};
+ for (const p of people) {
+ const g = gen[p.id];
+ if (!byGen[g]) byGen[g] = [];
+ byGen[g].push(p.id);
+ }
+
+ const assignedX = {};
+ for (const g of Object.keys(byGen).sort((a, b) => Number(a) - Number(b))) {
+ const ids = byGen[g];
+ const placed = new Set();
+ const groups = [];
+ for (const id of ids) {
+ if (placed.has(id)) continue;
+ const partnerPs = partnerships.find(ps =>
+ (ps.person1_id === id || ps.person2_id === id) &&
+ gen[ps.person1_id] === gen[ps.person2_id]
+ );
+ if (partnerPs) {
+ const other = partnerPs.person1_id === id ? partnerPs.person2_id : partnerPs.person1_id;
+ if (!placed.has(other) && ids.includes(other)) {
+ groups.push([id, other]);
+ placed.add(id); placed.add(other);
+ continue;
+ }
+ }
+ groups.push([id]);
+ placed.add(id);
+ }
+
+ let totalWidth = -30;
+ for (const grp of groups) totalWidth += (grp.length === 2 ? NODE_W * 2 + COUPLE_GAP : NODE_W) + 30;
+ let x = -totalWidth / 2;
+ for (const grp of groups) {
+ if (grp.length === 2) {
+ assignedX[grp[0]] = x + NODE_W / 2;
+ assignedX[grp[1]] = x + NODE_W + COUPLE_GAP + NODE_W / 2;
+ x += NODE_W * 2 + COUPLE_GAP + 30;
+ } else {
+ assignedX[grp[0]] = x + NODE_W / 2;
+ x += NODE_W + 30;
+ }
+ }
+ }
+
+ for (const p of people) {
+ pos[p.id] = { x: assignedX[p.id] ?? 0, y: gen[p.id] * GEN_Y };
+ }
+ return pos;
+}
+
+function drawEdges(pos) {
+ const g = gMain.append('g').attr('class', 'edges');
+ const { partnerships, parentLinks } = state.graph;
+
+ for (const ps of partnerships) {
+ const p1 = pos[ps.person1_id], p2 = pos[ps.person2_id];
+ if (!p1 || !p2) continue;
+ const mx = (p1.x + p2.x) / 2;
+ const midY = p1.y + NODE_H / 2;
+
+ g.append('line').attr('class', 'edge-line')
+ .attr('x1', p1.x + NODE_W / 2).attr('y1', midY)
+ .attr('x2', p2.x - NODE_W / 2).attr('y2', midY);
+
+ const dot = g.append('circle').attr('class', 'partner-dot')
+ .attr('cx', mx).attr('cy', midY).attr('r', 6);
+ dot.on('click', () => state.canEdit && openPartnershipDetail(ps));
+
+ const children = parentLinks.filter(pl => pl.partnership_id === ps.id).map(pl => pl.child_id);
+ if (children.length) {
+ const dropY = midY + 40;
+ g.append('line').attr('class', 'edge-line')
+ .attr('x1', mx).attr('y1', midY + 6)
+ .attr('x2', mx).attr('y2', dropY);
+ const childXs = children.map(cid => pos[cid]?.x ?? mx);
+ if (children.length > 1) {
+ g.append('line').attr('class', 'edge-line')
+ .attr('x1', Math.min(...childXs)).attr('y1', dropY)
+ .attr('x2', Math.max(...childXs)).attr('y2', dropY);
+ }
+ for (const cid of children) {
+ const cp = pos[cid]; if (!cp) continue;
+ g.append('line').attr('class', 'edge-line')
+ .attr('x1', cp.x).attr('y1', dropY)
+ .attr('x2', cp.x).attr('y2', cp.y);
+ }
+ }
+ }
+
+ for (const pl of parentLinks.filter(p => p.parent_id)) {
+ const pp = pos[pl.parent_id], cp = pos[pl.child_id];
+ if (!pp || !cp) continue;
+ g.append('path').attr('class', 'edge-line')
+ .attr('d', `M${pp.x},${pp.y + NODE_H} C${pp.x},${pp.y + NODE_H + 40} ${cp.x},${cp.y - 40} ${cp.x},${cp.y}`);
+ }
+}
+
+function drawNodes(pos) {
+ const g = gMain.append('g').attr('class', 'nodes');
+ for (const person of state.graph.people) {
+ const { x, y } = pos[person.id] ?? { x: 0, y: 0 };
+ const grp = g.append('g')
+ .attr('transform', `translate(${x - NODE_W / 2},${y})`)
+ .attr('cursor', 'pointer')
+ .on('click', () => openDetailPanel(person.id));
+
+ const rect = grp.append('rect')
+ .attr('width', NODE_W).attr('height', NODE_H)
+ .attr('rx', 8).attr('ry', 8)
+ .attr('fill', 'var(--polar3)')
+ .attr('stroke', person.id === state.selectedPersonId ? 'var(--lightblue)' : 'var(--polar4)')
+ .attr('stroke-width', 1);
+
+ grp.on('mouseenter', () => rect.attr('stroke', 'var(--lightblue)'))
+ .on('mouseleave', () => rect.attr('stroke', person.id === state.selectedPersonId ? 'var(--lightblue)' : 'var(--polar4)'));
+
+ const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ');
+ grp.append('text')
+ .attr('x', NODE_W / 2).attr('y', 22)
+ .attr('text-anchor', 'middle')
+ .attr('font-family', "'EB Garamond', Georgia, serif")
+ .attr('font-size', 14).attr('font-weight', 600)
+ .attr('fill', 'var(--snow3)')
+ .text(truncate(fullName, 20));
+
+ const dateStr = formatDateRange(person.birth_date, person.death_date);
+ if (dateStr) {
+ grp.append('text')
+ .attr('x', NODE_W / 2).attr('y', 40)
+ .attr('text-anchor', 'middle')
+ .attr('font-family', "'Inter', sans-serif")
+ .attr('font-size', 11).attr('fill', 'var(--snow1)').attr('opacity', 0.55)
+ .text(dateStr);
+ }
+
+ if (person.burial_place) {
+ grp.append('text')
+ .attr('x', NODE_W / 2).attr('y', dateStr ? 54 : 40)
+ .attr('text-anchor', 'middle')
+ .attr('font-family', "'Inter', sans-serif")
+ .attr('font-size', 10).attr('fill', 'var(--snow1)').attr('opacity', 0.35)
+ .text('⚰ ' + truncate(person.burial_place, 18));
+ }
+ }
+}
+
+/* ── Detail panel ── */
+function openDetailPanel(personId) {
+ state.selectedPersonId = personId;
+ const person = state.graph.people.find(p => p.id === personId);
+ if (!person) return;
+
+ renderCanvas();
+
+ const panel = document.getElementById('detailPanel');
+ panel.classList.add('open');
+
+ const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ');
+ document.getElementById('detailName').textContent = fullName;
+ document.getElementById('btnEditPerson').style.display = state.canEdit ? '' : 'none';
+ document.getElementById('btnDeletePerson').style.display = state.canEdit ? '' : 'none';
+
+ let html = '';
+
+ if (person.birth_date || person.birth_place)
+ html += field('Born', [person.birth_date, person.birth_place].filter(Boolean).join(' · '));
+ if (person.death_date) html += field('Died', person.death_date);
+ if (person.burial_place) html += field('Buried', person.burial_place);
+ if (person.bio) html += field('Biography', person.bio);
+
+ const myPartnerships = state.graph.partnerships.filter(
+ ps => ps.person1_id === personId || ps.person2_id === personId
+ );
+ if (myPartnerships.length) {
+ html += `<p class="detail-section-title">Partnerships</p>`;
+ for (const ps of myPartnerships) {
+ const otherId = ps.person1_id === personId ? ps.person2_id : ps.person1_id;
+ const other = state.graph.people.find(p => p.id === otherId);
+ const otherName = other ? [other.first_name, other.last_name].filter(Boolean).join(' ') : '—';
+ const label = { married: 'Married', separated: 'Separated', partnered: 'Partner' }[ps.union_type] ?? ps.union_type;
+ html += `<div class="detail-rel-item" onclick="openDetailPanel(${otherId})">
+ <span>${esc(otherName)}</span>
+ <span class="detail-rel-badge">${label}${ps.union_date ? ' ' + ps.union_date.slice(0, 4) : ''}</span>
+ </div>`;
+ }
+ }
+
+ const childIds = [...new Set([
+ ...myPartnerships.flatMap(ps => state.graph.parentLinks.filter(pl => pl.partnership_id === ps.id).map(pl => pl.child_id)),
+ ...state.graph.parentLinks.filter(pl => pl.parent_id === personId).map(pl => pl.child_id),
+ ])];
+ if (childIds.length) {
+ html += `<p class="detail-section-title">Children</p>`;
+ for (const cid of childIds) {
+ const child = state.graph.people.find(p => p.id === cid);
+ if (!child) continue;
+ html += `<div class="detail-rel-item" onclick="openDetailPanel(${cid})">
+ <span>${esc([child.first_name, child.last_name].filter(Boolean).join(' '))}</span>
+ </div>`;
+ }
+ }
+
+ const myParentLinks = state.graph.parentLinks.filter(pl => pl.child_id === personId);
+ const parentPeople = [];
+ for (const pl of myParentLinks) {
+ if (pl.partnership_id) {
+ const ps = state.graph.partnerships.find(p => p.id === pl.partnership_id);
+ if (ps) [ps.person1_id, ps.person2_id].forEach(pid => { if (!parentPeople.includes(pid)) parentPeople.push(pid); });
+ } else if (pl.parent_id && !parentPeople.includes(pl.parent_id)) {
+ parentPeople.push(pl.parent_id);
+ }
+ }
+ if (parentPeople.length) {
+ html += `<p class="detail-section-title">Parents</p>`;
+ for (const pid of parentPeople) {
+ const parent = state.graph.people.find(p => p.id === pid);
+ if (!parent) continue;
+ html += `<div class="detail-rel-item" onclick="openDetailPanel(${pid})">
+ <span>${esc([parent.first_name, parent.last_name].filter(Boolean).join(' '))}</span>
+ </div>`;
+ }
+ }
+
+ // Siblings: people who share a parent link with this person
+ const siblingIds = [];
+ for (const pl of myParentLinks) {
+ const sharedLinks = state.graph.parentLinks.filter(spl =>
+ spl.child_id !== personId &&
+ ((pl.partnership_id && spl.partnership_id === pl.partnership_id) ||
+ (pl.parent_id && spl.parent_id === pl.parent_id))
+ );
+ for (const spl of sharedLinks) {
+ if (!siblingIds.includes(spl.child_id)) siblingIds.push(spl.child_id);
+ }
+ }
+ if (siblingIds.length) {
+ html += `<p class="detail-section-title">Siblings</p>`;
+ for (const sid of siblingIds) {
+ const sib = state.graph.people.find(p => p.id === sid);
+ if (!sib) continue;
+ html += `<div class="detail-rel-item" onclick="openDetailPanel(${sid})">
+ <span>${esc([sib.first_name, sib.last_name].filter(Boolean).join(' '))}</span>
+ </div>`;
+ }
+ }
+
+ if (state.canEdit) {
+ html += `<p class="detail-section-title">Add relationship</p>
+ <button class="btn" style="width:100%;margin-bottom:0.5rem" onclick="openAddPartnershipModal(${personId})">Add partnership</button>
+ <button class="btn" style="width:100%;margin-bottom:0.5rem" onclick="openAddParentLinkModal(${personId},'child')">Set parents of this person</button>
+ <button class="btn" style="width:100%" onclick="openAddParentLinkModal(null,'parent',${personId})">Add a child for this person</button>`;
+ }
+
+ document.getElementById('detailBody').innerHTML = html;
+}
+
+function closeDetailPanel() {
+ state.selectedPersonId = null;
+ document.getElementById('detailPanel').classList.remove('open');
+}
+
+function field(label, value) {
+ return `<div class="detail-field">
+ <div class="detail-field-label">${esc(label)}</div>
+ <div class="detail-field-value">${esc(String(value))}</div>
+ </div>`;
+}
+
+/* ── Partnership detail (dot click) ── */
+function openPartnershipDetail(ps) {
+ state._editingPartnershipId = ps.id;
+ const p1 = state.graph.people.find(p => p.id === ps.person1_id);
+ const p2 = state.graph.people.find(p => p.id === ps.person2_id);
+ const n1 = p1 ? [p1.first_name, p1.last_name].filter(Boolean).join(' ') : '?';
+ const n2 = p2 ? [p2.first_name, p2.last_name].filter(Boolean).join(' ') : '?';
+ document.getElementById('modalPartnershipTitle').textContent = `${n1} & ${n2}`;
+ document.getElementById('partnerPerson2').closest('.form-group').style.display = '';
+ document.getElementById('partnerType').closest('.form-group').style.display = '';
+ document.getElementById('partnerStart').closest('.form-row').style.display = '';
+ document.getElementById('btnPartnerSave').textContent = 'Save';
+ state._siblingMode = false;
+ populatePersonSelects('partnerPerson1', 'partnerPerson2');
+ document.getElementById('partnerPerson1').value = ps.person1_id;
+ document.getElementById('partnerPerson2').value = ps.person2_id;
+ document.getElementById('partnerType').value = ps.union_type;
+ document.getElementById('partnerStart').value = ps.union_date ?? '';
+ document.getElementById('partnerEnd').value = ps.union_end_date ?? '';
+ openModal('modalPartnership');
+}
+
+/* ── Context menu ── */
+function openCtxMenu(e, treeId) {
+ e.preventDefault();
+ state._ctxTreeId = treeId;
+ const tree = state.trees.find(t => t.id === treeId);
+ const isOwner = tree?.role === 'owner';
+ const menu = document.getElementById('ctxMenu');
+ document.getElementById('ctxRename').style.display = isOwner ? '' : 'none';
+ document.getElementById('ctxDelete').style.display = isOwner ? '' : 'none';
+ document.getElementById('ctxLeave').style.display = isOwner ? 'none' : '';
+ menu.style.left = e.clientX + 'px';
+ menu.style.top = e.clientY + 'px';
+ menu.classList.add('open');
+}
+
+document.addEventListener('click', () => document.getElementById('ctxMenu').classList.remove('open'));
+
+/* ── Modal helpers ── */
+function openModal(id) { document.getElementById(id).classList.add('open'); }
+function closeModal(id) { document.getElementById(id).classList.remove('open'); }
+
+function populatePersonSelects(...ids) {
+ const options = state.graph.people.map(p => {
+ const name = [p.first_name, p.last_name].filter(Boolean).join(' ');
+ return `<option value="${p.id}">${esc(name)}</option>`;
+ }).join('');
+ for (const id of ids) document.getElementById(id).innerHTML = options;
+}
+
+/* ── Tree modals ── */
+document.getElementById('btnNewTree').addEventListener('click', openNewTreeModal);
+document.getElementById('emptyNewTree').addEventListener('click', openNewTreeModal);
+
+function openNewTreeModal() {
+ state._editingTreeId = null;
+ document.getElementById('modalTreeTitle').textContent = 'New tree';
+ document.getElementById('btnTreeSave').textContent = 'Create';
+ document.getElementById('treeNameInput').value = '';
+ openModal('modalTree');
+ setTimeout(() => document.getElementById('treeNameInput').focus(), 50);
+}
+
+document.getElementById('btnTreeCancel').addEventListener('click', () => closeModal('modalTree'));
+document.getElementById('btnTreeSave').addEventListener('click', async () => {
+ const name = document.getElementById('treeNameInput').value.trim();
+ if (!name) return;
+ try {
+ if (state._editingTreeId) {
+ await api('PATCH', `/api/trees/${state._editingTreeId}`, { name });
+ } else {
+ const t = await api('POST', '/api/trees', { name });
+ state.activeTreeId = t.id;
+ }
+ closeModal('modalTree');
+ await loadTrees();
+ if (state.activeTreeId) await loadGraph(state.activeTreeId);
+ } catch(e) { showError(e.message); }
+});
+
+/* ── Context menu actions ── */
+document.getElementById('ctxRename').addEventListener('click', () => {
+ const tree = state.trees.find(t => t.id === state._ctxTreeId);
+ if (!tree) return;
+ state._editingTreeId = tree.id;
+ document.getElementById('modalTreeTitle').textContent = 'Rename tree';
+ document.getElementById('btnTreeSave').textContent = 'Save';
+ document.getElementById('treeNameInput').value = tree.name;
+ openModal('modalTree');
+});
+
+document.getElementById('ctxMembers').addEventListener('click', () => openMembersModal(state._ctxTreeId));
+
+document.getElementById('ctxDelete').addEventListener('click', async () => {
+ if (!confirm('Delete this tree and all its data?')) return;
+ try {
+ await api('DELETE', `/api/trees/${state._ctxTreeId}`);
+ if (state.activeTreeId === state._ctxTreeId) selectTree(null);
+ await loadTrees();
+ } catch(e) { showError(e.message); }
+});
+
+document.getElementById('ctxLeave').addEventListener('click', async () => {
+ if (!confirm('Leave this tree?')) return;
+ try {
+ await api('DELETE', `/api/trees/${state._ctxTreeId}/leave`);
+ if (state.activeTreeId === state._ctxTreeId) selectTree(null);
+ await loadTrees();
+ } catch(e) { showError(e.message); }
+});
+
+/* ── Add/edit person modal ── */
+document.getElementById('btnAddPerson').addEventListener('click', () => openPersonModal(null));
+
+function openPersonModal(person) {
+ state._editingPersonId = person?.id ?? null;
+ document.getElementById('modalPersonTitle').textContent = person ? 'Edit person' : 'Add person';
+ document.getElementById('personFirstName').value = person?.first_name ?? '';
+ document.getElementById('personLastName').value = person?.last_name ?? '';
+ document.getElementById('personBirthDate').value = person?.birth_date ?? '';
+ document.getElementById('personDeathDate').value = person?.death_date ?? '';
+ document.getElementById('personBirthPlace').value = person?.birth_place ?? '';
+ document.getElementById('personBurialPlace').value = person?.burial_place ?? '';
+ document.getElementById('personBio').value = person?.bio ?? '';
+ openModal('modalPerson');
+ setTimeout(() => document.getElementById('personFirstName').focus(), 50);
+}
+
+document.getElementById('btnPersonCancel').addEventListener('click', () => closeModal('modalPerson'));
+
+document.getElementById('btnPersonSave').addEventListener('click', async () => {
+ const body = {
+ first_name: document.getElementById('personFirstName').value.trim(),
+ last_name: document.getElementById('personLastName').value.trim() || null,
+ birth_date: document.getElementById('personBirthDate').value.trim() || null,
+ death_date: document.getElementById('personDeathDate').value.trim() || null,
+ birth_place: document.getElementById('personBirthPlace').value.trim() || null,
+ burial_place: document.getElementById('personBurialPlace').value.trim() || null,
+ bio: document.getElementById('personBio').value.trim() || null,
+ };
+ if (!body.first_name) { alert('First name is required'); return; }
+ try {
+ const isNew = !state._editingPersonId;
+ let saved;
+ if (state._editingPersonId) {
+ saved = await api('PATCH', `/api/trees/${state.activeTreeId}/people/${state._editingPersonId}`, body);
+ } else {
+ saved = await api('POST', `/api/trees/${state.activeTreeId}/people`, body);
+ }
+ closeModal('modalPerson');
+ await loadGraph(state.activeTreeId);
+ // After creating (not editing) a person, offer to connect them if others exist
+ if (isNew && state.graph.people.length > 1) {
+ openConnectModal(saved.id);
+ }
+ } catch(e) {
+ showError('Could not save: ' + e.message);
+ }
+});
+
+/* ── Edit / delete from detail panel ── */
+document.getElementById('btnEditPerson').addEventListener('click', () => {
+ const person = state.graph.people.find(p => p.id === state.selectedPersonId);
+ if (person) openPersonModal(person);
+});
+
+document.getElementById('btnDeletePerson').addEventListener('click', async () => {
+ if (!confirm('Delete this person and all their relationships?')) return;
+ try {
+ await api('DELETE', `/api/trees/${state.activeTreeId}/people/${state.selectedPersonId}`);
+ closeDetailPanel();
+ await loadGraph(state.activeTreeId);
+ } catch(e) { showError(e.message); }
+});
+
+document.getElementById('btnCloseDetail').addEventListener('click', closeDetailPanel);
+
+/* ── Connect modal ── */
+function openConnectModal(personId) {
+ state._connectingPersonId = personId;
+ const person = state.graph.people.find(p => p.id === personId);
+ const name = person ? [person.first_name, person.last_name].filter(Boolean).join(' ') : '';
+ document.getElementById('connectPersonName').textContent = name;
+ openModal('modalConnect');
+}
+
+document.getElementById('connectAsPartner').addEventListener('click', () => {
+ closeModal('modalConnect');
+ openAddPartnershipModal(state._connectingPersonId);
+});
+
+document.getElementById('connectAsChild').addEventListener('click', () => {
+ closeModal('modalConnect');
+ openAddParentLinkModal(state._connectingPersonId, 'child');
+});
+
+document.getElementById('connectAsParent').addEventListener('click', () => {
+ closeModal('modalConnect');
+ openAddParentLinkModal(null, 'parent', state._connectingPersonId);
+});
+
+document.getElementById('connectAsSibling').addEventListener('click', () => {
+ closeModal('modalConnect');
+ openSiblingModal(state._connectingPersonId);
+});
+
+document.getElementById('btnConnectSkip').addEventListener('click', () => closeModal('modalConnect'));
+
+/* ── Sibling modal: select an existing person → inherit their parent links ── */
+function openSiblingModal(newPersonId) {
+ const others = state.graph.people.filter(p => p.id !== newPersonId);
+ if (!others.length) { showError('No other people in the tree.'); return; }
+ state._siblingMode = true;
+ state._siblingModePersonId = newPersonId;
+ document.getElementById('modalPartnershipTitle').textContent = 'Add as sibling of…';
+ // Show only person1 select; hide partner/date fields
+ populatePersonSelects('partnerPerson1');
+ Array.from(document.getElementById('partnerPerson1').options)
+ .forEach(o => { if (Number(o.value) === newPersonId) o.remove(); });
+ document.getElementById('partnerPerson2').closest('.form-group').style.display = 'none';
+ document.getElementById('partnerType').closest('.form-group').style.display = 'none';
+ document.getElementById('partnerStart').closest('.form-row').style.display = 'none';
+ document.getElementById('btnPartnerSave').textContent = 'Add as sibling';
+ openModal('modalPartnership');
+}
+
+/* ── Partnership modal ── */
+function openAddPartnershipModal(preselectedPersonId) {
+ if (state.graph.people.length < 2) { alert('Add at least two people first.'); return; }
+ state._editingPartnershipId = null;
+ state._siblingMode = false;
+ document.getElementById('modalPartnershipTitle').textContent = 'Add partnership';
+ document.getElementById('partnerPerson2').closest('.form-group').style.display = '';
+ document.getElementById('partnerType').closest('.form-group').style.display = '';
+ document.getElementById('partnerStart').closest('.form-row').style.display = '';
+ document.getElementById('btnPartnerSave').textContent = 'Save';
+ populatePersonSelects('partnerPerson1', 'partnerPerson2');
+ if (preselectedPersonId) document.getElementById('partnerPerson1').value = preselectedPersonId;
+ document.getElementById('partnerType').value = 'partnered';
+ document.getElementById('partnerStart').value = '';
+ document.getElementById('partnerEnd').value = '';
+ openModal('modalPartnership');
+}
+
+document.getElementById('btnPartnerCancel').addEventListener('click', () => {
+ state._siblingMode = false;
+ // Restore hidden fields
+ document.getElementById('partnerPerson2').closest('.form-group').style.display = '';
+ document.getElementById('partnerType').closest('.form-group').style.display = '';
+ document.getElementById('partnerStart').closest('.form-row').style.display = '';
+ closeModal('modalPartnership');
+});
+
+document.getElementById('btnPartnerSave').addEventListener('click', async () => {
+ if (state._siblingMode) {
+ const siblingId = parseInt(document.getElementById('partnerPerson1').value);
+ const newPersonId = state._siblingModePersonId;
+ state._siblingMode = false;
+ document.getElementById('partnerPerson2').closest('.form-group').style.display = '';
+ document.getElementById('partnerType').closest('.form-group').style.display = '';
+ document.getElementById('partnerStart').closest('.form-row').style.display = '';
+ closeModal('modalPartnership');
+ try {
+ const siblingLinks = state.graph.parentLinks.filter(pl => pl.child_id === siblingId);
+ if (!siblingLinks.length) {
+ showError("That person has no parents in the tree yet. Set their parents first, then add the sibling.");
+ return;
+ }
+ for (const pl of siblingLinks) {
+ await api('POST', `/api/trees/${state.activeTreeId}/parentlinks`, {
+ child_id: newPersonId,
+ partnership_id: pl.partnership_id ?? null,
+ parent_id: pl.parent_id ?? null,
+ });
+ }
+ await loadGraph(state.activeTreeId);
+ } catch(e) { showError(e.message); }
+ return;
+ }
+
+ const p1 = parseInt(document.getElementById('partnerPerson1').value);
+ const p2 = parseInt(document.getElementById('partnerPerson2').value);
+ if (p1 === p2) { alert('Select two different people.'); return; }
+ const body = {
+ person1_id: p1, person2_id: p2,
+ union_type: document.getElementById('partnerType').value,
+ union_date: document.getElementById('partnerStart').value.trim() || null,
+ union_end_date: document.getElementById('partnerEnd').value.trim() || null,
+ };
+ try {
+ if (state._editingPartnershipId) {
+ await api('PATCH', `/api/trees/${state.activeTreeId}/partnerships/${state._editingPartnershipId}`, {
+ union_type: body.union_type, union_date: body.union_date, union_end_date: body.union_end_date,
+ });
+ } else {
+ await api('POST', `/api/trees/${state.activeTreeId}/partnerships`, body);
+ }
+ closeModal('modalPartnership');
+ await loadGraph(state.activeTreeId);
+ if (state.selectedPersonId) openDetailPanel(state.selectedPersonId);
+ } catch(e) { showError(e.message); }
+});
+
+/* ── Parent link modal ── */
+function openAddParentLinkModal(childPreselect, mode, parentPreselect) {
+ populatePersonSelects('linkChild', 'linkParent');
+
+ if (mode === 'child' && childPreselect != null) {
+ Array.from(document.getElementById('linkParent').options)
+ .forEach(o => { if (Number(o.value) === childPreselect) o.remove(); });
+ document.getElementById('linkChild').value = childPreselect;
+ }
+ if (mode === 'parent' && parentPreselect != null) {
+ Array.from(document.getElementById('linkChild').options)
+ .forEach(o => { if (Number(o.value) === parentPreselect) o.remove(); });
+ }
+
+ const psSelect = document.getElementById('linkPartnership');
+ psSelect.innerHTML = state.graph.partnerships.map(ps => {
+ const p1 = state.graph.people.find(p => p.id === ps.person1_id);
+ const p2 = state.graph.people.find(p => p.id === ps.person2_id);
+ const n1 = p1 ? [p1.first_name, p1.last_name].filter(Boolean).join(' ') : '?';
+ const n2 = p2 ? [p2.first_name, p2.last_name].filter(Boolean).join(' ') : '?';
+ return `<option value="${ps.id}">${esc(n1)} & ${esc(n2)}</option>`;
+ }).join('');
+
+ if (!psSelect.options.length) {
+ document.getElementById('linkViaType').value = 'single';
+ document.getElementById('linkPartnershipGroup').style.display = 'none';
+ document.getElementById('linkParentGroup').style.display = '';
+ if (mode === 'parent' && parentPreselect != null)
+ document.getElementById('linkParent').value = parentPreselect;
+ } else {
+ document.getElementById('linkViaType').value = 'partnership';
+ document.getElementById('linkPartnershipGroup').style.display = '';
+ document.getElementById('linkParentGroup').style.display = 'none';
+ }
+
+ openModal('modalParentLink');
+}
+
+window.app = {
+ onLinkViaTypeChange() {
+ const via = document.getElementById('linkViaType').value;
+ document.getElementById('linkPartnershipGroup').style.display = via === 'partnership' ? '' : 'none';
+ document.getElementById('linkParentGroup').style.display = via === 'single' ? '' : 'none';
+ }
+};
+
+document.getElementById('btnLinkCancel').addEventListener('click', () => closeModal('modalParentLink'));
+document.getElementById('btnLinkSave').addEventListener('click', async () => {
+ const childId = parseInt(document.getElementById('linkChild').value);
+ const via = document.getElementById('linkViaType').value;
+ const body = { child_id: childId };
+ if (via === 'partnership') {
+ body.partnership_id = parseInt(document.getElementById('linkPartnership').value);
+ } else {
+ body.parent_id = parseInt(document.getElementById('linkParent').value);
+ }
+ try {
+ await api('POST', `/api/trees/${state.activeTreeId}/parentlinks`, body);
+ closeModal('modalParentLink');
+ await loadGraph(state.activeTreeId);
+ if (state.selectedPersonId) openDetailPanel(state.selectedPersonId);
+ } catch(e) { showError(e.message); }
+});
+
+/* ── Members modal ── */
+async function openMembersModal(treeId) {
+ state._ctxTreeId = treeId;
+ await loadTrees();
+ renderMembersModal(treeId);
+ openModal('modalMembers');
+}
+
+function renderMembersModal(treeId) {
+ const tree = state.trees.find(t => t.id === treeId);
+ if (!tree) return;
+ const isOwner = tree.role === 'owner';
+
+ document.getElementById('membersList').innerHTML = tree.members.map(m => `
+ <div class="member-row">
+ <span class="member-username">${esc(m.username)}</span>
+ <span class="member-role">${m.role}</span>
+ ${isOwner && m.role !== 'owner' ? `
+ <button class="btn-icon danger" title="Remove" onclick="removeMember(${treeId},'${esc(m.username)}')">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
+ </button>` : ''}
+ </div>`).join('');
+
+ document.getElementById('pendingList').innerHTML = tree.pending_invitations?.length ? (
+ '<p class="form-label" style="margin-top:1rem;margin-bottom:0.4rem">Pending</p>' +
+ tree.pending_invitations.map(inv => `
+ <div class="pending-row">
+ <span>${esc(inv.email)} <em style="opacity:0.5">(${inv.role})</em></span>
+ ${isOwner ? `<button class="btn-icon danger" onclick="revokeInvitation(${treeId},'${esc(inv.email)}')">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
+ </button>` : ''}
+ </div>`).join('')
+ ) : '';
+
+ document.querySelector('.members-invite-section').style.display = isOwner ? '' : 'none';
+}
+
+window.removeMember = async (treeId, username) => {
+ if (!confirm(`Remove ${username}?`)) return;
+ try {
+ await api('DELETE', `/api/trees/${treeId}/members/${username}`);
+ await loadTrees(); renderMembersModal(treeId);
+ } catch(e) { showError(e.message); }
+};
+
+window.revokeInvitation = async (treeId, email) => {
+ try {
+ await api('DELETE', `/api/trees/${treeId}/invitations/${encodeURIComponent(email)}`);
+ await loadTrees(); renderMembersModal(treeId);
+ } catch(e) { showError(e.message); }
+};
+
+document.getElementById('btnInviteSend').addEventListener('click', async () => {
+ const email = document.getElementById('inviteEmail').value.trim();
+ const role = document.getElementById('inviteRole').value;
+ if (!email) return;
+ try {
+ await api('POST', `/api/trees/${state._ctxTreeId}/invite`, { email, role });
+ document.getElementById('inviteEmail').value = '';
+ await loadTrees(); renderMembersModal(state._ctxTreeId);
+ } catch(e) { showError(e.message); }
+});
+
+document.getElementById('btnMembersClose').addEventListener('click', () => closeModal('modalMembers'));
+
+/* ── Toolbar ── */
+document.getElementById('btnZoomIn').addEventListener('click', () => svg?.transition().call(zoom.scaleBy, 1.3));
+document.getElementById('btnZoomOut').addEventListener('click', () => svg?.transition().call(zoom.scaleBy, 1 / 1.3));
+document.getElementById('btnFit').addEventListener('click', fitCanvas);
+document.getElementById('sidebarToggle').addEventListener('click', () => {
+ document.getElementById('sidebar').classList.toggle('collapsed');
+});
+
+/* ── Close modals on backdrop / Escape ── */
+document.querySelectorAll('.modal-overlay').forEach(overlay => {
+ overlay.addEventListener('click', e => { if (e.target === overlay) overlay.classList.remove('open'); });
+});
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape') {
+ document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
+ document.getElementById('ctxMenu').classList.remove('open');
+ }
+});
+
+/* ── Helpers ── */
+function esc(s) {
+ return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+}
+function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
+function formatDateRange(birth, death) {
+ if (!birth && !death) return '';
+ const b = birth ? birth.slice(0, 4) : '?';
+ return death ? `${b} – ${death.slice(0, 4)}` : b;
+}
+
+/* ── Init ── */
+showEmptyState();
+boot().catch(console.error);
diff --git a/static/index.html b/static/index.html
@@ -0,0 +1,347 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="/static/style.css">
+ <title>Family Tree</title>
+</head>
+<body>
+
+ <!-- Topbar -->
+ <nav class="topbar">
+ <div class="topbar-inner">
+ <div class="topbar-left">
+ <span class="topbar-name">Family Tree</span>
+ <button class="btn-sidebar-toggle" id="sidebarToggle" title="Toggle sidebar">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5"/>
+ </svg>
+ </button>
+ </div>
+ <div class="topbar-right">
+ <span class="topbar-username" id="topbarUsername"></span>
+ <a href="https://auth.piribauer.ch/if/user/" class="nav-link" target="_blank" rel="noopener">Account</a>
+ <a href="/outpost.goauthentik.io/sign_out" class="nav-link signout">Sign out</a>
+ <a href="https://hub.piribauer.ch" class="topbar-home" aria-label="Hub">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/>
+ </svg>
+ </a>
+ </div>
+ </div>
+ </nav>
+
+ <!-- App body -->
+ <div class="app-body">
+
+ <!-- Sidebar -->
+ <aside class="sidebar" id="sidebar">
+ <div class="sidebar-header">Trees</div>
+ <div class="tree-list" id="treeList"></div>
+ <div class="sidebar-actions">
+ <button class="btn-new-tree" id="btnNewTree">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
+ </svg>
+ New tree
+ </button>
+ </div>
+ </aside>
+
+ <!-- Canvas area -->
+ <main class="canvas-area" id="canvasArea">
+
+ <!-- Empty state -->
+ <div class="empty-state" id="emptyState">
+ <div class="empty-icon">
+ <svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
+ <circle cx="24" cy="10" r="4"/>
+ <line x1="24" y1="14" x2="24" y2="22"/>
+ <line x1="24" y1="22" x2="12" y2="30"/>
+ <line x1="24" y1="22" x2="36" y2="30"/>
+ <circle cx="12" cy="34" r="4"/>
+ <circle cx="36" cy="34" r="4"/>
+ </svg>
+ </div>
+ <p class="empty-title">No tree selected</p>
+ <p class="empty-sub">Create a tree or select one from the sidebar</p>
+ <button class="btn btn-primary" id="emptyNewTree">Create a tree</button>
+ </div>
+
+ <!-- Canvas toolbar -->
+ <div class="canvas-toolbar" id="canvasToolbar" style="display:none">
+ <button class="toolbar-btn" id="btnZoomIn" title="Zoom in">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35M11 8v6M8 11h6"/>
+ </svg>
+ </button>
+ <button class="toolbar-btn" id="btnZoomOut" title="Zoom out">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35M8 11h6"/>
+ </svg>
+ </button>
+ <button class="toolbar-btn" id="btnFit" title="Fit to screen">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9M20.25 20.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/>
+ </svg>
+ </button>
+ <div class="toolbar-sep"></div>
+ <button class="toolbar-btn toolbar-btn-primary" id="btnAddPerson" title="Add person">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z"/>
+ </svg>
+ Add person
+ </button>
+ </div>
+
+ <!-- SVG canvas -->
+ <svg id="treeCanvas" style="display:none"></svg>
+
+ </main>
+
+ <!-- Detail panel -->
+ <aside class="detail-panel" id="detailPanel">
+ <div class="detail-header">
+ <span class="detail-name" id="detailName"></span>
+ <div class="detail-header-actions">
+ <button class="btn-icon" id="btnEditPerson" title="Edit">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"/>
+ </svg>
+ </button>
+ <button class="btn-icon danger" id="btnDeletePerson" title="Delete">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>
+ </svg>
+ </button>
+ <button class="btn-icon" id="btnCloseDetail" title="Close">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
+ </svg>
+ </button>
+ </div>
+ </div>
+ <div class="detail-body" id="detailBody"></div>
+ </aside>
+
+ </div>
+
+ <!-- Context menu -->
+ <div class="ctx-menu" id="ctxMenu">
+ <div class="ctx-item" id="ctxRename">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:14px;height:14px">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"/>
+ </svg>
+ Rename
+ </div>
+ <div class="ctx-item" id="ctxMembers">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:14px;height:14px">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
+ </svg>
+ Manage members
+ </div>
+ <div class="ctx-item" id="ctxLeave">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:14px;height:14px">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"/>
+ </svg>
+ Leave tree
+ </div>
+ <div class="ctx-item danger" id="ctxDelete">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:14px;height:14px">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>
+ </svg>
+ Delete tree
+ </div>
+ </div>
+
+ <!-- ── Modals ── -->
+
+ <!-- New/rename tree -->
+ <div class="modal-overlay" id="modalTree">
+ <div class="modal">
+ <h2 id="modalTreeTitle">New tree</h2>
+ <div class="form-group">
+ <label class="form-label">Name</label>
+ <input class="form-input" id="treeNameInput" type="text" placeholder="e.g. Piribauer family">
+ </div>
+ <div class="modal-actions">
+ <button class="btn" id="btnTreeCancel">Cancel</button>
+ <button class="btn btn-primary" id="btnTreeSave">Create</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Add/edit person -->
+ <div class="modal-overlay" id="modalPerson">
+ <div class="modal modal-wide">
+ <h2 id="modalPersonTitle">Add person</h2>
+ <div class="form-row">
+ <div class="form-group">
+ <label class="form-label">First name</label>
+ <input class="form-input" id="personFirstName" type="text" placeholder="First name">
+ </div>
+ <div class="form-group">
+ <label class="form-label">Last name</label>
+ <input class="form-input" id="personLastName" type="text" placeholder="Last name">
+ </div>
+ </div>
+ <div class="form-row">
+ <div class="form-group">
+ <label class="form-label">Birth date</label>
+ <input class="form-input" id="personBirthDate" type="text" placeholder="YYYY-MM-DD">
+ </div>
+ <div class="form-group">
+ <label class="form-label">Death date</label>
+ <input class="form-input" id="personDeathDate" type="text" placeholder="YYYY-MM-DD">
+ </div>
+ </div>
+ <div class="form-row">
+ <div class="form-group">
+ <label class="form-label">Birth place</label>
+ <input class="form-input" id="personBirthPlace" type="text" placeholder="City, Country">
+ </div>
+ <div class="form-group">
+ <label class="form-label">Burial place</label>
+ <input class="form-input" id="personBurialPlace" type="text" placeholder="Cemetery, City">
+ </div>
+ </div>
+ <div class="form-group">
+ <label class="form-label">Biography</label>
+ <textarea class="form-input" id="personBio" rows="3" placeholder="A short note…"></textarea>
+ </div>
+ <div class="modal-actions">
+ <button class="btn" id="btnPersonCancel">Cancel</button>
+ <button class="btn btn-primary" id="btnPersonSave">Save</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Connect new person -->
+ <div class="modal-overlay" id="modalConnect">
+ <div class="modal">
+ <h2>Connect <span id="connectPersonName" style="color:var(--lightblue)"></span></h2>
+ <p style="font-size:0.875rem;color:var(--snow1);opacity:0.6;margin-bottom:1.25rem">
+ How would you like to add this person to the tree?
+ </p>
+ <div class="connect-options">
+ <button class="btn connect-btn" id="connectAsPartner">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"/></svg>
+ Partner of someone
+ </button>
+ <button class="btn connect-btn" id="connectAsChild">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3"/></svg>
+ Child of someone
+ </button>
+ <button class="btn connect-btn" id="connectAsParent">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18"/></svg>
+ Parent of someone
+ </button>
+ <button class="btn connect-btn" id="connectAsSibling">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"/></svg>
+ Sibling of someone
+ </button>
+ </div>
+ <div class="modal-actions">
+ <button class="btn" id="btnConnectSkip">Skip for now</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Add/edit partnership -->
+ <div class="modal-overlay" id="modalPartnership">
+ <div class="modal">
+ <h2 id="modalPartnershipTitle">Add partnership</h2>
+ <div class="form-group">
+ <label class="form-label">Person 1</label>
+ <select class="form-input" id="partnerPerson1"></select>
+ </div>
+ <div class="form-group">
+ <label class="form-label">Person 2</label>
+ <select class="form-input" id="partnerPerson2"></select>
+ </div>
+ <div class="form-group">
+ <label class="form-label">Type</label>
+ <select class="form-input" id="partnerType">
+ <option value="married">Married</option>
+ <option value="partnered" selected>Partnered</option>
+ <option value="separated">Separated</option>
+ </select>
+ </div>
+ <div class="form-row">
+ <div class="form-group">
+ <label class="form-label">Start date</label>
+ <input class="form-input" id="partnerStart" type="text" placeholder="YYYY-MM-DD">
+ </div>
+ <div class="form-group">
+ <label class="form-label">End date</label>
+ <input class="form-input" id="partnerEnd" type="text" placeholder="YYYY-MM-DD">
+ </div>
+ </div>
+ <div class="modal-actions">
+ <button class="btn" id="btnPartnerCancel">Cancel</button>
+ <button class="btn btn-primary" id="btnPartnerSave">Save</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Add parent link -->
+ <div class="modal-overlay" id="modalParentLink">
+ <div class="modal">
+ <h2>Add parent link</h2>
+ <div class="form-group">
+ <label class="form-label">Child</label>
+ <select class="form-input" id="linkChild"></select>
+ </div>
+ <div class="form-group">
+ <label class="form-label">Link via</label>
+ <select class="form-input" id="linkViaType" onchange="app.onLinkViaTypeChange()">
+ <option value="partnership">Partnership (two parents)</option>
+ <option value="single">Single parent</option>
+ </select>
+ </div>
+ <div class="form-group" id="linkPartnershipGroup">
+ <label class="form-label">Partnership</label>
+ <select class="form-input" id="linkPartnership"></select>
+ </div>
+ <div class="form-group" id="linkParentGroup" style="display:none">
+ <label class="form-label">Parent</label>
+ <select class="form-input" id="linkParent"></select>
+ </div>
+ <div class="modal-actions">
+ <button class="btn" id="btnLinkCancel">Cancel</button>
+ <button class="btn btn-primary" id="btnLinkSave">Save</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Manage members -->
+ <div class="modal-overlay" id="modalMembers">
+ <div class="modal modal-wide">
+ <h2>Members</h2>
+ <div id="membersList"></div>
+ <div class="members-invite-section">
+ <p class="form-label" style="margin-bottom:0.6rem;margin-top:1.25rem">Invite by email</p>
+ <div class="invite-row">
+ <input class="form-input" id="inviteEmail" type="email" placeholder="email@example.com">
+ <select class="form-input" id="inviteRole" style="width:110px;flex-shrink:0">
+ <option value="editor">Editor</option>
+ <option value="viewer" selected>Viewer</option>
+ </select>
+ <button class="btn btn-primary" id="btnInviteSend">Invite</button>
+ </div>
+ <div id="pendingList"></div>
+ </div>
+ <div class="modal-actions">
+ <button class="btn btn-primary" id="btnMembersClose">Done</button>
+ </div>
+ </div>
+ </div>
+
+ <script src="https://d3js.org/d3.v7.min.js"></script>
+ <script src="/static/app.js"></script>
+</body>
+</html>
diff --git a/static/style.css b/static/style.css
@@ -0,0 +1,597 @@
+:root {
+ --polar1: #2E3440;
+ --polar2: #3B4252;
+ --polar3: #434C5E;
+ --polar4: #4C566A;
+ --snow1: #D8DEE9;
+ --snow2: #E5E9F0;
+ --snow3: #ECEFF4;
+ --turquoise: #8FBCBB;
+ --lightblue: #88C0D0;
+ --midblue: #81A1C1;
+ --darkblue: #5E81AC;
+ --red: #BF616A;
+ --orange: #D08770;
+ --yellow: #EBCB8B;
+ --green: #A3BE8C;
+ --violet: #B48EAD;
+}
+
+*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { height: 100%; overflow: hidden; }
+
+body {
+ background: var(--polar1);
+ font-family: 'Inter', system-ui, sans-serif;
+ color: var(--snow1);
+ font-size: 1rem;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── Topbar ── */
+.topbar {
+ flex-shrink: 0;
+ background: var(--polar2);
+ border-bottom: 1px solid var(--polar3);
+ z-index: 100;
+}
+.topbar-inner {
+ height: 56px;
+ padding: 0 1.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.topbar-left { display: flex; align-items: center; gap: 1rem; }
+.topbar-name {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: var(--snow3);
+ letter-spacing: 0.02em;
+}
+.topbar-right { display: flex; align-items: center; gap: 1.25rem; }
+.topbar-username {
+ font-size: 0.8rem;
+ color: var(--snow1);
+ opacity: 0.45;
+}
+.nav-link {
+ color: var(--snow1);
+ text-decoration: none;
+ font-size: 0.875rem;
+ font-weight: 400;
+ letter-spacing: 0.03em;
+ transition: color 0.15s;
+}
+.nav-link:hover { color: var(--lightblue); }
+.nav-link.signout:hover { color: var(--red); }
+.topbar-home {
+ display: flex; align-items: center;
+ color: var(--snow1); opacity: 0.45;
+ transition: opacity 0.15s, color 0.15s;
+}
+.topbar-home:hover { opacity: 1; color: var(--lightblue); }
+.topbar-home svg { width: 1rem; height: 1rem; }
+
+/* ── App body ── */
+.app-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ── Sidebar ── */
+.sidebar {
+ width: 220px;
+ flex-shrink: 0;
+ background: var(--polar2);
+ border-right: 1px solid var(--polar3);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ transition: width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+}
+.sidebar.collapsed { width: 0 !important; }
+
+.sidebar-header {
+ padding: 1.25rem 1rem 0.75rem;
+ font-size: 0.7rem;
+ font-weight: 500;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--snow1);
+ opacity: 0.45;
+ flex-shrink: 0;
+}
+.tree-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 0.5rem;
+}
+.tree-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.5rem;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s;
+ user-select: none;
+}
+.tree-item:hover { background: var(--polar3); }
+.tree-item.active { background: rgba(136,192,208,0.08); }
+.tree-item-name {
+ flex: 1;
+ font-size: 0.875rem;
+ color: var(--snow2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.tree-item-role {
+ font-size: 0.65rem;
+ color: var(--snow1);
+ opacity: 0.4;
+ letter-spacing: 0.05em;
+ flex-shrink: 0;
+}
+.tree-item-icon {
+ width: 14px; height: 14px;
+ color: var(--lightblue);
+ flex-shrink: 0;
+}
+
+.sidebar-actions {
+ padding: 0.75rem 1rem 1.25rem;
+ border-top: 1px solid var(--polar3);
+ flex-shrink: 0;
+}
+.btn-new-tree {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ background: transparent;
+ border: 1px solid var(--polar4);
+ border-radius: 7px;
+ color: var(--snow1);
+ font-size: 0.8rem;
+ font-family: inherit;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: border-color 0.15s, color 0.15s;
+}
+.btn-new-tree:hover { border-color: var(--lightblue); color: var(--lightblue); }
+.btn-new-tree svg { width: 14px; height: 14px; }
+
+/* ── Canvas area ── */
+.canvas-area {
+ flex: 1;
+ min-width: 0;
+ position: relative;
+ overflow: hidden;
+ background: var(--polar1);
+}
+
+#treeCanvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ cursor: grab;
+}
+#treeCanvas:active { cursor: grabbing; }
+
+/* ── Canvas toolbar ── */
+.canvas-toolbar {
+ position: absolute;
+ top: 1rem;
+ left: 1rem;
+ z-index: 10;
+ display: flex;
+ gap: 0.4rem;
+ align-items: center;
+}
+.toolbar-btn {
+ width: 34px; height: 34px;
+ border-radius: 7px;
+ border: 1px solid var(--polar3);
+ background: var(--polar2);
+ color: var(--snow1);
+ cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+ transition: border-color 0.15s, color 0.15s;
+}
+.toolbar-btn:hover { border-color: var(--lightblue); color: var(--lightblue); }
+.toolbar-btn svg { width: 16px; height: 16px; }
+.toolbar-btn-primary {
+ width: auto;
+ padding: 0 0.85rem;
+ gap: 0.4rem;
+ font-size: 0.8rem;
+ font-family: inherit;
+ font-weight: 500;
+ color: var(--lightblue);
+ border-color: var(--lightblue);
+}
+.toolbar-sep {
+ width: 1px; height: 22px;
+ background: var(--polar3);
+ margin: 0 0.1rem;
+}
+
+/* ── Empty state ── */
+.empty-state {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ pointer-events: none;
+}
+.empty-state button { pointer-events: all; }
+.empty-icon svg {
+ width: 64px; height: 64px;
+ color: var(--polar4);
+ stroke-width: 1;
+}
+.empty-title {
+ font-family: 'EB Garamond', Georgia, serif;
+ font-size: 1.5rem;
+ color: var(--snow2);
+}
+.empty-sub {
+ font-size: 0.875rem;
+ color: var(--snow1);
+ opacity: 0.45;
+ margin-bottom: 0.5rem;
+}
+
+/* ── D3 node styles (applied via JS/SVG attributes + these classes) ── */
+.node-card {
+ fill: var(--polar3);
+ stroke: var(--polar4);
+ stroke-width: 1;
+ rx: 8;
+ ry: 8;
+ transition: stroke 0.15s;
+}
+.node-card.hovered, .node-card.selected {
+ stroke: var(--lightblue);
+}
+.node-name {
+ font-family: 'EB Garamond', Georgia, serif;
+ fill: var(--snow3);
+ font-size: 14px;
+ font-weight: 600;
+}
+.node-dates {
+ font-family: 'Inter', sans-serif;
+ fill: var(--snow1);
+ font-size: 11px;
+ opacity: 0.6;
+}
+.edge-line {
+ stroke: var(--polar4);
+ stroke-width: 2;
+ fill: none;
+}
+.partner-dot {
+ fill: var(--polar3);
+ stroke: var(--polar4);
+ stroke-width: 1.5;
+ cursor: pointer;
+ transition: stroke 0.15s;
+}
+.partner-dot:hover { stroke: var(--lightblue); }
+
+/* ── Detail panel ── */
+.detail-panel {
+ width: 0;
+ flex-shrink: 0;
+ background: var(--polar2);
+ border-left: 1px solid transparent;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ transition: width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), border-color 0.3s;
+}
+.detail-panel.open {
+ width: 300px;
+ border-color: var(--polar3);
+}
+.detail-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem 1rem 0.75rem;
+ border-bottom: 1px solid var(--polar3);
+ flex-shrink: 0;
+ gap: 0.5rem;
+}
+.detail-name {
+ font-family: 'EB Garamond', Georgia, serif;
+ font-size: 1.15rem;
+ font-weight: 600;
+ color: var(--snow3);
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.detail-header-actions { display: flex; gap: 0.3rem; flex-shrink: 0; }
+.detail-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1rem;
+}
+.detail-field { margin-bottom: 1rem; }
+.detail-field-label {
+ font-size: 0.68rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--snow1);
+ opacity: 0.45;
+ margin-bottom: 0.2rem;
+}
+.detail-field-value {
+ font-size: 0.875rem;
+ color: var(--snow2);
+ line-height: 1.5;
+}
+.detail-section-title {
+ font-size: 0.7rem;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--snow1);
+ opacity: 0.35;
+ margin: 1.25rem 0 0.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--polar3);
+}
+.detail-rel-item {
+ font-size: 0.875rem;
+ color: var(--snow1);
+ padding: 0.3rem 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ border-radius: 4px;
+ padding: 0.3rem 0.4rem;
+ transition: background 0.1s;
+}
+.detail-rel-item:hover { background: var(--polar3); }
+.detail-rel-badge {
+ font-size: 0.65rem;
+ color: var(--snow1);
+ opacity: 0.45;
+ background: var(--polar3);
+ border-radius: 3px;
+ padding: 0.1rem 0.35rem;
+ flex-shrink: 0;
+}
+.detail-actions {
+ padding: 1rem;
+ border-top: 1px solid var(--polar3);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+.detail-actions .btn { width: 100%; justify-content: center; }
+
+/* ── Connect options ── */
+.connect-options { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.5rem; }
+.connect-btn {
+ justify-content: flex-start;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ font-size: 0.9rem;
+ border-color: var(--polar3);
+}
+.connect-btn:hover { border-color: var(--lightblue); color: var(--lightblue); }
+.connect-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
+
+/* ── Modal ── */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.55);
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s;
+}
+.modal-overlay.open { opacity: 1; pointer-events: all; }
+.modal {
+ background: var(--polar2);
+ border: 1px solid var(--polar3);
+ border-radius: 12px;
+ padding: 2rem;
+ width: 420px;
+ max-width: calc(100vw - 2rem);
+ max-height: calc(100vh - 4rem);
+ overflow-y: auto;
+ transform: translateY(12px);
+ transition: transform 0.2s;
+}
+.modal-overlay.open .modal { transform: translateY(0); }
+.modal-wide { width: 520px; }
+.modal h2 {
+ font-family: 'EB Garamond', Georgia, serif;
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: var(--snow3);
+ margin-bottom: 1.5rem;
+}
+.form-group { margin-bottom: 1.1rem; }
+.form-label {
+ display: block;
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--snow1);
+ opacity: 0.55;
+ margin-bottom: 0.4rem;
+}
+.form-input {
+ width: 100%;
+ padding: 0.55rem 0.75rem;
+ background: var(--polar1);
+ border: 1px solid var(--polar3);
+ border-radius: 7px;
+ color: var(--snow2);
+ font-family: inherit;
+ font-size: 0.9rem;
+ outline: none;
+ transition: border-color 0.15s;
+}
+.form-input:focus { border-color: var(--lightblue); }
+.form-input::placeholder { color: var(--snow1); opacity: 0.3; }
+textarea.form-input { resize: vertical; min-height: 72px; line-height: 1.5; }
+select.form-input {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23D8DEE9' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.75rem center;
+ cursor: pointer;
+}
+select.form-input option { background: var(--polar2); color: var(--snow1); }
+.form-row { display: flex; gap: 0.75rem; margin-bottom: 1.1rem; }
+.form-row .form-group { flex: 1; margin-bottom: 0; }
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.6rem;
+ margin-top: 1.75rem;
+}
+.invite-row { display: flex; gap: 0.5rem; align-items: center; }
+.invite-row .form-input { flex: 1; }
+
+/* Members list */
+.member-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--polar3);
+ font-size: 0.875rem;
+}
+.member-username { flex: 1; color: var(--snow2); }
+.member-role {
+ font-size: 0.7rem;
+ color: var(--snow1);
+ opacity: 0.5;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.pending-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0;
+ font-size: 0.8rem;
+ color: var(--snow1);
+ opacity: 0.6;
+}
+.pending-row span { flex: 1; }
+
+/* ── Buttons ── */
+.btn {
+ padding: 0.5rem 1.1rem;
+ border-radius: 7px;
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ border: 1px solid var(--polar3);
+ background: transparent;
+ color: var(--snow1);
+ display: inline-flex; align-items: center; gap: 0.4rem;
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
+}
+.btn:hover { border-color: var(--snow1); }
+.btn-primary {
+ background: var(--lightblue);
+ border-color: var(--lightblue);
+ color: var(--polar1);
+}
+.btn-primary:hover { background: var(--turquoise); border-color: var(--turquoise); }
+.btn-danger { border-color: var(--red); color: var(--red); }
+.btn-danger:hover { background: rgba(191,97,106,0.15); }
+.btn-icon {
+ width: 30px; height: 30px;
+ border-radius: 6px;
+ border: 1px solid var(--polar3);
+ background: transparent;
+ color: var(--snow1);
+ cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+ flex-shrink: 0;
+ transition: border-color 0.15s, color 0.15s;
+}
+.btn-icon:hover { border-color: var(--lightblue); color: var(--lightblue); }
+.btn-icon.danger:hover { border-color: var(--red); color: var(--red); }
+.btn-icon svg { width: 14px; height: 14px; }
+.btn-sidebar-toggle {
+ width: 28px; height: 28px;
+ border-radius: 6px;
+ border: 1px solid var(--polar3);
+ background: transparent;
+ color: var(--snow1);
+ cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+ transition: border-color 0.15s, color 0.15s;
+}
+.btn-sidebar-toggle:hover { border-color: var(--lightblue); color: var(--lightblue); }
+.btn-sidebar-toggle svg { width: 14px; height: 14px; }
+
+/* ── Context menu ── */
+.ctx-menu {
+ position: fixed;
+ z-index: 300;
+ background: var(--polar2);
+ border: 1px solid var(--polar3);
+ border-radius: 8px;
+ padding: 0.3rem;
+ min-width: 168px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+ display: none;
+}
+.ctx-menu.open { display: block; }
+.ctx-item {
+ padding: 0.45rem 0.75rem;
+ border-radius: 5px;
+ font-size: 0.85rem;
+ cursor: pointer;
+ color: var(--snow1);
+ transition: background 0.1s;
+ display: flex; align-items: center; gap: 0.5rem;
+}
+.ctx-item:hover { background: var(--polar3); }
+.ctx-item.danger { color: var(--red); }
+.ctx-item.danger:hover { background: rgba(191,97,106,0.15); }
+
+/* ── Scrollbar ── */
+::-webkit-scrollbar { width: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--polar4); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: var(--snow1); opacity: 0.3; }
+
+/* ── Mobile ── */
+@media (max-width: 700px) {
+ .sidebar { width: 0; }
+ .detail-panel.open { width: 100%; position: absolute; right: 0; top: 0; bottom: 0; z-index: 50; }
+}