familytree

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

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:
MREADME.md | 6+++++-
Mapp/main.py | 27++++++++++++++++++++++++++-
Mapp/schemas.py | 5+++++
Mstatic/app.js | 452+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mstatic/i18n.js | 13+++++++++----
Mstatic/index.html | 14++++++++++----
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>