feat: i18n notes, contacts, budget, settings pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-03-31 22:57:45 +02:00
parent e6c6b0a4fc
commit 26bbd61e1d
4 changed files with 206 additions and 185 deletions
+53 -39
View File
@@ -8,6 +8,7 @@
import { api } from '/api.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js';
// --------------------------------------------------------
// Konstanten
@@ -18,6 +19,18 @@ const CATEGORIES = [
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
];
const CATEGORY_LABELS = () => ({
'Lebensmittel': t('budget.catFood'),
'Miete': t('budget.catRent'),
'Versicherung': t('budget.catInsurance'),
'Mobilität': t('budget.catMobility'),
'Freizeit': t('budget.catLeisure'),
'Kleidung': t('budget.catClothing'),
'Gesundheit': t('budget.catHealth'),
'Bildung': t('budget.catEducation'),
'Sonstiges': t('budget.catMisc'),
});
const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
@@ -74,7 +87,7 @@ async function loadMonth(month) {
state.entries = [];
state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] };
state.prevSummary = null;
window.oikos?.showToast('Budget konnte nicht geladen werden.', 'danger');
window.oikos?.showToast(t('budget.loadError'), 'danger');
}
}
@@ -89,24 +102,24 @@ export async function render(container, { user }) {
container.innerHTML = `
<div class="budget-page">
<h1 class="sr-only">Budget</h1>
<h1 class="sr-only">${t('budget.title')}</h1>
<div class="budget-nav">
<button class="btn btn--icon" id="budget-prev" aria-label="Vorheriger Monat">
<button class="btn btn--icon" id="budget-prev" aria-label="${t('budget.prevMonth')}">
<i data-lucide="chevron-left" aria-hidden="true"></i>
</button>
<button class="budget-nav__today" id="budget-today">Aktuell</button>
<button class="budget-nav__today" id="budget-today">${t('budget.currentMonth')}</button>
<span class="budget-nav__label" id="budget-label"></span>
<button class="btn btn--primary btn--icon" id="budget-add" aria-label="Eintrag hinzufügen">
<button class="btn btn--primary btn--icon" id="budget-add" aria-label="${t('budget.addEntryLabel')}">
<i data-lucide="plus" aria-hidden="true"></i>
</button>
<button class="btn btn--icon" id="budget-next" aria-label="Nächster Monat">
<button class="btn btn--icon" id="budget-next" aria-label="${t('budget.nextMonth')}">
<i data-lucide="chevron-right" aria-hidden="true"></i>
</button>
</div>
<div id="budget-body" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div style="padding:2rem;text-align:center;color:var(--color-text-disabled);">Lade…</div>
<div style="padding:2rem;text-align:center;color:var(--color-text-disabled);">${t('budget.loadingIndicator')}</div>
</div>
<button class="page-fab" id="fab-new-budget" aria-label="Neuer Eintrag">
<button class="page-fab" id="fab-new-budget" aria-label="${t('budget.newEntryFabLabel')}">
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
</button>
</div>
@@ -171,17 +184,17 @@ function renderBody() {
<!-- Zusammenfassung -->
<div class="budget-summary">
<div class="budget-summary-card budget-summary-card--income">
<div class="budget-summary-card__label">Einnahmen</div>
<div class="budget-summary-card__label">${t('budget.income')}</div>
<div class="budget-summary-card__amount">${formatAmount(s.income)}</div>
${p ? renderTrend(s.income, p.income, prevLabel) : ''}
</div>
<div class="budget-summary-card budget-summary-card--expenses">
<div class="budget-summary-card__label">Ausgaben</div>
<div class="budget-summary-card__label">${t('budget.expenses')}</div>
<div class="budget-summary-card__amount">${formatAmount(Math.abs(s.expenses))}</div>
${p ? renderTrend(s.expenses, p.expenses, prevLabel) : ''}
</div>
<div class="budget-summary-card ${balanceClass}">
<div class="budget-summary-card__label">Saldo</div>
<div class="budget-summary-card__label">${t('budget.balance')}</div>
<div class="budget-summary-card__amount">${formatAmount(s.balance)}</div>
${p ? renderTrend(s.balance, p.balance, prevLabel) : ''}
</div>
@@ -190,7 +203,7 @@ function renderBody() {
<!-- Kategorie-Balken -->
${s.byCategory.length ? `
<div class="budget-chart-section">
<div class="budget-chart-section__title">Nach Kategorie</div>
<div class="budget-chart-section__title">${t('budget.byCategory')}</div>
<div class="budget-chart">
${renderCategoryBars(s.byCategory)}
</div>
@@ -199,7 +212,7 @@ function renderBody() {
<!-- Transaktionsliste -->
<div class="budget-list-section">
<div class="budget-list-header">
<span class="budget-list-header__title">Transaktionen</span>
<span class="budget-list-header__title">${t('budget.transactions')}</span>
${state.entries.length ? `
<a href="/api/v1/budget/export?month=${state.month}" class="btn btn--secondary"
style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3);">
@@ -256,8 +269,8 @@ function renderEntries() {
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
<div class="empty-state__title">Keine Einträge diesen Monat</div>
<div class="empty-state__description">Budget-Einträge über den + Button hinzufügen.</div>
<div class="empty-state__title">${t('budget.emptyTitle')}</div>
<div class="empty-state__description">${t('budget.emptyDescription')}</div>
</div>`;
}
@@ -277,7 +290,7 @@ function renderEntries() {
<div class="budget-entry__meta">${date} · ${escHtml(e.category)}${recurTag}</div>
</div>
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="Eintrag löschen">
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}">
<i data-lucide="trash-2" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
</div>
@@ -298,7 +311,7 @@ function renderEntries() {
function renderTrend(current, prev, prevLabel) {
const delta = current - prev;
if (Math.abs(delta) < 0.005) {
return `<div class="budget-summary-card__trend budget-summary-card__trend--neutral">— wie ${prevLabel}</div>`;
return `<div class="budget-summary-card__trend budget-summary-card__trend--neutral">${t('budget.trendNeutral', { month: prevLabel })}</div>`;
}
const positive = delta > 0;
const arrow = positive ? '▲' : '▼';
@@ -323,38 +336,39 @@ function openBudgetModal({ mode, entry = null }) {
const isExpense = isEdit ? entry.amount < 0 : true;
const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : '';
const catLabels = CATEGORY_LABELS();
const catOpts = CATEGORIES.map((c) =>
`<option value="${c}" ${isEdit && entry.category === c ? 'selected' : ''}>${c}</option>`
`<option value="${c}" ${isEdit && entry.category === c ? 'selected' : ''}>${catLabels[c] || c}</option>`
).join('');
const content = `
<div class="amount-type-toggle">
<button class="amount-type-btn amount-type-btn--expenses ${isExpense ? 'amount-type-btn--active' : ''}"
id="type-expense" type="button">Ausgabe</button>
id="type-expense" type="button">${t('budget.typeExpense')}</button>
<button class="amount-type-btn amount-type-btn--income ${!isExpense ? 'amount-type-btn--active' : ''}"
id="type-income" type="button">Einnahme</button>
id="type-income" type="button">${t('budget.typeIncome')}</button>
</div>
<div class="form-group">
<label class="form-label" for="bm-title">Titel *</label>
<label class="form-label" for="bm-title">${t('budget.titleLabel')}</label>
<input type="text" class="form-input" id="bm-title"
placeholder="z.B. REWE Einkauf" value="${escHtml(isEdit ? entry.title : '')}">
placeholder="${t('budget.titlePlaceholder')}" value="${escHtml(isEdit ? entry.title : '')}">
</div>
<div class="form-group">
<label class="form-label" for="bm-amount">Betrag (€) *</label>
<label class="form-label" for="bm-amount">${t('budget.amountLabel')}</label>
<input type="number" class="form-input" id="bm-amount"
placeholder="0,00" step="0.01" min="0"
placeholder="${t('budget.amountPlaceholder')}" step="0.01" min="0"
value="${absAmount}">
</div>
<div class="form-group">
<label class="form-label" for="bm-category">Kategorie</label>
<label class="form-label" for="bm-category">${t('budget.categoryLabel')}</label>
<select class="form-input" id="bm-category">${catOpts}</select>
</div>
<div class="form-group">
<label class="form-label" for="bm-date">Datum *</label>
<label class="form-label" for="bm-date">${t('budget.dateLabel')}</label>
<input type="date" class="form-input" id="bm-date"
value="${isEdit ? entry.date : today}">
</div>
@@ -362,22 +376,22 @@ function openBudgetModal({ mode, entry = null }) {
<div class="form-group">
<label class="allday-toggle">
<input type="checkbox" id="bm-recurring" ${isEdit && entry.is_recurring ? 'checked' : ''}>
<span class="allday-toggle__label">Wiederkehrend</span>
<span class="allday-toggle__label">${t('budget.recurringLabel')}</span>
</label>
</div>
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
${isEdit ? `<button class="btn btn--danger btn--icon" id="bm-delete" aria-label="Eintrag löschen">
${isEdit ? `<button class="btn btn--danger btn--icon" id="bm-delete" aria-label="${t('budget.deleteLabel')}">
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>` : '<div></div>'}
<div style="display:flex;gap:var(--space-3)">
<button class="btn btn--secondary" id="bm-cancel">Abbrechen</button>
<button class="btn btn--primary" id="bm-save">${isEdit ? 'Speichern' : 'Hinzufügen'}</button>
<button class="btn btn--secondary" id="bm-cancel">${t('common.cancel')}</button>
<button class="btn btn--primary" id="bm-save">${isEdit ? t('common.save') : t('common.add')}</button>
</div>
</div>`;
openSharedModal({
title: isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag',
title: isEdit ? t('budget.editEntry') : t('budget.newEntry'),
content,
size: 'sm',
onSave(panel) {
@@ -397,7 +411,7 @@ function openBudgetModal({ mode, entry = null }) {
panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
panel.querySelector('#bm-delete')?.addEventListener('click', async () => {
if (!confirm(`"${entry.title}" wirklich löschen?`)) return;
if (!confirm(t('budget.deletePersonConfirm', { title: entry.title }))) return;
closeModal();
await deleteEntry(entry.id);
});
@@ -410,9 +424,9 @@ function openBudgetModal({ mode, entry = null }) {
const date = panel.querySelector('#bm-date').value;
const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0;
if (!title) { window.oikos?.showToast('Titel ist erforderlich', 'error'); return; }
if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast('Gültigen Betrag eingeben', 'error'); return; }
if (!date) { window.oikos?.showToast('Datum ist erforderlich', 'error'); return; }
if (!title) { window.oikos?.showToast(t('common.titleRequired'), 'error'); return; }
if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; }
if (!date) { window.oikos?.showToast(t('budget.dateRequired'), 'error'); return; }
const amount = currentType === 'expense' ? -absVal : absVal;
@@ -434,11 +448,11 @@ function openBudgetModal({ mode, entry = null }) {
closeModal();
renderBody();
window.oikos?.showToast(mode === 'create' ? 'Eintrag hinzugefügt' : 'Eintrag gespeichert', 'success');
window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Speichern' : 'Hinzufügen';
saveBtn.textContent = isEdit ? t('common.save') : t('common.add');
}
});
},
@@ -450,7 +464,7 @@ function openBudgetModal({ mode, entry = null }) {
// --------------------------------------------------------
async function deleteEntry(id) {
if (!confirm('Eintrag wirklich löschen?')) return;
if (!confirm(t('budget.deleteConfirm'))) return;
try {
await api.delete(`/budget/${id}`);
state.entries = state.entries.filter((e) => e.id !== id);
@@ -458,7 +472,7 @@ async function deleteEntry(id) {
state.summary = sumRes.data;
renderBody();
vibrate([30, 50, 30]);
window.oikos?.showToast('Eintrag gelöscht', 'success');
window.oikos?.showToast(t('budget.deletedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
}
+39 -38
View File
@@ -7,6 +7,7 @@
import { api } from '/api.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js';
// --------------------------------------------------------
// Konstanten
@@ -44,32 +45,32 @@ export async function render(container, { user }) {
_container = container;
container.innerHTML = `
<div class="contacts-page">
<h1 class="sr-only">Kontakte</h1>
<h1 class="sr-only">${t('contacts.title')}</h1>
<div class="contacts-toolbar">
<div class="contacts-toolbar__search">
<i data-lucide="search" class="contacts-toolbar__search-icon" aria-hidden="true"></i>
<input type="search" class="contacts-toolbar__search-input"
id="contacts-search" placeholder="Name, Telefon oder E-Mail suchen…"
id="contacts-search" placeholder="${t('contacts.searchPlaceholder')}"
autocomplete="off">
</div>
<label class="btn btn--secondary" title="vCard importieren" aria-label="Kontakt aus vCard importieren">
<label class="btn btn--secondary" title="${t('contacts.importTooltip')}" aria-label="${t('contacts.importLabel')}">
<i data-lucide="upload" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
Import
${t('contacts.importButton')}
<input type="file" id="contacts-import-input" accept=".vcf,text/vcard" style="display:none">
</label>
<button class="btn btn--primary" id="contacts-add-btn">
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
Neu
${t('contacts.addButton')}
</button>
</div>
<div class="contacts-filters" id="contacts-filters">
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">Alle</button>
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">${t('contacts.filterAll')}</button>
${CATEGORIES.map((c) => `
<button class="contact-filter-chip" data-cat="${escHtml(c)}">${CATEGORY_ICONS[c] || ''} ${escHtml(c)}</button>
`).join('')}
</div>
<div id="contacts-list" class="contacts-list"></div>
<button class="page-fab" id="fab-new-contact" aria-label="Neuer Kontakt">
<button class="page-fab" id="fab-new-contact" aria-label="${t('contacts.newContactLabel')}">
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
</button>
</div>
@@ -115,13 +116,13 @@ export async function render(container, { user }) {
try {
const text = await file.text();
const contact = parseVCard(text);
if (!contact.name) { window.oikos?.showToast('vCard enthält keinen Namen.', 'warning'); return; }
if (!contact.name) { window.oikos?.showToast(t('contacts.vcardNoName'), 'warning'); return; }
const res = await api.post('/contacts', contact);
state.contacts.push(res.data);
renderList();
window.oikos?.showToast(`${res.data.name} importiert.`, 'success');
window.oikos?.showToast(t('contacts.importedToast', { name: res.data.name }), 'success');
} catch (err) {
window.oikos?.showToast('Import fehlgeschlagen: ' + err.message, 'danger');
window.oikos?.showToast(t('contacts.importError', { error: err.message }), 'danger');
}
});
}
@@ -164,8 +165,8 @@ function renderList() {
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<div class="empty-state__title">Noch keine Kontakte</div>
<div class="empty-state__description">Neue Kontakte über den + Button hinzufügen.</div>
<div class="empty-state__title">${t('contacts.emptyTitle')}</div>
<div class="empty-state__description">${t('contacts.emptyDescription')}</div>
</div>
`;
if (window.lucide) lucide.createIcons();
@@ -207,9 +208,9 @@ function renderList() {
}
function renderContactItem(c) {
const phone = c.phone ? `<a href="tel:${escHtml(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="Anrufen"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const email = c.email ? `<a href="mailto:${escHtml(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="E-Mail"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" aria-label="In Maps öffnen"><i data-lucide="map-pin" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const phone = c.phone ? `<a href="tel:${escHtml(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="${t('contacts.callLabel')}"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const email = c.email ? `<a href="mailto:${escHtml(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="${t('contacts.emailActionLabel')}"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" aria-label="${t('contacts.mapsLabel')}"><i data-lucide="map-pin" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const meta = [c.phone, c.email].filter(Boolean).join(' · ');
return `
@@ -222,10 +223,10 @@ function renderContactItem(c) {
<div class="contact-item__actions">
${phone}${email}${maps}
<a href="/api/v1/contacts/${c.id}/vcard" download="${escHtml(c.name)}.vcf"
class="contact-action-btn" aria-label="Als vCard exportieren" title="vCard exportieren">
class="contact-action-btn" aria-label="${t('contacts.exportLabel')}" title="${t('contacts.exportTooltip')}">
<i data-lucide="download" style="width:16px;height:16px;" aria-hidden="true"></i>
</a>
<button class="contact-action-btn" data-action="delete" data-id="${c.id}" aria-label="Kontakt löschen">
<button class="contact-action-btn" data-action="delete" data-id="${c.id}" aria-label="${t('contacts.deleteLabel')}">
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>
</div>
@@ -247,49 +248,49 @@ function openContactModal({ mode, contact = null }) {
const content = `
<div class="form-group">
<label class="form-label" for="cm-name">Name *</label>
<input type="text" class="form-input" id="cm-name" placeholder="Vollständiger Name" value="${v('name')}">
<label class="form-label" for="cm-name">${t('contacts.nameLabel')}</label>
<input type="text" class="form-input" id="cm-name" placeholder="${t('contacts.namePlaceholder')}" value="${v('name')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-category">Kategorie</label>
<label class="form-label" for="cm-category">${t('contacts.categoryLabel')}</label>
<select class="form-input" id="cm-category">${catOpts}</select>
</div>
<div class="form-group">
<label class="form-label" for="cm-phone">Telefon</label>
<input type="tel" class="form-input" id="cm-phone" placeholder="+49 …" value="${v('phone')}">
<label class="form-label" for="cm-phone">${t('contacts.phoneLabel')}</label>
<input type="tel" class="form-input" id="cm-phone" placeholder="${t('contacts.phonePlaceholder')}" value="${v('phone')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-email">E-Mail</label>
<input type="email" class="form-input" id="cm-email" placeholder="name@beispiel.de" value="${v('email')}">
<label class="form-label" for="cm-email">${t('contacts.emailLabel')}</label>
<input type="email" class="form-input" id="cm-email" placeholder="${t('contacts.emailPlaceholder')}" value="${v('email')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-address">Adresse</label>
<input type="text" class="form-input" id="cm-address" placeholder="Straße, PLZ Ort" value="${v('address')}">
<label class="form-label" for="cm-address">${t('contacts.addressLabel')}</label>
<input type="text" class="form-input" id="cm-address" placeholder="${t('contacts.addressPlaceholder')}" value="${v('address')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-notes">Notizen</label>
<textarea class="form-input" id="cm-notes" rows="2" placeholder="Optional…">${v('notes')}</textarea>
<label class="form-label" for="cm-notes">${t('contacts.notesLabel')}</label>
<textarea class="form-input" id="cm-notes" rows="2" placeholder="${t('contacts.notesPlaceholder')}">${v('notes')}</textarea>
</div>
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
${isEdit ? `<button class="btn btn--danger btn--icon" id="cm-delete" aria-label="Kontakt löschen">
${isEdit ? `<button class="btn btn--danger btn--icon" id="cm-delete" aria-label="${t('contacts.deleteLabel')}">
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>` : '<div></div>'}
<div style="display:flex;gap:var(--space-3);">
<button class="btn btn--secondary" id="cm-cancel">Abbrechen</button>
<button class="btn btn--primary" id="cm-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
<button class="btn btn--secondary" id="cm-cancel">${t('common.cancel')}</button>
<button class="btn btn--primary" id="cm-save">${isEdit ? t('common.save') : t('common.create')}</button>
</div>
</div>`;
openSharedModal({
title: isEdit ? 'Kontakt bearbeiten' : 'Neuer Kontakt',
title: isEdit ? t('contacts.editContact') : t('contacts.newContact'),
content,
size: 'md',
onSave(panel) {
panel.querySelector('#cm-cancel').addEventListener('click', closeModal);
panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
if (!confirm(`"${contact.name}" wirklich löschen?`)) return;
if (!confirm(t('contacts.deletePersonConfirm', { name: contact.name }))) return;
closeModal();
await deleteContact(contact.id);
});
@@ -303,7 +304,7 @@ function openContactModal({ mode, contact = null }) {
const address = panel.querySelector('#cm-address').value.trim() || null;
const notes = panel.querySelector('#cm-notes').value.trim() || null;
if (!name) { window.oikos?.showToast('Name ist erforderlich', 'error'); return; }
if (!name) { window.oikos?.showToast(t('common.nameRequired'), 'error'); return; }
saveBtn.disabled = true;
saveBtn.textContent = '…';
@@ -324,11 +325,11 @@ function openContactModal({ mode, contact = null }) {
}
closeModal();
renderList();
window.oikos?.showToast(mode === 'create' ? 'Kontakt gespeichert' : 'Kontakt aktualisiert', 'success');
window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen';
saveBtn.textContent = isEdit ? t('common.save') : t('common.create');
}
});
},
@@ -336,13 +337,13 @@ function openContactModal({ mode, contact = null }) {
}
async function deleteContact(id) {
if (!confirm('Kontakt wirklich löschen?')) return;
if (!confirm(t('contacts.deleteConfirm'))) return;
try {
await api.delete(`/contacts/${id}`);
state.contacts = state.contacts.filter((c) => c.id !== id);
renderList();
vibrate([30, 50, 30]);
window.oikos?.showToast('Kontakt gelöscht', 'success');
window.oikos?.showToast(t('contacts.deletedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
}
+36 -35
View File
@@ -7,6 +7,7 @@
import { api } from '/api.js';
import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js';
// --------------------------------------------------------
// Konstanten
@@ -48,20 +49,20 @@ export async function render(container, { user }) {
container.innerHTML = `
<div class="notes-page">
<div class="notes-toolbar">
<h1 class="notes-toolbar__title">Pinnwand</h1>
<h1 class="notes-toolbar__title">${t('notes.title')}</h1>
<div class="notes-toolbar__search">
<i data-lucide="search" class="notes-toolbar__search-icon" aria-hidden="true"></i>
<input type="search" id="notes-search" class="notes-toolbar__search-input"
placeholder="Notizen durchsuchen…" autocomplete="off"
placeholder="${t('notes.searchPlaceholder')}" autocomplete="off"
value="${escHtml(state.filterQuery)}">
</div>
<button class="btn btn--primary" id="notes-add-btn">
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
Neue Notiz
${t('notes.addNoteLabel')}
</button>
</div>
<div id="notes-grid" class="notes-grid"></div>
<button class="page-fab" id="fab-new-note" aria-label="Neue Notiz">
<button class="page-fab" id="fab-new-note" aria-label="${t('notes.addNoteLabel')}">
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
</button>
</div>
@@ -75,7 +76,7 @@ export async function render(container, { user }) {
} catch (err) {
console.error('[Notes] Laden fehlgeschlagen:', err);
state.notes = [];
window.oikos?.showToast('Notizen konnten nicht geladen werden.', 'danger');
window.oikos?.showToast(t('notes.loadError'), 'danger');
}
const grid = container.querySelector('#notes-grid');
grid.addEventListener('click', async (e) => {
@@ -131,8 +132,8 @@ function renderGrid() {
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<div class="empty-state__title">${isFiltered ? 'Keine Treffer' : 'Noch keine Notizen'}</div>
<div class="empty-state__description">${isFiltered ? `Keine Notiz enthält „${escHtml(state.filterQuery)}".` : 'Neue Notiz über den + Button erstellen.'}</div>
<div class="empty-state__title">${isFiltered ? t('notes.noResultsTitle') : t('notes.emptyTitle')}</div>
<div class="empty-state__description">${isFiltered ? t('notes.noResultsDescription', { query: state.filterQuery }) : t('notes.emptyDescription')}</div>
</div>
`;
if (window.lucide) lucide.createIcons();
@@ -156,7 +157,7 @@ function renderNoteCard(note) {
data-id="${note.id}"
style="background-color:${escHtml(note.color)};color:${textColor};">
<button class="note-card__pin" data-action="pin" data-id="${note.id}"
aria-label="${note.pinned ? 'Anpinnen aufheben' : 'Anpinnen'}">
aria-label="${note.pinned ? t('notes.unpinAction') : t('notes.pinAction')}">
<i data-lucide="${note.pinned ? 'pin-off' : 'pin'}" style="width:12px;height:12px;" aria-hidden="true"></i>
</button>
${note.title ? `<div class="note-card__title">${escHtml(note.title)}</div>` : ''}
@@ -167,7 +168,7 @@ function renderNoteCard(note) {
style="background-color:${escHtml(note.creator_color || '#8E8E93')}">${initials}</span>
<span>${escHtml(note.creator_name || '')}</span>
</div>
<button class="note-card__delete" data-action="delete" data-id="${note.id}" aria-label="Notiz löschen">
<button class="note-card__delete" data-action="delete" data-id="${note.id}" aria-label="${t('notes.deleteLabel')}">
<i data-lucide="trash-2" style="width:12px;height:12px;" aria-hidden="true"></i>
</button>
</div>
@@ -315,58 +316,58 @@ function openNoteModal({ mode, note = null }) {
const content = `
<div class="form-group">
<label class="form-label" for="note-title">Titel (optional)</label>
<label class="form-label" for="note-title">${t('notes.titleLabel')}</label>
<input type="text" class="form-input" id="note-title"
placeholder="Kein Titel" value="${escHtml(isEdit && note.title ? note.title : '')}">
placeholder="${t('notes.titlePlaceholder')}" value="${escHtml(isEdit && note.title ? note.title : '')}">
</div>
<div class="form-group">
<label class="form-label" for="note-content">Inhalt <span style="font-weight:400;color:var(--text-tertiary);font-size:.85em;">(Markdown-Formatierung möglich)</span></label>
<label class="form-label" for="note-content">${t('notes.contentLabel')} <span style="font-weight:400;color:var(--text-tertiary);font-size:.85em;">${t('notes.contentMarkdownHint')}</span></label>
<div class="note-format-toolbar">
<button type="button" class="note-format-btn" data-format="bold" title="Fett (Strg+B)">
<button type="button" class="note-format-btn" data-format="bold" title="${t('notes.formatBold')}">
<i data-lucide="bold" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="italic" title="Kursiv (Strg+I)">
<button type="button" class="note-format-btn" data-format="italic" title="${t('notes.formatItalic')}">
<i data-lucide="italic" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="underline" title="Unterstrichen (Strg+U)">
<button type="button" class="note-format-btn" data-format="underline" title="${t('notes.formatUnderline')}">
<i data-lucide="underline" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="strikethrough" title="Durchgestrichen">
<button type="button" class="note-format-btn" data-format="strikethrough" title="${t('notes.formatStrikethrough')}">
<i data-lucide="strikethrough" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<span class="note-format-btn--sep"></span>
<button type="button" class="note-format-btn" data-format="heading" title="Überschrift">
<button type="button" class="note-format-btn" data-format="heading" title="${t('notes.formatHeading')}">
<i data-lucide="heading" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="list" title="Aufzählung">
<button type="button" class="note-format-btn" data-format="list" title="${t('notes.formatList')}">
<i data-lucide="list" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="ordered-list" title="Nummerierte Liste">
<button type="button" class="note-format-btn" data-format="ordered-list" title="${t('notes.formatOrderedList')}">
<i data-lucide="list-ordered" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="checklist" title="Checkliste">
<button type="button" class="note-format-btn" data-format="checklist" title="${t('notes.formatChecklist')}">
<i data-lucide="list-checks" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<span class="note-format-btn--sep"></span>
<button type="button" class="note-format-btn" data-format="link" title="Link">
<button type="button" class="note-format-btn" data-format="link" title="${t('notes.formatLink')}">
<i data-lucide="link" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="code" title="Code">
<button type="button" class="note-format-btn" data-format="code" title="${t('notes.formatCode')}">
<i data-lucide="code" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="quote" title="Zitat">
<button type="button" class="note-format-btn" data-format="quote" title="${t('notes.formatQuote')}">
<i data-lucide="quote" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
<button type="button" class="note-format-btn" data-format="divider" title="Trennlinie">
<button type="button" class="note-format-btn" data-format="divider" title="${t('notes.formatDivider')}">
<i data-lucide="minus" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
</div>
<textarea class="form-input" id="note-content" rows="6"
placeholder="Notiz eingeben…"
placeholder="${t('notes.contentPlaceholder')}"
style="resize:vertical;">${escHtml(isEdit ? note.content : '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Farbe</label>
<label class="form-label">${t('notes.colorLabel')}</label>
<div class="note-color-picker">
${NOTE_COLORS.map((c) => `
<div class="note-color-swatch ${c === selColor ? 'note-color-swatch--active' : ''}"
@@ -379,17 +380,17 @@ function openNoteModal({ mode, note = null }) {
<div class="form-group">
<label class="allday-toggle">
<input type="checkbox" id="note-pinned" ${isEdit && note.pinned ? 'checked' : ''}>
<span class="allday-toggle__label">Anpinnen (erscheint auf Dashboard)</span>
<span class="allday-toggle__label">${t('notes.pinnedLabel')}</span>
</label>
</div>
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
<button class="btn btn--secondary" id="note-modal-cancel">Abbrechen</button>
<button class="btn btn--primary" id="note-modal-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
<button class="btn btn--secondary" id="note-modal-cancel">${t('common.cancel')}</button>
<button class="btn btn--primary" id="note-modal-save">${isEdit ? t('common.save') : t('common.create')}</button>
</div>`;
openSharedModal({
title: isEdit ? 'Notiz bearbeiten' : 'Neue Notiz',
title: isEdit ? t('notes.editNote') : t('notes.newNote'),
content,
size: 'md',
onSave(panel) {
@@ -427,7 +428,7 @@ function openNoteModal({ mode, note = null }) {
const color = panel.querySelector('.note-color-swatch--active')?.dataset.color || NOTE_COLORS[0];
const pinned = panel.querySelector('#note-pinned').checked ? 1 : 0;
if (!cnt) { window.oikos?.showToast('Inhalt ist erforderlich', 'error'); return; }
if (!cnt) { window.oikos?.showToast(t('common.contentRequired'), 'error'); return; }
saveBtn.disabled = true;
saveBtn.textContent = '…';
@@ -444,12 +445,12 @@ function openNoteModal({ mode, note = null }) {
}
closeModal();
renderGrid();
window.oikos?.showToast(mode === 'create' ? 'Notiz erstellt' : 'Notiz gespeichert', 'success');
window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
btnError(saveBtn);
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen';
saveBtn.textContent = isEdit ? t('common.save') : t('common.create');
}
});
},
@@ -473,13 +474,13 @@ async function togglePin(id) {
}
async function deleteNote(id) {
if (!confirm('Notiz wirklich löschen?')) return;
if (!confirm(t('notes.deleteConfirm'))) return;
try {
await api.delete(`/notes/${id}`);
state.notes = state.notes.filter((n) => n.id !== id);
renderGrid();
vibrate([30, 50, 30]);
window.oikos?.showToast('Notiz gelöscht', 'success');
window.oikos?.showToast(t('notes.deletedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
}
+78 -73
View File
@@ -5,6 +5,7 @@
*/
import { api, auth } from '/api.js';
import { t } from '/i18n.js';
/**
* @param {HTMLElement} container
@@ -32,32 +33,42 @@ export async function render(container, { user }) {
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
} catch (_) { /* non-critical */ }
const googleStatusText = googleStatus.connected
? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDate(googleStatus.lastSync) }) : t('settings.connected'))
: googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured');
const appleStatusText = appleStatus.connected
? (appleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDate(appleStatus.lastSync) }) : t('settings.connected'))
: appleStatus.configured
? (appleStatus.lastSync ? t('settings.configuredLastSync', { date: formatDate(appleStatus.lastSync) }) : t('settings.configured'))
: t('settings.notConnected');
container.innerHTML = `
<div class="page settings-page">
<div class="page__header">
<h1 class="page__title">Einstellungen</h1>
<h1 class="page__title">${t('settings.title')}</h1>
</div>
${syncOk ? `<div class="settings-banner settings-banner--success">Kalender-Sync mit ${syncOk === 'google' ? 'Google' : 'Apple'} erfolgreich verbunden.</div>` : ''}
${syncErr ? `<div class="settings-banner settings-banner--error">Verbindung mit ${syncErr === 'google' ? 'Google' : 'Apple'} fehlgeschlagen. Bitte erneut versuchen.</div>` : ''}
${syncOk ? `<div class="settings-banner settings-banner--success">${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}</div>` : ''}
${syncErr ? `<div class="settings-banner settings-banner--error">${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}</div>` : ''}
<!-- Design -->
<section class="settings-section">
<h2 class="settings-section__title">Design</h2>
<h2 class="settings-section__title">${t('settings.sectionDesign')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">Darstellung</h3>
<h3 class="settings-card__title">${t('settings.cardAppearance')}</h3>
<div class="theme-toggle" id="theme-toggle">
<button class="theme-toggle__btn ${currentTheme() === 'system' ? 'theme-toggle__btn--active' : ''}" data-theme-value="system" aria-label="System-Einstellung verwenden">
<button class="theme-toggle__btn ${currentTheme() === 'system' ? 'theme-toggle__btn--active' : ''}" data-theme-value="system" aria-label="${t('settings.themeSysLabel')}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
System
${t('settings.themeSystem')}
</button>
<button class="theme-toggle__btn ${currentTheme() === 'light' ? 'theme-toggle__btn--active' : ''}" data-theme-value="light" aria-label="Helles Design">
<button class="theme-toggle__btn ${currentTheme() === 'light' ? 'theme-toggle__btn--active' : ''}" data-theme-value="light" aria-label="${t('settings.themeLightLabel')}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
Hell
${t('settings.themeLight')}
</button>
<button class="theme-toggle__btn ${currentTheme() === 'dark' ? 'theme-toggle__btn--active' : ''}" data-theme-value="dark" aria-label="Dunkles Design">
<button class="theme-toggle__btn ${currentTheme() === 'dark' ? 'theme-toggle__btn--active' : ''}" data-theme-value="dark" aria-label="${t('settings.themeDarkLabel')}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
Dunkel
${t('settings.themeDark')}
</button>
</div>
</div>
@@ -65,7 +76,7 @@ export async function render(container, { user }) {
<!-- Mein Konto -->
<section class="settings-section">
<h2 class="settings-section__title">Mein Konto</h2>
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
<div class="settings-card">
<div class="settings-user-info">
@@ -80,29 +91,29 @@ export async function render(container, { user }) {
</div>
<div class="settings-card">
<h3 class="settings-card__title">Passwort ändern</h3>
<h3 class="settings-card__title">${t('settings.changePassword')}</h3>
<form id="password-form" class="settings-form">
<div class="form-group">
<label class="form-label" for="current-password">Aktuelles Passwort</label>
<label class="form-label" for="current-password">${t('settings.currentPasswordLabel')}</label>
<input class="form-input" type="password" id="current-password" autocomplete="current-password" required />
</div>
<div class="form-group">
<label class="form-label" for="new-password">Neues Passwort</label>
<label class="form-label" for="new-password">${t('settings.newPasswordLabel')}</label>
<input class="form-input" type="password" id="new-password" autocomplete="new-password" minlength="8" required />
</div>
<div class="form-group">
<label class="form-label" for="confirm-password">Neues Passwort bestätigen</label>
<label class="form-label" for="confirm-password">${t('settings.confirmPasswordLabel')}</label>
<input class="form-input" type="password" id="confirm-password" autocomplete="new-password" minlength="8" required />
</div>
<div id="password-error" class="form-error" hidden></div>
<button type="submit" class="btn btn--primary">Passwort speichern</button>
<button type="submit" class="btn btn--primary">${t('settings.savePassword')}</button>
</form>
</div>
</section>
<!-- Kalender-Synchronisation -->
<section class="settings-section">
<h2 class="settings-section__title">Kalender-Synchronisation</h2>
<h2 class="settings-section__title">${t('settings.sectionCalendarSync')}</h2>
<!-- Google Calendar -->
<div class="settings-card">
@@ -116,21 +127,19 @@ export async function render(container, { user }) {
</svg>
</div>
<div class="settings-sync-info">
<div class="settings-sync-info__name">Google Calendar</div>
<div class="settings-sync-info__name">${t('settings.googleCalendar')}</div>
<div class="settings-sync-info__status ${googleStatus.connected ? 'settings-sync-info__status--connected' : ''}">
${googleStatus.connected
? `Verbunden${googleStatus.lastSync ? ` · Zuletzt: ${formatDate(googleStatus.lastSync)}` : ''}`
: googleStatus.configured ? 'Nicht verbunden' : 'Nicht konfiguriert (fehlende .env-Variablen)'}
${googleStatusText}
</div>
</div>
</div>
${googleStatus.configured ? `
<div class="settings-sync-actions">
${googleStatus.connected ? `
<button class="btn btn--secondary" id="google-sync-btn">Jetzt synchronisieren</button>
${user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="google-disconnect-btn">Verbindung trennen</button>` : ''}
<button class="btn btn--secondary" id="google-sync-btn">${t('settings.syncNow')}</button>
${user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="google-disconnect-btn">${t('settings.disconnect')}</button>` : ''}
` : `
${user?.role === 'admin' ? `<a href="/api/v1/calendar/google/auth" class="btn btn--primary">Mit Google verbinden</a>` : '<span class="form-hint">Nur Admin kann Google Calendar verbinden.</span>'}
${user?.role === 'admin' ? `<a href="/api/v1/calendar/google/auth" class="btn btn--primary">${t('settings.connectGoogle')}</a>` : `<span class="form-hint">${t('settings.googleOnlyAdmin')}</span>`}
`}
</div>
` : ''}
@@ -145,84 +154,80 @@ export async function render(container, { user }) {
</svg>
</div>
<div class="settings-sync-info">
<div class="settings-sync-info__name">Apple Calendar (iCloud)</div>
<div class="settings-sync-info__name">${t('settings.appleCalendar')}</div>
<div class="settings-sync-info__status ${appleStatus.configured ? 'settings-sync-info__status--connected' : ''}">
${appleStatus.connected
? `Verbunden${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
: appleStatus.configured
? `Konfiguriert (via .env)${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
: 'Nicht verbunden'}
${appleStatusText}
</div>
</div>
</div>
${appleStatus.configured ? `
<div class="settings-sync-actions">
<button class="btn btn--secondary" id="apple-sync-btn">Jetzt synchronisieren</button>
${appleStatus.connected && user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="apple-disconnect-btn">Verbindung trennen</button>` : ''}
<button class="btn btn--secondary" id="apple-sync-btn">${t('settings.syncNow')}</button>
${appleStatus.connected && user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="apple-disconnect-btn">${t('settings.disconnect')}</button>` : ''}
</div>
` : user?.role === 'admin' ? `
<form id="apple-connect-form" class="settings-form settings-form--compact">
<div class="form-group">
<label class="form-label" for="apple-caldav-url">CalDAV-Server-URL</label>
<input class="form-input" type="url" id="apple-caldav-url" placeholder="https://caldav.icloud.com" required />
<label class="form-label" for="apple-caldav-url">${t('settings.caldavUrlLabel')}</label>
<input class="form-input" type="url" id="apple-caldav-url" placeholder="${t('settings.caldavUrlPlaceholder')}" required />
</div>
<div class="form-group">
<label class="form-label" for="apple-username">Apple-ID (E-Mail)</label>
<label class="form-label" for="apple-username">${t('settings.appleIdLabel')}</label>
<input class="form-input" type="email" id="apple-username" autocomplete="username" required />
</div>
<div class="form-group">
<label class="form-label" for="apple-password">App-spezifisches Passwort</label>
<label class="form-label" for="apple-password">${t('settings.applePasswordLabel')}</label>
<input class="form-input" type="password" id="apple-password" autocomplete="current-password" required />
<span class="form-hint">Passwort unter <strong>appleid.apple.com → Sicherheit</strong> erstellen.</span>
<span class="form-hint">${t('settings.applePasswordHint')}</span>
</div>
<div id="apple-connect-error" class="form-error" hidden></div>
<button type="submit" class="btn btn--primary" id="apple-connect-btn">Verbinden &amp; testen</button>
<button type="submit" class="btn btn--primary" id="apple-connect-btn">${t('settings.appleConnectBtn')}</button>
</form>
` : '<span class="form-hint">Nur Admin kann Apple Calendar verbinden.</span>'}
` : `<span class="form-hint">${t('settings.appleOnlyAdmin')}</span>`}
</div>
</section>
<!-- Familienmitglieder (nur Admin) -->
${user?.role === 'admin' ? `
<section class="settings-section">
<h2 class="settings-section__title">Familienmitglieder</h2>
<h2 class="settings-section__title">${t('settings.sectionFamily')}</h2>
<div class="settings-card" id="members-card">
<ul class="settings-members" id="members-list">
${users.map(memberHtml).join('')}
</ul>
<button class="btn btn--primary settings-add-btn" id="add-member-btn">+ Mitglied hinzufügen</button>
<button class="btn btn--primary settings-add-btn" id="add-member-btn">${t('settings.addMember')}</button>
</div>
<div class="settings-card settings-card--hidden" id="add-member-form-card">
<h3 class="settings-card__title">Neues Familienmitglied</h3>
<h3 class="settings-card__title">${t('settings.newMemberTitle')}</h3>
<form id="add-member-form" class="settings-form">
<div class="form-group">
<label class="form-label" for="new-username">Benutzername</label>
<label class="form-label" for="new-username">${t('settings.usernameLabel')}</label>
<input class="form-input" type="text" id="new-username" required autocomplete="off" />
</div>
<div class="form-group">
<label class="form-label" for="new-display-name">Anzeigename</label>
<label class="form-label" for="new-display-name">${t('settings.displayNameLabel')}</label>
<input class="form-input" type="text" id="new-display-name" required />
</div>
<div class="form-group">
<label class="form-label" for="new-member-password">Passwort</label>
<label class="form-label" for="new-member-password">${t('settings.memberPasswordLabel')}</label>
<input class="form-input" type="password" id="new-member-password" minlength="8" required autocomplete="new-password" />
</div>
<div class="form-group">
<label class="form-label" for="new-avatar-color">Farbe</label>
<label class="form-label" for="new-avatar-color">${t('settings.colorLabel')}</label>
<input class="form-input form-input--color" type="color" id="new-avatar-color" value="#007AFF" />
</div>
<div class="form-group">
<label class="form-label" for="new-role">Rolle</label>
<label class="form-label" for="new-role">${t('settings.roleLabel')}</label>
<select class="form-input" id="new-role">
<option value="member">Mitglied</option>
<option value="admin">Admin</option>
<option value="member">${t('settings.roleMember')}</option>
<option value="admin">${t('settings.roleAdmin')}</option>
</select>
</div>
<div id="member-error" class="form-error" hidden></div>
<div class="settings-form-actions">
<button type="submit" class="btn btn--primary">Erstellen</button>
<button type="button" class="btn btn--secondary" id="cancel-add-member">Abbrechen</button>
<button type="submit" class="btn btn--primary">${t('settings.createMember')}</button>
<button type="button" class="btn btn--secondary" id="cancel-add-member">${t('settings.cancelAddMember')}</button>
</div>
</form>
</div>
@@ -231,7 +236,7 @@ export async function render(container, { user }) {
<!-- Abmelden -->
<section class="settings-section">
<button class="btn btn--danger-outline settings-logout-btn" id="logout-btn">Abmelden</button>
<button class="btn btn--danger-outline settings-logout-btn" id="logout-btn">${t('settings.logout')}</button>
</section>
</div>
`;
@@ -270,7 +275,7 @@ function bindEvents(container, user) {
errorEl.hidden = true;
if (newPw !== confirmPw) {
showError(errorEl, 'Passwörter stimmen nicht überein.');
showError(errorEl, t('settings.passwordMismatch'));
return;
}
@@ -279,7 +284,7 @@ function bindEvents(container, user) {
try {
await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw });
passwordForm.reset();
window.oikos?.showToast('Passwort erfolgreich geändert.', 'success');
window.oikos?.showToast(t('settings.passwordSavedToast'), 'success');
} catch (err) {
showError(errorEl, err.message);
} finally {
@@ -293,15 +298,15 @@ function bindEvents(container, user) {
if (googleSyncBtn) {
googleSyncBtn.addEventListener('click', async () => {
googleSyncBtn.disabled = true;
googleSyncBtn.textContent = 'Synchronisiere…';
googleSyncBtn.textContent = t('settings.synchronizing');
try {
await api.post('/calendar/google/sync', {});
window.oikos?.showToast('Google Calendar synchronisiert.', 'success');
window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Google Calendar' }), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
} finally {
googleSyncBtn.disabled = false;
googleSyncBtn.textContent = 'Jetzt synchronisieren';
googleSyncBtn.textContent = t('settings.syncNow');
}
});
}
@@ -310,10 +315,10 @@ function bindEvents(container, user) {
const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
if (googleDisconnectBtn) {
googleDisconnectBtn.addEventListener('click', async () => {
if (!confirm('Google Calendar-Verbindung trennen?')) return;
if (!confirm(t('settings.googleDisconnectConfirm'))) return;
try {
await api.delete('/calendar/google/disconnect');
window.oikos?.showToast('Google Calendar getrennt.', 'default');
window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default');
window.oikos?.navigate('/settings');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
@@ -326,15 +331,15 @@ function bindEvents(container, user) {
if (appleSyncBtn) {
appleSyncBtn.addEventListener('click', async () => {
appleSyncBtn.disabled = true;
appleSyncBtn.textContent = 'Synchronisiere…';
appleSyncBtn.textContent = t('settings.synchronizing');
try {
await api.post('/calendar/apple/sync', {});
window.oikos?.showToast('Apple Calendar synchronisiert.', 'success');
window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Apple Calendar' }), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
} finally {
appleSyncBtn.disabled = false;
appleSyncBtn.textContent = 'Jetzt synchronisieren';
appleSyncBtn.textContent = t('settings.syncNow');
}
});
}
@@ -343,10 +348,10 @@ function bindEvents(container, user) {
const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn');
if (appleDisconnectBtn) {
appleDisconnectBtn.addEventListener('click', async () => {
if (!confirm('Apple Calendar-Verbindung trennen?')) return;
if (!confirm(t('settings.appleDisconnectConfirm'))) return;
try {
await api.delete('/calendar/apple/disconnect');
window.oikos?.showToast('Apple Calendar getrennt.', 'default');
window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default');
window.oikos?.navigate('/settings');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
@@ -368,16 +373,16 @@ function bindEvents(container, user) {
const btn = container.querySelector('#apple-connect-btn');
btn.disabled = true;
btn.textContent = 'Verbinde…';
btn.textContent = t('settings.appleConnecting');
try {
await api.post('/calendar/apple/connect', { url, username, password });
window.oikos?.showToast('Apple Calendar verbunden.', 'success');
window.oikos?.showToast(t('settings.appleConnectedToast'), 'success');
window.oikos?.navigate('/settings');
} catch (err) {
showError(errorEl, err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Verbinden & testen';
btn.textContent = t('settings.appleConnectBtn');
}
});
}
@@ -425,7 +430,7 @@ function bindEvents(container, user) {
addMemberForm.reset();
container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
container.querySelector('#add-member-btn').hidden = false;
window.oikos?.showToast(`${res.user.display_name} hinzugefügt.`, 'success');
window.oikos?.showToast(t('settings.memberAddedToast', { name: res.user.display_name }), 'success');
bindDeleteButtons(container, user);
} catch (err) {
showError(errorEl, err.message);
@@ -458,11 +463,11 @@ function bindDeleteButtons(container, user) {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.deleteUser, 10);
const name = btn.dataset.name;
if (!confirm(`${name} wirklich löschen?`)) return;
if (!confirm(t('settings.deleteMemberConfirm', { name }))) return;
try {
await auth.deleteUser(id);
btn.closest('.settings-member').remove();
window.oikos?.showToast(`${name} gelöscht.`, 'default');
window.oikos?.showToast(t('settings.memberDeletedToast', { name }), 'default');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
@@ -480,9 +485,9 @@ function memberHtml(u) {
<div class="settings-avatar settings-avatar--sm" style="background:${u.avatar_color}">${initials(u.display_name)}</div>
<div class="settings-member__info">
<span class="settings-member__name">${u.display_name}</span>
<span class="settings-member__meta">@${u.username} · ${u.role === 'admin' ? 'Admin' : 'Mitglied'}</span>
<span class="settings-member__meta">@${u.username} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}</span>
</div>
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${u.display_name}" aria-label="${u.display_name} löschen" title="Löschen">
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${u.display_name}" aria-label="${u.display_name} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
<i data-lucide="trash-2" aria-hidden="true"></i>
</button>
</li>