commit 8f244e00bf1eaf2ae4ea20490d95c1ce69a8428c
parent aabd5cd68a20bbd86d33254f4ddcb835b26ca3d9
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date: Sat, 9 May 2026 17:43:37 +0000
Add username-based member invite, PDF export, and layout crossing fix
- Replace email-based invite with direct username lookup (POST /api/trees/{id}/members)
- Add Download PDF button: renders full tree at print resolution with all person data
- Fix Sugiyama upward-sweep comparator: slot tiebreaker fires first for shared children to eliminate crossing edges
- Update README to reflect username-based sharing and PDF export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat:
6 files changed, 450 insertions(+), 67 deletions(-)
diff --git a/README.md b/README.md
@@ -10,12 +10,16 @@ Self-hosted family tree builder, served at [familytree.piribauer.ch](https://fam
## 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.
+Each user can create their own family tree. Others can be added directly by their Authentik username via the "Manage members" modal. 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.
+The layout uses a Sugiyama-style algorithm with barycentric crossing minimisation to minimise crossing edges across generations.
+
+A **Download PDF** button renders the full tree at print resolution with all person details (name, dates, places, biography excerpt) embedded in each card.
+
## Installation
```console
diff --git a/app/main.py b/app/main.py
@@ -8,7 +8,7 @@ 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,
+ TreeCreate, TreeRename, InviteRequest, RoleUpdate, AddMemberRequest,
PersonCreate, PersonUpdate,
PartnershipCreate, PartnershipUpdate,
ParentLinkCreate,
@@ -210,6 +210,26 @@ def revoke_invitation(tree_id: int, email: str, request: Request, db: Session =
db.commit()
+@app.post("/api/trees/{tree_id}/members", status_code=201)
+def add_member(tree_id: int, body: AddMemberRequest, request: Request, db: Session = Depends(get_db)):
+ username, _ = _get_identity(request)
+ _require_owner(db, tree_id, username)
+ target = body.username.strip()
+ if not target:
+ raise HTTPException(status_code=422, detail="Username is required")
+ if body.role not in ("editor", "viewer"):
+ raise HTTPException(status_code=422, detail="role must be editor or viewer")
+ if target == username:
+ raise HTTPException(status_code=400, detail="Cannot add yourself")
+ try:
+ db.add(TreeMembership(tree_id=tree_id, username=target, role=body.role))
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="User is already a member")
+ return {"username": target, "role": body.role}
+
+
@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)
@@ -322,6 +342,11 @@ def add_partnership(tree_id: int, body: PartnershipCreate, request: Request, db:
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 db.query(Partnership).filter(
+ ((Partnership.person1_id == pid) | (Partnership.person2_id == pid)),
+ Partnership.tree_id == tree_id
+ ).first():
+ raise HTTPException(status_code=409, detail=f"Person {pid} already has a partnership")
if body.union_type not in ("married", "partnered", "separated"):
raise HTTPException(status_code=422, detail="Invalid union_type")
try:
diff --git a/app/schemas.py b/app/schemas.py
@@ -18,6 +18,11 @@ class RoleUpdate(BaseModel):
role: str
+class AddMemberRequest(BaseModel):
+ username: str
+ role: str = "viewer"
+
+
class PersonCreate(BaseModel):
first_name: str
last_name: str | None = None
diff --git a/static/app.js b/static/app.js
@@ -181,8 +181,11 @@ function fitCanvas() {
svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
-function computeLayout({ people, partnerships, parentLinks }) {
+function computeLayout({ people, partnerships, parentLinks }, opts = {}) {
const pos = {};
+ const _NW = opts.nodeW ?? NODE_W;
+ const _CG = opts.coupleGap ?? COUPLE_GAP;
+ const _GY = opts.genY ?? GEN_Y;
const partnershipChildren = {};
const singleParentChildren = {};
@@ -255,16 +258,18 @@ function computeLayout({ people, partnerships, parentLinks }) {
byGen[g].push(p.id);
}
- // ── Horizontal layout: bottom-up subtree widths, top-down X assignment ──────
- // Each couple/single is a "group". We compute the minimum horizontal span
- // needed for a group and all its descendants (subtree width), then assign X
- // positions top-down so every parent is automatically centered above its
- // children — no top-down anchoring that can drift when children spread wider.
+ // ── Horizontal layout ──────────────────────────────────────────────────────
+ // Two-phase approach:
+ // Phase 1 – ordering only (Sugiyama barycentric): alternating downward and
+ // upward sweeps determine a crossing-free left-to-right order for
+ // every generation, including childless intermediate groups.
+ // Phase 2 – coordinates only: seed rough positions from the Phase-1 order,
+ // then refine bottom-up so each parent row centres over its children.
- const MARGIN = 30;
- const grpW = grp => grp.length === 2 ? NODE_W * 2 + COUPLE_GAP : NODE_W;
+ const MARGIN = opts.margin ?? 30;
+ const grpW = grp => grp.length === 2 ? _NW * 2 + _CG : _NW;
- // Build one group (partner-pair or single) per generation for every person.
+ // Each person belongs to exactly one layout group: their couple, or themselves.
const genGroups = {};
for (const g of Object.keys(byGen).sort((a, b) => Number(a) - Number(b))) {
const ids = byGen[g];
@@ -287,12 +292,10 @@ function computeLayout({ people, partnerships, parentLinks }) {
genGroups[Number(g)] = groups;
}
- // Map each person to their group object (array reference).
const personGroup = new Map();
for (const gs of Object.values(genGroups))
for (const grp of gs) for (const id of grp) personGroup.set(id, grp);
- // Return the child groups (next-gen groups) of a given group.
function childGroupsOf(grp) {
const seen = new Set(); const result = [];
for (const id of grp) {
@@ -309,44 +312,182 @@ function computeLayout({ people, partnerships, parentLinks }) {
return result;
}
- // Compute subtree widths bottom-up (deepest generation first).
- const stw = new Map();
- for (const g of Object.keys(genGroups).map(Number).sort((a, b) => b - a)) {
- for (const grp of genGroups[g]) {
- const children = childGroupsOf(grp);
- if (!children.length) { stw.set(grp, grpW(grp)); continue; }
- const childSpan = children.reduce((s, cg, i) => s + stw.get(cg) + (i > 0 ? MARGIN : 0), 0);
- stw.set(grp, Math.max(grpW(grp), childSpan));
+ const childrenOf = new Map();
+ for (const gs of Object.values(genGroups))
+ for (const grp of gs) childrenOf.set(grp, childGroupsOf(grp));
+
+ // parentsOf: reverse of childrenOf — needed for the downward sweep.
+ const parentsOf = new Map();
+ for (const [grp, ch] of childrenOf)
+ for (const cg of ch) {
+ if (!parentsOf.has(cg)) parentsOf.set(cg, []);
+ parentsOf.get(cg).push(grp);
+ }
+
+ // groupChildSlot: slot (0 = left, 1 = right) through which parentGrp reaches childGrp.
+ // Breaks ordering ties: parents of the left person of a couple sort left of
+ // parents of the right person when both share the same leftmost child.
+ const groupChildSlot = new Map();
+ for (const pl of parentLinks) {
+ const parentPs = pl.partnership_id != null
+ ? partnerships.find(p => p.id === pl.partnership_id) : null;
+ const parentGrp = parentPs
+ ? personGroup.get(parentPs.person1_id)
+ : (pl.parent_id != null ? personGroup.get(pl.parent_id) : null);
+ if (!parentGrp) continue;
+ const childGrp = personGroup.get(pl.child_id);
+ if (!childGrp) continue;
+ const slot = childGrp.indexOf(pl.child_id);
+ if (slot === -1) continue;
+ if (!groupChildSlot.has(parentGrp)) groupChildSlot.set(parentGrp, new Map());
+ const m = groupChildSlot.get(parentGrp);
+ if (!m.has(childGrp) || slot < m.get(childGrp)) m.set(childGrp, slot);
+ }
+
+ const maxG = Object.keys(genGroups).length
+ ? Math.max(...Object.keys(genGroups).map(Number)) : 0;
+
+ // ── Phase 1: crossing-minimisation (Sugiyama barycentric) ─────────────────
+ // Start from DB insertion order and run 4 passes of alternating sweeps.
+ // Downward sweep: each generation sorted by the leftmost-rank of its parent
+ // groups in the layer above — this correctly orders childless groups by
+ // where their parents are, not by where their (absent) children would be.
+ // Upward sweep: each generation sorted by the leftmost-rank of its child
+ // groups in the layer below, with a slot tiebreaker so parents of the left
+ // person of a couple end up left of parents of the right person.
+
+ const genOrdered = {};
+ for (let g = 0; g <= maxG; g++) genOrdered[g] = [...(genGroups[g] || [])];
+
+ for (let pass = 0; pass < 4; pass++) {
+ // Downward sweep
+ for (let g = 1; g <= maxG; g++) {
+ if (!genOrdered[g].length) continue;
+ const rank = new Map(genOrdered[g - 1].map((grp, i) => [grp, i]));
+ genOrdered[g] = [...genOrdered[g]].sort((a, b) => {
+ const pA = parentsOf.get(a) || [], pB = parentsOf.get(b) || [];
+ const minA = pA.length ? Math.min(...pA.map(pg => rank.get(pg) ?? Infinity)) : Infinity;
+ const minB = pB.length ? Math.min(...pB.map(pg => rank.get(pg) ?? Infinity)) : Infinity;
+ if (minA !== minB) return minA - minB;
+ // Tiebreak: a group whose rightmost parent is further right also goes right.
+ // This handles the case where one group is a shared child of two parents
+ // (one left, one right) while another group only has the left parent — the
+ // shared child must sit right of the single-parent group to avoid crossings.
+ const maxA = pA.length ? Math.max(...pA.map(pg => rank.get(pg) ?? -Infinity)) : -Infinity;
+ const maxB = pB.length ? Math.max(...pB.map(pg => rank.get(pg) ?? -Infinity)) : -Infinity;
+ return maxA - maxB;
+ });
+ }
+ // Upward sweep
+ for (let g = maxG - 1; g >= 0; g--) {
+ if (!genOrdered[g].length) continue;
+ const rank = new Map(genOrdered[g + 1].map((grp, i) => [grp, i]));
+ const downRank = new Map(genOrdered[g].map((grp, i) => [grp, i]));
+ genOrdered[g] = [...genOrdered[g]].sort((a, b) => {
+ const cA = childrenOf.get(a) || [], cB = childrenOf.get(b) || [];
+ // HIGHEST PRIORITY: if both share any child, use the slot (left/right)
+ // of that shared child to immediately fix parent ordering. This fires
+ // before any rank comparison so it cannot be blocked by childless groups.
+ if (cA.length && cB.length) {
+ const aSet = new Set(cA);
+ for (const cg of cB) {
+ if (aSet.has(cg)) {
+ const sA = groupChildSlot.get(a)?.get(cg) ?? Infinity;
+ const sB = groupChildSlot.get(b)?.get(cg) ?? Infinity;
+ if (sA !== sB && sA !== Infinity && sB !== Infinity) return sA - sB;
+ }
+ }
+ }
+ // SECOND: leftmost child rank (only when both have children)
+ if (cA.length && cB.length) {
+ const rA = Math.min(...cA.map(cg => rank.get(cg) ?? Infinity));
+ const rB = Math.min(...cB.map(cg => rank.get(cg) ?? Infinity));
+ if (rA !== rB) return rA - rB;
+ }
+ // FALLBACK: preserve downward-sweep order (keeps childless groups near
+ // their parents rather than drifting to the end of the layer).
+ return (downRank.get(a) ?? 0) - (downRank.get(b) ?? 0);
+ });
}
}
- // Assign X positions top-down: each parent is centered above its children.
+ // ── Phase 2: coordinate assignment ────────────────────────────────────────
+ // Seed rough positions: equal spacing in the Phase-1 order.
+ // These serve as the ideal fallback for groups with no children so the
+ // bottom-up sweep never pushes them to the far right.
+
+ const roughCx = new Map();
+ for (let g = 0; g <= maxG; g++) {
+ const groups = genOrdered[g] || [];
+ const totalW = groups.reduce((s, grp, i) => s + grpW(grp) + (i > 0 ? MARGIN : 0), 0);
+ let x = -totalW / 2;
+ for (const grp of groups) {
+ roughCx.set(grp, x + grpW(grp) / 2);
+ x += grpW(grp) + MARGIN;
+ }
+ }
+
+ // Refine bottom-up: centre each parent row over its children.
+ const assignedCx = new Map(roughCx);
+
+ for (let g = maxG - 1; g >= 0; g--) {
+ const groups = genOrdered[g] || [];
+ if (!groups.length) continue;
+
+ const idealOf = new Map();
+ for (const grp of groups) {
+ const ch = (childrenOf.get(grp) || []).filter(cg => assignedCx.has(cg));
+ if (ch.length) {
+ const cxs = ch.map(cg => assignedCx.get(cg));
+ idealOf.set(grp, (Math.min(...cxs) + Math.max(...cxs)) / 2);
+ } else {
+ idealOf.set(grp, roughCx.get(grp));
+ }
+ }
+
+ // Sweep in Phase-1 order — never re-sort by ideal, that would undo the
+ // crossing-free ordering. Only push groups right to avoid overlap.
+ const result = [];
+ let prevRight = null;
+ for (const grp of groups) {
+ const ideal = idealOf.get(grp);
+ const half = grpW(grp) / 2;
+ const cx = prevRight === null
+ ? ideal
+ : Math.max(ideal, prevRight + MARGIN + half);
+ result.push([grp, cx]);
+ prevRight = cx + half;
+ }
+
+ // Re-centre: shift so groups-with-children sit at their true ideal on average.
+ const withCh = result.filter(([grp]) => (childrenOf.get(grp) || []).some(cg => assignedCx.has(cg)));
+ if (withCh.length) {
+ const trueIdeal = grp => {
+ const cxs = (childrenOf.get(grp) || []).filter(cg => assignedCx.has(cg)).map(cg => assignedCx.get(cg));
+ return (Math.min(...cxs) + Math.max(...cxs)) / 2;
+ };
+ const avgI = withCh.reduce((s, [grp]) => s + trueIdeal(grp), 0) / withCh.length;
+ const avgP = withCh.reduce((s, [, cx]) => s + cx, 0) / withCh.length;
+ const shift = avgI - avgP;
+ for (const e of result) e[1] += shift;
+ }
+
+ for (const [grp, cx] of result) assignedCx.set(grp, cx);
+ }
+
+ // Translate group centers to per-person x positions.
const assignedX = {};
- const placed2 = new Set();
- function placeGrp(grp, cx) {
- if (placed2.has(grp)) return;
- placed2.add(grp);
+ for (const [grp, cx] of assignedCx) {
if (grp.length === 2) {
- assignedX[grp[0]] = cx - NODE_W / 2 - COUPLE_GAP / 2;
- assignedX[grp[1]] = cx + NODE_W / 2 + COUPLE_GAP / 2;
+ assignedX[grp[0]] = cx - _NW / 2 - _CG / 2;
+ assignedX[grp[1]] = cx + _NW / 2 + _CG / 2;
} else {
assignedX[grp[0]] = cx;
}
- const children = childGroupsOf(grp);
- if (!children.length) return;
- const total = children.reduce((s, cg, i) => s + stw.get(cg) + (i > 0 ? MARGIN : 0), 0);
- let x = cx - total / 2;
- for (const cg of children) { placeGrp(cg, x + stw.get(cg) / 2); x += stw.get(cg) + MARGIN; }
}
- // Root groups (gen 0) are placed left-to-right, centered at origin.
- const roots = genGroups[0] || [];
- const rootSpan = roots.reduce((s, grp, i) => s + stw.get(grp) + (i > 0 ? MARGIN : 0), 0);
- let rx = -rootSpan / 2;
- for (const grp of roots) { placeGrp(grp, rx + stw.get(grp) / 2); rx += stw.get(grp) + MARGIN; }
-
for (const p of people) {
- pos[p.id] = { x: assignedX[p.id] ?? 0, y: gen[p.id] * GEN_Y };
+ pos[p.id] = { x: assignedX[p.id] ?? 0, y: gen[p.id] * _GY };
}
return pos;
}
@@ -372,20 +513,34 @@ function drawEdges(pos) {
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;
+ if (children.length === 1 && Math.abs(childXs[0] - mx) > 1) {
+ // Single child not directly below this couple: draw a bezier curve so
+ // the connector doesn't sit at the same horizontal level as sibling bars
+ // from other families, which would make unrelated people look like siblings.
+ const cp = pos[children[0]];
+ if (cp) {
+ g.append('path').attr('class', 'edge-line').attr('fill', 'none')
+ .attr('d', `M${mx},${midY + 6} C${mx},${midY + 70} ${cp.x},${cp.y - 70} ${cp.x},${cp.y}`);
+ }
+ } else {
+ // Standard comb: vertical stem → horizontal sibling bar → drops to children.
g.append('line').attr('class', 'edge-line')
- .attr('x1', cp.x).attr('y1', dropY)
- .attr('x2', cp.x).attr('y2', cp.y);
+ .attr('x1', mx).attr('y1', midY + 6)
+ .attr('x2', mx).attr('y2', dropY);
+ if (children.length > 1) {
+ const barMin = Math.min(mx, ...childXs);
+ const barMax = Math.max(mx, ...childXs);
+ g.append('line').attr('class', 'edge-line')
+ .attr('x1', barMin).attr('y1', dropY)
+ .attr('x2', barMax).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);
+ }
}
}
}
@@ -447,6 +602,185 @@ function drawNodes(pos) {
}
}
+/* ── PDF export ── */
+function wrapText(text, maxChars, maxLines) {
+ const words = text.split(/\s+/);
+ const lines = [];
+ let cur = '';
+ for (const word of words) {
+ const test = cur ? cur + ' ' + word : word;
+ if (test.length <= maxChars) {
+ cur = test;
+ } else {
+ if (cur) lines.push(cur);
+ if (lines.length >= maxLines) break;
+ cur = word.slice(0, maxChars);
+ }
+ }
+ if (cur && lines.length < maxLines) lines.push(cur);
+ return lines;
+}
+
+function drawPrintEdges(g, pos, { partnerships, parentLinks }, NW, NH) {
+ const edgeG = g.append('g');
+ const lineAttr = sel => sel.attr('stroke', '#444').attr('stroke-width', 1.5).attr('fill', 'none');
+
+ 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 + NH / 2;
+
+ lineAttr(edgeG.append('line'))
+ .attr('x1', p1.x + NW / 2).attr('y1', midY)
+ .attr('x2', p2.x - NW / 2).attr('y2', midY);
+
+ edgeG.append('circle').attr('cx', mx).attr('cy', midY).attr('r', 5).attr('fill', '#444');
+
+ const children = parentLinks.filter(pl => pl.partnership_id === ps.id).map(pl => pl.child_id);
+ if (children.length) {
+ const dropY = midY + 50;
+ const childXs = children.map(cid => pos[cid]?.x ?? mx);
+ if (children.length === 1 && Math.abs(childXs[0] - mx) > 1) {
+ const cp = pos[children[0]];
+ if (cp) lineAttr(edgeG.append('path'))
+ .attr('d', `M${mx},${midY + 5} C${mx},${midY + 80} ${cp.x},${cp.y - 80} ${cp.x},${cp.y}`);
+ } else {
+ lineAttr(edgeG.append('line')).attr('x1', mx).attr('y1', midY + 5).attr('x2', mx).attr('y2', dropY);
+ if (children.length > 1) {
+ lineAttr(edgeG.append('line'))
+ .attr('x1', Math.min(mx, ...childXs)).attr('y1', dropY)
+ .attr('x2', Math.max(mx, ...childXs)).attr('y2', dropY);
+ }
+ for (const cid of children) {
+ const cp = pos[cid]; if (!cp) continue;
+ lineAttr(edgeG.append('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;
+ lineAttr(edgeG.append('path'))
+ .attr('d', `M${pp.x},${pp.y + NH} C${pp.x},${pp.y + NH + 55} ${cp.x},${cp.y - 55} ${cp.x},${cp.y}`);
+ }
+}
+
+function drawPrintNodes(g, pos, people, NW, NH) {
+ const nodeG = g.append('g');
+ for (const person of people) {
+ const { x, y } = pos[person.id] ?? { x: 0, y: 0 };
+ const grp = nodeG.append('g').attr('transform', `translate(${x - NW / 2},${y})`);
+
+ grp.append('rect')
+ .attr('width', NW).attr('height', NH).attr('rx', 6).attr('ry', 6)
+ .attr('fill', 'white').attr('stroke', '#333').attr('stroke-width', 1);
+
+ const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ');
+ grp.append('text')
+ .attr('x', NW / 2).attr('y', 20)
+ .attr('text-anchor', 'middle')
+ .attr('font-family', "'EB Garamond', Georgia, serif")
+ .attr('font-size', 14).attr('font-weight', 600).attr('fill', '#111')
+ .text(fullName);
+
+ grp.append('line')
+ .attr('x1', 8).attr('y1', 26).attr('x2', NW - 8).attr('y2', 26)
+ .attr('stroke', '#ccc').attr('stroke-width', 0.5);
+
+ let lineY = 40;
+ const addLine = (label, value, color = '#333') => {
+ if (lineY > NH - 8) return;
+ grp.append('text')
+ .attr('x', 10).attr('y', lineY)
+ .attr('font-family', 'Inter, sans-serif')
+ .attr('font-size', 10).attr('fill', color)
+ .text(label + value);
+ lineY += 14;
+ };
+
+ const bornParts = [person.birth_date, person.birth_place].filter(Boolean);
+ if (bornParts.length) addLine('* ', bornParts.join(' · '));
+ if (person.death_date) addLine('† ', person.death_date);
+ if (person.burial_place) addLine('⚰ ', truncate(person.burial_place, 26), '#555');
+ if (person.bio && lineY <= NH - 14) {
+ const linesAvail = Math.floor((NH - lineY - 4) / 12);
+ if (linesAvail >= 1) {
+ const bioLines = wrapText(person.bio, 28, linesAvail);
+ for (const line of bioLines) {
+ if (lineY > NH - 8) break;
+ grp.append('text')
+ .attr('x', 10).attr('y', lineY)
+ .attr('font-family', 'Inter, sans-serif')
+ .attr('font-size', 9).attr('fill', '#555').attr('font-style', 'italic')
+ .text(line);
+ lineY += 12;
+ }
+ }
+ }
+ }
+}
+
+function downloadPDF() {
+ if (!state.activeTreeId) return;
+ const tree = state.trees.find(t => t.id === state.activeTreeId);
+ const NW = 225, NH = 115, GY = 188, CG = 52;
+
+ const pos = computeLayout(state.graph, { nodeW: NW, genY: GY, coupleGap: CG, margin: 48 });
+
+ const container = document.createElement('div');
+ container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden';
+ document.body.appendChild(container);
+
+ const printSvg = d3.select(container).append('svg').attr('xmlns', 'http://www.w3.org/2000/svg');
+ const printG = printSvg.append('g');
+
+ drawPrintEdges(printG, pos, state.graph, NW, NH);
+ drawPrintNodes(printG, pos, state.graph.people, NW, NH);
+
+ const bb = printG.node().getBBox();
+ const pad = 48;
+ printSvg
+ .attr('viewBox', `${bb.x - pad} ${bb.y - pad} ${bb.width + pad * 2} ${bb.height + pad * 2}`)
+ .attr('width', bb.width + pad * 2)
+ .attr('height', bb.height + pad * 2);
+
+ const svgStr = new XMLSerializer().serializeToString(printSvg.node());
+ document.body.removeChild(container);
+
+ const treeName = tree ? esc(tree.name) : 'Family Tree';
+ const html = `<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>${treeName}</title>
+ <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@400;500&family=EB+Garamond:wght@400;600&display=swap" rel="stylesheet">
+ <style>
+ @page { size: auto; margin: 10mm; }
+ * { box-sizing: border-box; }
+ body { margin: 0; background: white; display: flex; flex-direction: column; align-items: center; }
+ h1 { font-family: 'EB Garamond', Georgia, serif; font-size: 22px; font-weight: 600; color: #111; margin: 16px 0 8px; }
+ svg { max-width: 100%; height: auto; display: block; }
+ </style>
+</head>
+<body>
+ <h1>${treeName}</h1>
+ ${svgStr}
+</body>
+</html>`;
+
+ const win = window.open('', '_blank');
+ if (!win) { showError('Popup blocked — allow popups for this site.'); return; }
+ win.document.write(html);
+ win.document.close();
+ win.focus();
+ setTimeout(() => win.print(), 600);
+}
+
/* ── Detail panel ── */
function openDetailPanel(personId) {
state.selectedPersonId = personId;
@@ -548,9 +882,11 @@ function openDetailPanel(personId) {
}
if (state.canEdit) {
- html += `<p class="detail-section-title">${t('add-relationship')}</p>
- <button class="btn" style="width:100%;margin-bottom:0.5rem" onclick="openAddPartnershipModal(${personId})">${t('add-partnership-btn')}</button>
- <button class="btn" style="width:100%;margin-bottom:0.5rem" onclick="openAddParentLinkModal(${personId},'child')">${t('set-parents-btn')}</button>
+ html += `<p class="detail-section-title">${t('add-relationship')}</p>`;
+ if (!myPartnerships.length) {
+ html += `<button class="btn" style="width:100%;margin-bottom:0.5rem" onclick="openAddPartnershipModal(${personId})">${t('add-partnership-btn')}</button>`;
+ }
+ html += `<button class="btn" style="width:100%;margin-bottom:0.5rem" onclick="openAddParentLinkModal(${personId},'child')">${t('set-parents-btn')}</button>
<button class="btn" style="width:100%" onclick="openAddParentLinkModal(null,'parent',${personId})">${t('add-child-btn')}</button>`;
}
@@ -990,16 +1326,18 @@ window.revokeInvitation = async (treeId, email) => {
};
document.getElementById('btnInviteSend').addEventListener('click', async () => {
- const email = document.getElementById('inviteEmail').value.trim();
+ const username = document.getElementById('inviteUsername').value.trim();
const role = document.getElementById('inviteRole').value;
- if (!email) return;
+ if (!username) return;
try {
- await api('POST', `/api/trees/${state._ctxTreeId}/invite`, { email, role });
- document.getElementById('inviteEmail').value = '';
+ await api('POST', `/api/trees/${state._ctxTreeId}/members`, { username, role });
+ document.getElementById('inviteUsername').value = '';
await loadTrees(); renderMembersModal(state._ctxTreeId);
} catch(e) { showError(e.message); }
});
+document.getElementById('btnDownloadPDF').addEventListener('click', downloadPDF);
+
document.getElementById('btnMembersClose').addEventListener('click', () => closeModal('modalMembers'));
/* ── Toolbar ── */
diff --git a/static/i18n.js b/static/i18n.js
@@ -90,13 +90,16 @@
'form-parent': 'Parent',
// members modal
'members-title': 'Members',
- 'invite-by-email': 'Invite by email',
+ 'add-by-username': 'Add by username',
+ 'username-ph': 'username',
'role-editor': 'Editor',
'role-viewer': 'Viewer',
- 'invite-btn': 'Invite',
+ 'invite-btn': 'Add',
'done': 'Done',
'remove': 'Remove',
'pending': 'Pending',
+ // toolbar
+ 'download-pdf': 'PDF',
// canvas
'no-people-yet': 'No people yet — use “Add person” to get started',
// roles in sidebar
@@ -187,13 +190,15 @@
'form-partnership': 'Partnerschaft',
'form-parent': 'Elternteil',
'members-title': 'Mitglieder',
- 'invite-by-email': 'Per E-Mail einladen',
+ 'add-by-username': 'Benutzername hinzufügen',
+ 'username-ph': 'Benutzername',
'role-editor': 'Bearbeiter',
'role-viewer': 'Betrachter',
- 'invite-btn': 'Einladen',
+ 'invite-btn': 'Hinzufügen',
'done': 'Fertig',
'remove': 'Entfernen',
'pending': 'Ausstehend',
+ 'download-pdf': 'PDF',
'no-people-yet': 'Noch keine Personen — verwende „Person hinzufügen“ um zu beginnen',
'role-owner': 'Besitzer',
'alert-first-name-req': 'Vorname ist erforderlich.',
diff --git a/static/index.html b/static/index.html
@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <script>window.onerror=function(m,s,l,c,e){var b=document.createElement('div');b.style.cssText='position:fixed;top:0;left:0;right:0;background:#bf616a;color:#fff;padding:1rem;z-index:99999;font-family:monospace;font-size:13px;white-space:pre-wrap';b.textContent='JS ERROR: '+m+'\n'+s+':'+l;document.body?document.body.prepend(b):document.addEventListener('DOMContentLoaded',function(){document.body.prepend(b)});};</script>
<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">
@@ -95,6 +94,13 @@
</svg>
</button>
<div class="toolbar-sep"></div>
+ <button class="toolbar-btn" id="btnDownloadPDF" data-i18n-title="download-pdf" title="Download PDF">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12M12 16.5V3"/>
+ </svg>
+ <span data-i18n="download-pdf">PDF</span>
+ </button>
+ <div class="toolbar-sep"></div>
<button class="toolbar-btn toolbar-btn-primary" id="btnAddPerson" data-i18n-title="add-person" 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"/>
@@ -329,14 +335,14 @@
<h2 data-i18n="members-title">Members</h2>
<div id="membersList"></div>
<div class="members-invite-section">
- <p class="form-label" style="margin-bottom:0.6rem;margin-top:1.25rem" data-i18n="invite-by-email">Invite by email</p>
+ <p class="form-label" style="margin-bottom:0.6rem;margin-top:1.25rem" data-i18n="add-by-username">Add by username</p>
<div class="invite-row">
- <input class="form-input" id="inviteEmail" type="email" placeholder="email@example.com">
+ <input class="form-input" id="inviteUsername" type="text" data-i18n-placeholder="username-ph" placeholder="username">
<select class="form-input" id="inviteRole" style="width:110px;flex-shrink:0">
<option value="editor" data-i18n="role-editor">Editor</option>
<option value="viewer" selected data-i18n="role-viewer">Viewer</option>
</select>
- <button class="btn btn-primary" id="btnInviteSend" data-i18n="invite-btn">Invite</button>
+ <button class="btn btn-primary" id="btnInviteSend" data-i18n="invite-btn">Add</button>
</div>
<div id="pendingList"></div>
</div>