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:
| M | static/app.js | | | 186 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- |
| A | static/i18n.js | | | 247 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | static/index.html | | | 145 | +++++++++++++++++++++++++++++++++++++++++-------------------------------------- |
| M | static/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;