familytree

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

commit 342025d2f6e345894003c0ef2b2d47caa01e2e2f
parent b50a48ef0e3097892d3efe7844a5b661fc6e4692
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date:   Sun,  3 May 2026 06:31:39 +0000

Fix generation layout and parent-anchored horizontal placement

- Normalize API response parent_links → parentLinks in loadGraph
- Fix BFS: partners without parents now inherit their partner's generation;
  children are re-pushed down when parent generations change (combined loop)
- Replace per-row independent centering with parent-anchored X placement:
  each sibling group centers under its parents, left-to-right sweep resolves
  overlaps; unanchored rows (gen 0) still center at origin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Diffstat:
Mstatic/app.js | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Astatic/i18n.js | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mstatic/index.html | 145+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mstatic/style.css | 30++++++++++++++++++++++++++++++
4 files changed, 493 insertions(+), 115 deletions(-)

diff --git a/static/app.js b/static/app.js @@ -146,7 +146,7 @@ function renderCanvas() { .attr('fill', 'var(--polar4)') .attr('font-size', 14) .attr('font-family', 'Inter, sans-serif') - .text('No people yet — use "Add person" to get started'); + .text(t('no-people-yet')); requestAnimationFrame(fitCanvas); return; } @@ -213,6 +213,34 @@ function computeLayout({ people, partnerships, parentLinks }) { } for (const p of people) { if (gen[p.id] === undefined) gen[p.id] = 0; } + // Iteratively fix generations: pull root-partners to their partner's row, + // then push children down accordingly. Must be one combined loop because + // moving a child up may require moving that child's root-partner up too. + let changed = true; + while (changed) { + changed = false; + for (const ps of partnerships) { + const g1 = gen[ps.person1_id], g2 = gen[ps.person2_id]; + if (g1 !== g2) { + if (!childIds.has(ps.person1_id) && g2 !== undefined) { gen[ps.person1_id] = g2; changed = true; } + else if (!childIds.has(ps.person2_id) && g1 !== undefined) { gen[ps.person2_id] = g1; changed = true; } + } + } + for (const pl of parentLinks) { + let parentGen; + if (pl.partnership_id) { + const ps = partnerships.find(p => p.id === pl.partnership_id); + if (ps) parentGen = Math.max(gen[ps.person1_id] ?? 0, gen[ps.person2_id] ?? 0); + } else if (pl.parent_id != null) { + parentGen = gen[pl.parent_id] ?? 0; + } + if (parentGen !== undefined) { + const expected = parentGen + 1; + if ((gen[pl.child_id] ?? 0) < expected) { gen[pl.child_id] = expected; changed = true; } + } + } + } + const byGen = {}; for (const p of people) { const g = gen[p.id]; @@ -221,10 +249,22 @@ function computeLayout({ people, partnerships, parentLinks }) { } const assignedX = {}; + const MARGIN = 30; + const grpW = grp => grp.length === 2 ? NODE_W * 2 + COUPLE_GAP : NODE_W; + const placeGrp = (grp, cx) => { + if (grp.length === 2) { + assignedX[grp[0]] = cx - NODE_W / 2 - COUPLE_GAP / 2; + assignedX[grp[1]] = cx + NODE_W / 2 + COUPLE_GAP / 2; + } else { + assignedX[grp[0]] = cx; + } + }; + 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 => @@ -243,17 +283,66 @@ function computeLayout({ people, partnerships, parentLinks }) { 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; + // Ideal center X for each group: midpoint of its parents in the row above. + // Prefer a partnership link over a single-parent link. + const idealCenter = groups.map(grp => { + const child = grp.find(id => childIds.has(id)); + if (!child) return null; + const pl = parentLinks.find(l => l.child_id === child && l.partnership_id != null) + || parentLinks.find(l => l.child_id === child); + if (!pl) return null; + if (pl.partnership_id) { + const ps = partnerships.find(p => p.id === pl.partnership_id); + if (!ps) return null; + const x1 = assignedX[ps.person1_id], x2 = assignedX[ps.person2_id]; + if (x1 == null || x2 == null) return null; + return (x1 + x2) / 2; + } + if (pl.parent_id != null) return assignedX[pl.parent_id] ?? null; + return null; + }); + + // Sort groups by ideal center; groups with no anchor go last. + const sorted = groups + .map((grp, i) => ({ grp, ic: idealCenter[i] })) + .sort((a, b) => { + if (a.ic == null && b.ic == null) return 0; + if (a.ic == null) return 1; + if (b.ic == null) return -1; + return a.ic - b.ic; + }); + + const anchored = sorted.filter(o => o.ic != null); + const floating = sorted.filter(o => o.ic == null); + + if (anchored.length === 0) { + // Gen 0 or fully unanchored: center the whole row. + const total = sorted.reduce((s, o, i) => s + grpW(o.grp) + (i > 0 ? MARGIN : 0), 0); + let x = -total / 2; + for (const { grp } of sorted) { + const w = grpW(grp); + placeGrp(grp, x + w / 2); + x += w + MARGIN; + } + } else { + // Place each anchored group at its parent midpoint, then sweep left-to-right + // so no two groups overlap. + const leftEdge = anchored.map(({ grp, ic }) => ic - grpW(grp) / 2); + for (let i = 1; i < anchored.length; i++) { + const minLeft = leftEdge[i - 1] + grpW(anchored[i - 1].grp) + MARGIN; + if (leftEdge[i] < minLeft) leftEdge[i] = minLeft; + } + for (let i = 0; i < anchored.length; i++) { + placeGrp(anchored[i].grp, leftEdge[i] + grpW(anchored[i].grp) / 2); + } + + // Floating groups (no parents, pulled into this gen via partnership) go + // right of the last anchored group. + let floatX = leftEdge[anchored.length - 1] + grpW(anchored[anchored.length - 1].grp) + MARGIN; + for (const { grp } of floating) { + const w = grpW(grp); + placeGrp(grp, floatX + w / 2); + floatX += w + MARGIN; } } } @@ -379,21 +468,21 @@ function openDetailPanel(personId) { 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); + html += field(t('born'), [person.birth_date, person.birth_place].filter(Boolean).join(' · ')); + if (person.death_date) html += field(t('died'), person.death_date); + if (person.burial_place) html += field(t('buried'), person.burial_place); + if (person.bio) html += field(t('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>`; + html += `<p class="detail-section-title">${t('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; + const label = { married: t('rel-married'), separated: t('rel-separated'), partnered: t('rel-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> @@ -406,7 +495,7 @@ function openDetailPanel(personId) { ...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>`; + html += `<p class="detail-section-title">${t('children')}</p>`; for (const cid of childIds) { const child = state.graph.people.find(p => p.id === cid); if (!child) continue; @@ -427,7 +516,7 @@ function openDetailPanel(personId) { } } if (parentPeople.length) { - html += `<p class="detail-section-title">Parents</p>`; + html += `<p class="detail-section-title">${t('parents')}</p>`; for (const pid of parentPeople) { const parent = state.graph.people.find(p => p.id === pid); if (!parent) continue; @@ -450,7 +539,7 @@ function openDetailPanel(personId) { } } if (siblingIds.length) { - html += `<p class="detail-section-title">Siblings</p>`; + html += `<p class="detail-section-title">${t('siblings')}</p>`; for (const sid of siblingIds) { const sib = state.graph.people.find(p => p.id === sid); if (!sib) continue; @@ -461,10 +550,10 @@ function openDetailPanel(personId) { } 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>`; + 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> + <button class="btn" style="width:100%" onclick="openAddParentLinkModal(null,'parent',${personId})">${t('add-child-btn')}</button>`; } document.getElementById('detailBody').innerHTML = html; @@ -493,7 +582,7 @@ function openPartnershipDetail(ps) { 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'; + document.getElementById('btnPartnerSave').textContent = t('save'); state._siblingMode = false; populatePersonSelects('partnerPerson1', 'partnerPerson2'); document.getElementById('partnerPerson1').value = ps.person1_id; @@ -539,8 +628,8 @@ document.getElementById('emptyNewTree').addEventListener('click', openNewTreeMod function openNewTreeModal() { state._editingTreeId = null; - document.getElementById('modalTreeTitle').textContent = 'New tree'; - document.getElementById('btnTreeSave').textContent = 'Create'; + document.getElementById('modalTreeTitle').textContent = t('new-tree-title'); + document.getElementById('btnTreeSave').textContent = t('create'); document.getElementById('treeNameInput').value = ''; openModal('modalTree'); setTimeout(() => document.getElementById('treeNameInput').focus(), 50); @@ -568,8 +657,8 @@ 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('modalTreeTitle').textContent = t('rename-tree-title'); + document.getElementById('btnTreeSave').textContent = t('save'); document.getElementById('treeNameInput').value = tree.name; openModal('modalTree'); }); @@ -577,7 +666,7 @@ document.getElementById('ctxRename').addEventListener('click', () => { document.getElementById('ctxMembers').addEventListener('click', () => openMembersModal(state._ctxTreeId)); document.getElementById('ctxDelete').addEventListener('click', async () => { - if (!confirm('Delete this tree and all its data?')) return; + if (!confirm(t('confirm-delete-tree'))) return; try { await api('DELETE', `/api/trees/${state._ctxTreeId}`); if (state.activeTreeId === state._ctxTreeId) selectTree(null); @@ -586,7 +675,7 @@ document.getElementById('ctxDelete').addEventListener('click', async () => { }); document.getElementById('ctxLeave').addEventListener('click', async () => { - if (!confirm('Leave this tree?')) return; + if (!confirm(t('confirm-leave-tree'))) return; try { await api('DELETE', `/api/trees/${state._ctxTreeId}/leave`); if (state.activeTreeId === state._ctxTreeId) selectTree(null); @@ -599,7 +688,7 @@ document.getElementById('btnAddPerson').addEventListener('click', () => openPers function openPersonModal(person) { state._editingPersonId = person?.id ?? null; - document.getElementById('modalPersonTitle').textContent = person ? 'Edit person' : 'Add person'; + document.getElementById('modalPersonTitle').textContent = person ? t('edit-person-title') : t('add-person-title'); document.getElementById('personFirstName').value = person?.first_name ?? ''; document.getElementById('personLastName').value = person?.last_name ?? ''; document.getElementById('personBirthDate').value = person?.birth_date ?? ''; @@ -623,7 +712,7 @@ document.getElementById('btnPersonSave').addEventListener('click', async () => { 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; } + if (!body.first_name) { alert(t('alert-first-name-req')); return; } try { const isNew = !state._editingPersonId; let saved; @@ -650,7 +739,7 @@ document.getElementById('btnEditPerson').addEventListener('click', () => { }); document.getElementById('btnDeletePerson').addEventListener('click', async () => { - if (!confirm('Delete this person and all their relationships?')) return; + if (!confirm(t('confirm-delete-person'))) return; try { await api('DELETE', `/api/trees/${state.activeTreeId}/people/${state.selectedPersonId}`); closeDetailPanel(); @@ -697,7 +786,7 @@ function openSiblingModal(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…'; + document.getElementById('modalPartnershipTitle').textContent = t('sibling-of-title'); // Show only person1 select; hide partner/date fields populatePersonSelects('partnerPerson1'); Array.from(document.getElementById('partnerPerson1').options) @@ -705,20 +794,20 @@ function openSiblingModal(newPersonId) { 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'; + document.getElementById('btnPartnerSave').textContent = t('add-sibling-btn'); openModal('modalPartnership'); } /* ── Partnership modal ── */ function openAddPartnershipModal(preselectedPersonId) { - if (state.graph.people.length < 2) { alert('Add at least two people first.'); return; } + if (state.graph.people.length < 2) { alert(t('alert-add-two-people')); return; } state._editingPartnershipId = null; state._siblingMode = false; - document.getElementById('modalPartnershipTitle').textContent = 'Add partnership'; + document.getElementById('modalPartnershipTitle').textContent = t('add-partnership-title'); 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'; + document.getElementById('btnPartnerSave').textContent = t('save'); populatePersonSelects('partnerPerson1', 'partnerPerson2'); if (preselectedPersonId) document.getElementById('partnerPerson1').value = preselectedPersonId; document.getElementById('partnerType').value = 'partnered'; @@ -748,7 +837,7 @@ document.getElementById('btnPartnerSave').addEventListener('click', async () => 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."); + showError(t('alert-no-parents')); return; } for (const pl of siblingLinks) { @@ -765,7 +854,7 @@ document.getElementById('btnPartnerSave').addEventListener('click', async () => const p1 = parseInt(document.getElementById('partnerPerson1').value); const p2 = parseInt(document.getElementById('partnerPerson2').value); - if (p1 === p2) { alert('Select two different people.'); return; } + if (p1 === p2) { alert(t('alert-select-diff')); return; } const body = { person1_id: p1, person2_id: p2, union_type: document.getElementById('partnerType').value, @@ -868,13 +957,13 @@ function renderMembersModal(treeId) { <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)}')"> + <button class="btn-icon danger" title="${t('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>' + + `<p class="form-label" style="margin-top:1rem;margin-bottom:0.4rem">${t('pending')}</p>` + tree.pending_invitations.map(inv => ` <div class="pending-row"> <span>${esc(inv.email)} <em style="opacity:0.5">(${inv.role})</em></span> @@ -888,7 +977,7 @@ function renderMembersModal(treeId) { } window.removeMember = async (treeId, username) => { - if (!confirm(`Remove ${username}?`)) return; + if (!confirm(`${t('remove')} ${username}?`)) return; try { await api('DELETE', `/api/trees/${treeId}/members/${username}`); await loadTrees(); renderMembersModal(treeId); @@ -945,6 +1034,13 @@ function formatDateRange(birth, death) { return death ? `${b} – ${death.slice(0, 4)}` : b; } +/* ── Language change ── */ +document.addEventListener('langchange', () => { + if (state.trees.length) renderSidebar(); + if (state.activeTreeId) renderCanvas(); + if (state.selectedPersonId) openDetailPanel(state.selectedPersonId); +}); + /* ── Init ── */ showEmptyState(); boot().catch(console.error); diff --git a/static/i18n.js b/static/i18n.js @@ -0,0 +1,247 @@ +(function () { + const TRANSLATIONS = { + en: { + // topbar + 'account': 'Account', + 'sign-out': 'Sign out', + 'toggle-sidebar': 'Toggle sidebar', + // sidebar + 'sidebar-trees': 'Trees', + 'new-tree': 'New tree', + // empty state + 'empty-title': 'No tree selected', + 'empty-sub': 'Create a tree or select one from the sidebar', + 'create-tree-btn': 'Create a tree', + // toolbar + 'zoom-in': 'Zoom in', + 'zoom-out': 'Zoom out', + 'fit-screen': 'Fit to screen', + 'add-person': 'Add person', + // detail panel buttons + 'btn-edit': 'Edit', + 'btn-delete': 'Delete', + 'btn-close': 'Close', + // detail panel fields + 'born': 'Born', + 'died': 'Died', + 'buried': 'Buried', + 'biography': 'Biography', + 'partnerships': 'Partnerships', + 'children': 'Children', + 'parents': 'Parents', + 'siblings': 'Siblings', + 'add-relationship': 'Add relationship', + 'add-partnership-btn': 'Add partnership', + 'set-parents-btn': 'Set parents of this person', + 'add-child-btn': 'Add a child for this person', + 'rel-married': 'Married', + 'rel-separated': 'Separated', + 'rel-partner': 'Partner', + // context menu + 'ctx-rename': 'Rename', + 'ctx-members': 'Manage members', + 'ctx-leave': 'Leave tree', + 'ctx-delete': 'Delete tree', + // tree modal + 'new-tree-title': 'New tree', + 'rename-tree-title': 'Rename tree', + 'form-name': 'Name', + 'tree-name-ph': 'e.g. Piribauer family', + 'create': 'Create', + 'cancel': 'Cancel', + 'save': 'Save', + // person modal + 'add-person-title': 'Add person', + 'edit-person-title': 'Edit person', + 'form-first-name': 'First name', + 'form-last-name': 'Last name', + 'form-birth-date': 'Birth date', + 'form-death-date': 'Death date', + 'form-birth-place': 'Birth place', + 'form-burial-place': 'Burial place', + 'form-biography': 'Biography', + 'bio-ph': 'A short note…', + // connect modal + 'connect-how': 'How would you like to add this person to the tree?', + 'connect-partner': 'Partner of someone', + 'connect-child': 'Child of someone', + 'connect-parent': 'Parent of someone', + 'connect-sibling': 'Sibling of someone', + 'skip-for-now': 'Skip for now', + // partnership modal + 'add-partnership-title':'Add partnership', + 'sibling-of-title': 'Add as sibling of…', + 'form-person1': 'Person 1', + 'form-person2': 'Person 2', + 'form-type': 'Type', + 'type-married': 'Married', + 'type-partnered': 'Partnered', + 'type-separated': 'Separated', + 'form-start-date': 'Start date', + 'form-end-date': 'End date', + 'add-sibling-btn': 'Add as sibling', + // parent link modal + 'add-parent-link-title':'Add parent link', + 'form-child': 'Child', + 'form-link-via': 'Link via', + 'link-partnership': 'Partnership (two parents)', + 'link-single-parent': 'Single parent', + 'form-partnership': 'Partnership', + 'form-parent': 'Parent', + // members modal + 'members-title': 'Members', + 'invite-by-email': 'Invite by email', + 'role-editor': 'Editor', + 'role-viewer': 'Viewer', + 'invite-btn': 'Invite', + 'done': 'Done', + 'remove': 'Remove', + 'pending': 'Pending', + // canvas + 'no-people-yet': 'No people yet — use “Add person” to get started', + // roles in sidebar + 'role-owner': 'owner', + // alerts / confirms + 'alert-first-name-req': 'First name is required', + 'alert-add-two-people': 'Add at least two people first.', + 'alert-select-diff': 'Select two different people.', + 'alert-no-parents': 'That person has no parents in the tree yet. Set their parents first, then add the sibling.', + 'confirm-delete-tree': 'Delete this tree and all its data?', + 'confirm-leave-tree': 'Leave this tree?', + 'confirm-delete-person':'Delete this person and all their relationships?', + }, + de: { + 'account': 'Konto', + 'sign-out': 'Abmelden', + 'toggle-sidebar': 'Seitenleiste umschalten', + 'sidebar-trees': 'Stammbäume', + 'new-tree': 'Neuer Stammbaum', + 'empty-title': 'Kein Stammbaum ausgewählt', + 'empty-sub': 'Erstelle einen Stammbaum oder wähle einen aus der Seitenleiste', + 'create-tree-btn': 'Stammbaum erstellen', + 'zoom-in': 'Vergrößern', + 'zoom-out': 'Verkleinern', + 'fit-screen': 'Auf Bildschirm anpassen', + 'add-person': 'Person hinzufügen', + 'btn-edit': 'Bearbeiten', + 'btn-delete': 'Löschen', + 'btn-close': 'Schließen', + 'born': 'Geboren', + 'died': 'Gestorben', + 'buried': 'Begraben', + 'biography': 'Biografie', + 'partnerships': 'Partnerschaften', + 'children': 'Kinder', + 'parents': 'Eltern', + 'siblings': 'Geschwister', + 'add-relationship': 'Beziehung hinzufügen', + 'add-partnership-btn': 'Partnerschaft hinzufügen', + 'set-parents-btn': 'Eltern dieser Person festlegen', + 'add-child-btn': 'Kind dieser Person hinzufügen', + 'rel-married': 'Verheiratet', + 'rel-separated': 'Getrennt', + 'rel-partner': 'Partner', + 'ctx-rename': 'Umbenennen', + 'ctx-members': 'Mitglieder verwalten', + 'ctx-leave': 'Stammbaum verlassen', + 'ctx-delete': 'Stammbaum löschen', + 'new-tree-title': 'Neuer Stammbaum', + 'rename-tree-title': 'Stammbaum umbenennen', + 'form-name': 'Name', + 'tree-name-ph': 'z.B. Familie Piribauer', + 'create': 'Erstellen', + 'cancel': 'Abbrechen', + 'save': 'Speichern', + 'add-person-title': 'Person hinzufügen', + 'edit-person-title': 'Person bearbeiten', + 'form-first-name': 'Vorname', + 'form-last-name': 'Nachname', + 'form-birth-date': 'Geburtsdatum', + 'form-death-date': 'Sterbedatum', + 'form-birth-place': 'Geburtsort', + 'form-burial-place': 'Begräbnisort', + 'form-biography': 'Biografie', + 'bio-ph': 'Eine kurze Notiz…', + 'connect-how': 'Wie möchtest du diese Person zum Stammbaum hinzufügen?', + 'connect-partner': 'Partner von jemandem', + 'connect-child': 'Kind von jemandem', + 'connect-parent': 'Elternteil von jemandem', + 'connect-sibling': 'Geschwister von jemandem', + 'skip-for-now': 'Vorerst überspringen', + 'add-partnership-title':'Partnerschaft hinzufügen', + 'sibling-of-title': 'Als Geschwister von…', + 'form-person1': 'Person 1', + 'form-person2': 'Person 2', + 'form-type': 'Typ', + 'type-married': 'Verheiratet', + 'type-partnered': 'Zusammen', + 'type-separated': 'Getrennt', + 'form-start-date': 'Startdatum', + 'form-end-date': 'Enddatum', + 'add-sibling-btn': 'Als Geschwister hinzufügen', + 'add-parent-link-title':'Elternteil-Verbindung hinzufügen', + 'form-child': 'Kind', + 'form-link-via': 'Verbinden über', + 'link-partnership': 'Partnerschaft (zwei Elternteile)', + 'link-single-parent': 'Alleinerziehend', + 'form-partnership': 'Partnerschaft', + 'form-parent': 'Elternteil', + 'members-title': 'Mitglieder', + 'invite-by-email': 'Per E-Mail einladen', + 'role-editor': 'Bearbeiter', + 'role-viewer': 'Betrachter', + 'invite-btn': 'Einladen', + 'done': 'Fertig', + 'remove': 'Entfernen', + 'pending': 'Ausstehend', + 'no-people-yet': 'Noch keine Personen — verwende „Person hinzufügen“ um zu beginnen', + 'role-owner': 'Besitzer', + 'alert-first-name-req': 'Vorname ist erforderlich.', + 'alert-add-two-people': 'Füge zuerst mindestens zwei Personen hinzu.', + 'alert-select-diff': 'Wähle zwei verschiedene Personen.', + 'alert-no-parents': 'Diese Person hat noch keine Eltern im Stammbaum. Lege zuerst ihre Eltern fest.', + 'confirm-delete-tree': 'Diesen Stammbaum und alle Daten löschen?', + 'confirm-leave-tree': 'Diesen Stammbaum verlassen?', + 'confirm-delete-person':'Diese Person und alle ihre Beziehungen löschen?', + }, + }; + + window.getLang = function () { + const m = document.cookie.match(/(?:^|;\s*)lang=([^;]*)/); + return (m && (m[1] === 'en' || m[1] === 'de')) ? m[1] : 'en'; + }; + + window.setLang = function (lang) { + document.cookie = 'lang=' + lang + '; domain=.piribauer.ch; path=/; max-age=31536000; SameSite=Lax'; + applyTranslations(lang); + document.dispatchEvent(new CustomEvent('langchange', { detail: { lang: lang } })); + }; + + window.t = function (key) { + const lang = getLang(); + return (TRANSLATIONS[lang] && TRANSLATIONS[lang][key] !== undefined) + ? TRANSLATIONS[lang][key] + : (TRANSLATIONS.en[key] !== undefined ? TRANSLATIONS.en[key] : key); + }; + + function applyTranslations(lang) { + const tr = TRANSLATIONS[lang] || TRANSLATIONS.en; + document.querySelectorAll('[data-i18n]').forEach(function (el) { + const v = tr[el.dataset.i18n]; + if (v !== undefined) el.textContent = v; + }); + document.querySelectorAll('[data-i18n-title]').forEach(function (el) { + const v = tr[el.dataset.i18nTitle]; + if (v !== undefined) el.title = v; + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) { + const v = tr[el.dataset.i18nPlaceholder]; + if (v !== undefined) el.placeholder = v; + }); + document.querySelectorAll('.lang-opt').forEach(function (btn) { + btn.classList.toggle('lang-active', btn.dataset.lang === lang); + }); + } + + applyTranslations(getLang()); +})(); diff --git a/static/index.html b/static/index.html @@ -16,7 +16,7 @@ <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"> + <button class="btn-sidebar-toggle" id="sidebarToggle" data-i18n-title="toggle-sidebar" 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> @@ -24,8 +24,12 @@ </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://auth.piribauer.ch/if/user/" class="nav-link" target="_blank" rel="noopener" data-i18n="account">Account</a> + <a href="/outpost.goauthentik.io/sign_out" class="nav-link signout" data-i18n="sign-out">Sign out</a> + <div class="lang-switcher"> + <button class="lang-opt lang-active" data-lang="en" onclick="setLang('en')">EN</button> + <button class="lang-opt" data-lang="de" onclick="setLang('de')">DE</button> + </div> <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"/> @@ -40,14 +44,14 @@ <!-- Sidebar --> <aside class="sidebar" id="sidebar"> - <div class="sidebar-header">Trees</div> + <div class="sidebar-header" data-i18n="sidebar-trees">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 + <span data-i18n="new-tree">New tree</span> </button> </div> </aside> @@ -67,34 +71,34 @@ <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> + <p class="empty-title" data-i18n="empty-title">No tree selected</p> + <p class="empty-sub" data-i18n="empty-sub">Create a tree or select one from the sidebar</p> + <button class="btn btn-primary" id="emptyNewTree" data-i18n="create-tree-btn">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"> + <button class="toolbar-btn" id="btnZoomIn" data-i18n-title="zoom-in" 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"> + <button class="toolbar-btn" id="btnZoomOut" data-i18n-title="zoom-out" 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"> + <button class="toolbar-btn" id="btnFit" data-i18n-title="fit-screen" 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"> + <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"/> </svg> - Add person + <span data-i18n="add-person">Add person</span> </button> </div> @@ -108,17 +112,17 @@ <div class="detail-header"> <span class="detail-name" id="detailName"></span> <div class="detail-header-actions"> - <button class="btn-icon" id="btnEditPerson" title="Edit"> + <button class="btn-icon" id="btnEditPerson" data-i18n-title="btn-edit" 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"> + <button class="btn-icon danger" id="btnDeletePerson" data-i18n-title="btn-delete" 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"> + <button class="btn-icon" id="btnCloseDetail" data-i18n-title="btn-close" 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> @@ -136,25 +140,25 @@ <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 + <span data-i18n="ctx-rename">Rename</span> </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 + <span data-i18n="ctx-members">Manage members</span> </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 + <span data-i18n="ctx-leave">Leave tree</span> </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 + <span data-i18n="ctx-delete">Delete tree</span> </div> </div> @@ -163,14 +167,14 @@ <!-- New/rename tree --> <div class="modal-overlay" id="modalTree"> <div class="modal"> - <h2 id="modalTreeTitle">New tree</h2> + <h2 id="modalTreeTitle" data-i18n="new-tree-title">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"> + <label class="form-label" data-i18n="form-name">Name</label> + <input class="form-input" id="treeNameInput" type="text" data-i18n-placeholder="tree-name-ph" 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> + <button class="btn" id="btnTreeCancel" data-i18n="cancel">Cancel</button> + <button class="btn btn-primary" id="btnTreeSave" data-i18n="create">Create</button> </div> </div> </div> @@ -178,44 +182,44 @@ <!-- Add/edit person --> <div class="modal-overlay" id="modalPerson"> <div class="modal modal-wide"> - <h2 id="modalPersonTitle">Add person</h2> + <h2 id="modalPersonTitle" data-i18n="add-person-title">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"> + <label class="form-label" data-i18n="form-first-name">First name</label> + <input class="form-input" id="personFirstName" type="text" data-i18n-placeholder="form-first-name" 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"> + <label class="form-label" data-i18n="form-last-name">Last name</label> + <input class="form-input" id="personLastName" type="text" data-i18n-placeholder="form-last-name" placeholder="Last name"> </div> </div> <div class="form-row"> <div class="form-group"> - <label class="form-label">Birth date</label> + <label class="form-label" data-i18n="form-birth-date">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> + <label class="form-label" data-i18n="form-death-date">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> + <label class="form-label" data-i18n="form-birth-place">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> + <label class="form-label" data-i18n="form-burial-place">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> + <label class="form-label" data-i18n="form-biography">Biography</label> + <textarea class="form-input" id="personBio" rows="3" data-i18n-placeholder="bio-ph" 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> + <button class="btn" id="btnPersonCancel" data-i18n="cancel">Cancel</button> + <button class="btn btn-primary" id="btnPersonSave" data-i18n="save">Save</button> </div> </div> </div> @@ -224,29 +228,29 @@ <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"> + <p style="font-size:0.875rem;color:var(--snow1);opacity:0.6;margin-bottom:1.25rem" data-i18n="connect-how"> 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 + <span data-i18n="connect-partner">Partner of someone</span> </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 + <span data-i18n="connect-child">Child of someone</span> </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 + <span data-i18n="connect-parent">Parent of someone</span> </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 + <span data-i18n="connect-sibling">Sibling of someone</span> </button> </div> <div class="modal-actions"> - <button class="btn" id="btnConnectSkip">Skip for now</button> + <button class="btn" id="btnConnectSkip" data-i18n="skip-for-now">Skip for now</button> </div> </div> </div> @@ -254,36 +258,36 @@ <!-- Add/edit partnership --> <div class="modal-overlay" id="modalPartnership"> <div class="modal"> - <h2 id="modalPartnershipTitle">Add partnership</h2> + <h2 id="modalPartnershipTitle" data-i18n="add-partnership-title">Add partnership</h2> <div class="form-group"> - <label class="form-label">Person 1</label> + <label class="form-label" data-i18n="form-person1">Person 1</label> <select class="form-input" id="partnerPerson1"></select> </div> <div class="form-group"> - <label class="form-label">Person 2</label> + <label class="form-label" data-i18n="form-person2">Person 2</label> <select class="form-input" id="partnerPerson2"></select> </div> <div class="form-group"> - <label class="form-label">Type</label> + <label class="form-label" data-i18n="form-type">Type</label> <select class="form-input" id="partnerType"> - <option value="married">Married</option> - <option value="partnered" selected>Partnered</option> - <option value="separated">Separated</option> + <option value="married" data-i18n="type-married">Married</option> + <option value="partnered" selected data-i18n="type-partnered">Partnered</option> + <option value="separated" data-i18n="type-separated">Separated</option> </select> </div> <div class="form-row"> <div class="form-group"> - <label class="form-label">Start date</label> + <label class="form-label" data-i18n="form-start-date">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> + <label class="form-label" data-i18n="form-end-date">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> + <button class="btn" id="btnPartnerCancel" data-i18n="cancel">Cancel</button> + <button class="btn btn-primary" id="btnPartnerSave" data-i18n="save">Save</button> </div> </div> </div> @@ -291,29 +295,29 @@ <!-- Add parent link --> <div class="modal-overlay" id="modalParentLink"> <div class="modal"> - <h2>Add parent link</h2> + <h2 data-i18n="add-parent-link-title">Add parent link</h2> <div class="form-group"> - <label class="form-label">Child</label> + <label class="form-label" data-i18n="form-child">Child</label> <select class="form-input" id="linkChild"></select> </div> <div class="form-group"> - <label class="form-label">Link via</label> + <label class="form-label" data-i18n="form-link-via">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> + <option value="partnership" data-i18n="link-partnership">Partnership (two parents)</option> + <option value="single" data-i18n="link-single-parent">Single parent</option> </select> </div> <div class="form-group" id="linkPartnershipGroup"> - <label class="form-label">Partnership</label> + <label class="form-label" data-i18n="form-partnership">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> + <label class="form-label" data-i18n="form-parent">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> + <button class="btn" id="btnLinkCancel" data-i18n="cancel">Cancel</button> + <button class="btn btn-primary" id="btnLinkSave" data-i18n="save">Save</button> </div> </div> </div> @@ -321,27 +325,28 @@ <!-- Manage members --> <div class="modal-overlay" id="modalMembers"> <div class="modal modal-wide"> - <h2>Members</h2> + <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">Invite by email</p> + <p class="form-label" style="margin-bottom:0.6rem;margin-top:1.25rem" data-i18n="invite-by-email">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> + <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">Invite</button> + <button class="btn btn-primary" id="btnInviteSend" data-i18n="invite-btn">Invite</button> </div> <div id="pendingList"></div> </div> <div class="modal-actions"> - <button class="btn btn-primary" id="btnMembersClose">Done</button> + <button class="btn btn-primary" id="btnMembersClose" data-i18n="done">Done</button> </div> </div> </div> <script src="https://d3js.org/d3.v7.min.js"></script> + <script src="/static/i18n.js"></script> <script src="/static/app.js"></script> </body> </html> diff --git a/static/style.css b/static/style.css @@ -66,6 +66,36 @@ body { } .nav-link:hover { color: var(--lightblue); } .nav-link.signout:hover { color: var(--red); } +.lang-switcher { + display: inline-flex; + align-items: center; + background: var(--polar3); + border-radius: 6px; + padding: 3px; + gap: 1px; +} +.lang-opt { + background: none; + border: none; + border-radius: 4px; + color: var(--snow1); + cursor: pointer; + font-family: inherit; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.1em; + line-height: 1; + opacity: 0.4; + padding: 4px 8px; + transition: background 0.15s, color 0.15s, opacity 0.15s, box-shadow 0.15s; +} +.lang-opt.lang-active { + background: var(--polar1); + box-shadow: 0 1px 3px rgba(0,0,0,0.35); + color: var(--lightblue); + opacity: 1; +} +.lang-opt:not(.lang-active):hover { opacity: 0.75; } .topbar-home { display: flex; align-items: center; color: var(--snow1); opacity: 0.45;