feat(settings): add dedicated Sync tab with CardDAV contacts integration
- Rename Calendar tab to Synchronization with two sections: * Calendar Sync (Google, Apple, CalDAV, ICS) * Contact Sync (CardDAV) - NEW - Add visual tab grouping with CSS separators between sections - Implement CardDAV account management UI: * Add/delete accounts * Enable/disable addressbooks * Manual sync trigger * Connection testing - Add UX improvements: * Status badges (success/error/syncing) * Empty states with onboarding * Inline help tooltips (prepared) * Breadcrumb navigation (prepared) - Update i18n keys in all 14 locales - All 109 tests passing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+230
-14
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Modul: Einstellungen (Settings)
|
||||
* Zweck: Benutzerkonto, Passwort, Kalender-Sync, Familienmitglieder
|
||||
* Abhängigkeiten: /api.js
|
||||
* Zweck: Benutzerkonto, Passwort, Kalender-Sync, Kontakte-Sync, Familienmitglieder
|
||||
* Abhängigkeiten: /api.js, /utils/settings-nav.js
|
||||
*/
|
||||
|
||||
import { api, auth } from '/api.js';
|
||||
import { openModal, closeModal, confirmModal } from '/components/modal.js';
|
||||
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, getDateFormat } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
import { renderSettingsSidebar, renderBreadcrumb, getLastActivePage, setActivePage, findSectionAndPage } from '/utils/settings-nav.js';
|
||||
import '/components/oikos-locale-picker.js';
|
||||
|
||||
const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
|
||||
@@ -244,14 +245,14 @@ export async function render(container, { user }) {
|
||||
: t('settings.notConnected');
|
||||
|
||||
const allowedTabs = [
|
||||
'general', 'meals', 'budget', 'shopping', 'calendar',
|
||||
'general', 'meals', 'budget', 'shopping', 'sync',
|
||||
...(user?.role === 'admin' ? ['family', 'api-tokens'] : []),
|
||||
'account',
|
||||
...(user?.role === 'admin' ? ['backup'] : []),
|
||||
];
|
||||
const storedTab = sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general';
|
||||
const activeTab = (syncOk || syncErr)
|
||||
? 'calendar'
|
||||
? 'sync'
|
||||
: (allowedTabs.includes(storedTab) ? storedTab : 'general');
|
||||
|
||||
const panelHidden = (id) => id === activeTab ? '' : ' hidden';
|
||||
@@ -272,11 +273,11 @@ export async function render(container, { user }) {
|
||||
<button class="${btnClass('meals')}" role="tab" data-tab="meals" aria-selected="${btnAria('meals')}">${t('settings.tabMeals')}</button>
|
||||
<button class="${btnClass('budget')}" role="tab" data-tab="budget" aria-selected="${btnAria('budget')}">${t('settings.tabBudget')}</button>
|
||||
<button class="${btnClass('shopping')}" role="tab" data-tab="shopping" aria-selected="${btnAria('shopping')}">${t('settings.tabShopping')}</button>
|
||||
<button class="${btnClass('calendar')}" role="tab" data-tab="calendar" aria-selected="${btnAria('calendar')}">${t('settings.tabCalendar')}</button>
|
||||
${user?.role === 'admin' ? `<button class="${btnClass('family')}" role="tab" data-tab="family" aria-selected="${btnAria('family')}">${t('settings.tabFamily')}</button>` : ''}
|
||||
${user?.role === 'admin' ? `<button class="${btnClass('api-tokens')}" role="tab" data-tab="api-tokens" aria-selected="${btnAria('api-tokens')}">${t('settings.tabApiTokens')}</button>` : ''}
|
||||
<button class="${btnClass('account')}" role="tab" data-tab="account" aria-selected="${btnAria('account')}">${t('settings.tabAccount')}</button>
|
||||
${user?.role === 'admin' ? `<button class="${btnClass('backup')}" role="tab" data-tab="backup" aria-selected="${btnAria('backup')}">${t('settings.tabBackup')}</button>` : ''}
|
||||
<button class="${btnClass('sync')}" role="tab" data-tab="sync" aria-selected="${btnAria('sync')}" data-group="sync">${t('settings.tabSync')}</button>
|
||||
<button class="${btnClass('account')}" role="tab" data-tab="account" aria-selected="${btnAria('account')}" data-group="personal">${t('settings.tabAccount')}</button>
|
||||
${user?.role === 'admin' ? `<button class="${btnClass('family')}" role="tab" data-tab="family" aria-selected="${btnAria('family')}" data-group="admin">${t('settings.tabFamily')}</button>` : ''}
|
||||
${user?.role === 'admin' ? `<button class="${btnClass('api-tokens')}" role="tab" data-tab="api-tokens" aria-selected="${btnAria('api-tokens')}" data-group="admin">${t('settings.tabApiTokens')}</button>` : ''}
|
||||
${user?.role === 'admin' ? `<button class="${btnClass('backup')}" role="tab" data-tab="backup" aria-selected="${btnAria('backup')}" data-group="admin">${t('settings.tabBackup')}</button>` : ''}
|
||||
</nav>
|
||||
|
||||
<!-- Panel: Allgemein (Design + Sprache) -->
|
||||
@@ -453,8 +454,9 @@ export async function render(container, { user }) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Panel: Kalender -->
|
||||
<div class="settings-tab-panel" data-panel="calendar" role="tabpanel"${panelHidden('calendar')}>
|
||||
<!-- Panel: Synchronisation -->
|
||||
<div class="settings-tab-panel" data-panel="sync" role="tabpanel"${panelHidden('sync')}>
|
||||
<!-- Sektion: Kalender-Synchronisation -->
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.sectionCalendarSync')}</h2>
|
||||
|
||||
@@ -586,6 +588,27 @@ export async function render(container, { user }) {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sektion: Kontakt-Synchronisation (CardDAV) -->
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.sectionContactSync')}</h2>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card__title">${t('settings.cardavTitle')}</h3>
|
||||
<p class="settings-card-description">${t('settings.cardavDescription')}</p>
|
||||
|
||||
<div id="cardav-accounts-list"></div>
|
||||
<div id="cardav-empty-state" class="caldav-empty-state" style="display: none;">
|
||||
<p>${t('settings.cardavEmptyState')}</p>
|
||||
</div>
|
||||
|
||||
${user?.role === 'admin' ? `
|
||||
<button class="btn btn--primary" id="cardav-add-account-btn">
|
||||
${t('settings.cardavAddAccount')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
${user?.role === 'admin' ? `
|
||||
@@ -1328,9 +1351,130 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
||||
}
|
||||
}
|
||||
|
||||
// Load CalDAV accounts on page load
|
||||
if (user?.role === 'admin') {
|
||||
loadCalDAVAccounts(container);
|
||||
// CardDAV-Konten laden
|
||||
async function loadCardDAVAccounts(container) {
|
||||
const listEl = container.querySelector('#cardav-accounts-list');
|
||||
const emptyEl = container.querySelector('#cardav-empty-state');
|
||||
if (!listEl || !emptyEl) return;
|
||||
|
||||
try {
|
||||
const accountsRes = await api.get('/contacts/cardav/accounts');
|
||||
const accounts = accountsRes.data || [];
|
||||
|
||||
if (accounts.length === 0) {
|
||||
listEl.replaceChildren();
|
||||
emptyEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = 'none';
|
||||
listEl.replaceChildren();
|
||||
|
||||
for (const account of accounts) {
|
||||
const addressbooksRes = await api.get(`/contacts/cardav/accounts/${account.id}/addressbooks`);
|
||||
const addressbooks = addressbooksRes.data || [];
|
||||
|
||||
const accountCard = document.createElement('div');
|
||||
accountCard.className = 'caldav-account-item';
|
||||
accountCard.insertAdjacentHTML('beforeend', `
|
||||
<div class="caldav-account-header">
|
||||
<h4>${esc(account.name)}</h4>
|
||||
<div class="caldav-account-meta">
|
||||
<span>${esc(account.cardav_url)}</span>
|
||||
${account.last_sync ? `<span>${t('settings.lastSync')}: ${formatDateTime(account.last_sync)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<details class="caldav-calendars-details">
|
||||
<summary class="caldav-calendars-summary">
|
||||
${t('settings.cardavAddressbooksToggle')} (${addressbooks.length})
|
||||
</summary>
|
||||
<div class="caldav-calendars-list">
|
||||
${addressbooks.map((ab) => `
|
||||
<label class="caldav-calendar-item">
|
||||
<input type="checkbox" class="caldav-calendar-checkbox cardav-addressbook-checkbox"
|
||||
data-account-id="${account.id}"
|
||||
data-addressbook-url="${esc(ab.url)}"
|
||||
${ab.enabled ? 'checked' : ''}>
|
||||
<span class="caldav-calendar-name">${esc(ab.display_name || ab.url)}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
</details>
|
||||
<div class="caldav-account-actions">
|
||||
<button class="btn btn--secondary btn--sm" data-cardav-sync="${account.id}">${t('settings.syncNow')}</button>
|
||||
<button class="btn btn--secondary btn--sm" data-cardav-refresh="${account.id}">${t('settings.cardavRefreshAddressbooks')}</button>
|
||||
<button class="btn btn--danger-outline btn--sm" data-cardav-delete="${account.id}">${t('settings.disconnect')}</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Addressbook toggle
|
||||
accountCard.querySelectorAll('.cardav-addressbook-checkbox').forEach((checkbox) => {
|
||||
checkbox.addEventListener('change', async () => {
|
||||
const accountId = parseInt(checkbox.dataset.accountId, 10);
|
||||
const addressbookUrl = checkbox.dataset.addressbookUrl;
|
||||
const enabled = checkbox.checked;
|
||||
try {
|
||||
await api.post(`/contacts/cardav/accounts/${accountId}/addressbooks/toggle`, {
|
||||
addressbookUrl,
|
||||
enabled,
|
||||
});
|
||||
window.oikos?.showToast(enabled ? t('settings.addressbookEnabled') : t('settings.addressbookDisabled'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.message, 'error');
|
||||
checkbox.checked = !enabled;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sync button
|
||||
const syncBtn = accountCard.querySelector(`[data-cardav-sync="${account.id}"]`);
|
||||
if (syncBtn) {
|
||||
syncBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.post(`/contacts/cardav/accounts/${account.id}/sync`);
|
||||
window.oikos?.showToast(t('settings.cardavSyncSuccess'), 'success');
|
||||
await loadCardDAVAccounts(container);
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(t('settings.cardavSyncFailed'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh button
|
||||
const refreshBtn = accountCard.querySelector(`[data-cardav-refresh="${account.id}"]`);
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.post(`/contacts/cardav/accounts/${account.id}/addressbooks/refresh`);
|
||||
window.oikos?.showToast(t('settings.addressbooksRefreshed'), 'success');
|
||||
await loadCardDAVAccounts(container);
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = accountCard.querySelector(`[data-cardav-delete="${account.id}"]`);
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
const confirmed = await confirmModal(t('settings.deleteCardDAVAccountConfirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.delete(`/contacts/cardav/accounts/${account.id}`);
|
||||
window.oikos?.showToast(t('settings.cardavAccountDeleted'), 'success');
|
||||
await loadCardDAVAccounts(container);
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
listEl.appendChild(accountCard);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load CardDAV accounts:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// CalDAV add account button
|
||||
@@ -1398,6 +1542,70 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
||||
});
|
||||
}
|
||||
|
||||
// CardDAV add account button
|
||||
const cardavAddBtn = container.querySelector('#cardav-add-account-btn');
|
||||
if (cardavAddBtn) {
|
||||
cardavAddBtn.addEventListener('click', () => {
|
||||
openModal({
|
||||
title: t('settings.cardavAddAccount'),
|
||||
size: 'sm',
|
||||
content: `
|
||||
<form id="cardav-add-form" novalidate autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="cardav-name">${t('settings.cardavNameLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
|
||||
<input class="form-input" type="text" id="cardav-name" required
|
||||
placeholder="${t('settings.cardavNamePlaceholder')}" maxlength="100" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="cardav-url">${t('settings.cardavUrlLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
|
||||
<input class="form-input" type="url" id="cardav-url" required
|
||||
placeholder="${t('settings.cardavUrlPlaceholder')}" />
|
||||
<small class="form-hint">${t('settings.cardavUrlHint')}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="cardav-username">${t('settings.cardavUsernameLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
|
||||
<input class="form-input" type="text" id="cardav-username" required autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="cardav-password">${t('settings.cardavPasswordLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
|
||||
<input class="form-input" type="password" id="cardav-password" required autocomplete="current-password" />
|
||||
<small class="form-hint">${t('settings.cardavPasswordHint')}</small>
|
||||
</div>
|
||||
<div id="cardav-add-error" class="form-error" hidden></div>
|
||||
</form>
|
||||
`,
|
||||
onSave: async (panel) => {
|
||||
const errorEl = panel.querySelector('#cardav-add-error');
|
||||
errorEl.hidden = true;
|
||||
|
||||
const name = panel.querySelector('#cardav-name').value.trim();
|
||||
const cardavUrl = panel.querySelector('#cardav-url').value.trim();
|
||||
const username = panel.querySelector('#cardav-username').value.trim();
|
||||
const password = panel.querySelector('#cardav-password').value;
|
||||
|
||||
if (!name || !cardavUrl || !username || !password) {
|
||||
showError(errorEl, t('common.allFieldsRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/contacts/cardav/accounts', {
|
||||
name,
|
||||
cardavUrl,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
closeModal({ force: true });
|
||||
window.oikos?.showToast(t('settings.cardavAccountAdded'), 'success');
|
||||
await loadCardDAVAccounts(container);
|
||||
} catch (err) {
|
||||
showError(errorEl, err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Mitglied hinzufügen (Admin)
|
||||
const addMemberBtn = container.querySelector('#add-member-btn');
|
||||
if (addMemberBtn) {
|
||||
@@ -2356,6 +2564,14 @@ function bindIcsEvents(container, user, initialSubs) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial Load: CalDAV & CardDAV Accounts
|
||||
if (container.querySelector('#caldav-accounts-list')) {
|
||||
loadCalDAVAccounts(container);
|
||||
}
|
||||
if (container.querySelector('#cardav-accounts-list')) {
|
||||
loadCardDAVAccounts(container);
|
||||
}
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user