familytree

A self-hosted family tree builder.
Log | Files | Refs | README

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:
A.env.example | 1+
A.gitignore | 6++++++
AREADME.md | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/__init__.py | 0
Aapp/database.py | 19+++++++++++++++++++
Aapp/main.py | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/models.py | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/schemas.py | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afamilytree.service | 15+++++++++++++++
Anginx/family | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arequirements.txt | 5+++++
Astatic/app.js | 950+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/index.html | 347+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/style.css | 597+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); +} +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; } +}