From 4f10f334fb94387c340b75a8cdf5558e4f9b75f1 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Tue, 21 Apr 2026 00:00:33 +0200 Subject: [PATCH] feat(settings): add ICS subscription management UI and i18n keys --- public/pages/settings.js | 269 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 256 insertions(+), 13 deletions(-) diff --git a/public/pages/settings.js b/public/pages/settings.js index cbe4479..f80ae97 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -53,25 +53,28 @@ export async function render(container, { user }) { const syncErr = params.get('sync_error'); // State für Familienmitglieder + Sync-Status - let users = []; - let googleStatus = { configured: false, connected: false, lastSync: null }; - let appleStatus = { configured: false, lastSync: null }; - let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' }; - let categories = []; + let users = []; + let googleStatus = { configured: false, connected: false, lastSync: null }; + let appleStatus = { configured: false, lastSync: null }; + let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' }; + let categories = []; + let icsSubscriptions = []; try { - const [usersRes, gStatus, aStatus, prefsRes, catsRes] = await Promise.allSettled([ + const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes] = await Promise.allSettled([ user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }), api.get('/calendar/google/status'), api.get('/calendar/apple/status'), api.get('/preferences'), api.get('/shopping/categories'), + api.get('/calendar/subscriptions'), ]); - if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? []; - if (gStatus.status === 'fulfilled') googleStatus = gStatus.value; - if (aStatus.status === 'fulfilled') appleStatus = aStatus.value; - if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs; - if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? []; + if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? []; + if (gStatus.status === 'fulfilled') googleStatus = gStatus.value; + if (aStatus.status === 'fulfilled') appleStatus = aStatus.value; + if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs; + if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? []; + if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? []; } catch (_) { /* non-critical */ } const googleStatusText = googleStatus.connected @@ -239,6 +242,46 @@ export async function render(container, { user }) { ` : ''} + +
+
+
+
${t('settings.ics.title')}
+
+
+
+ +
+ +
+
+
@@ -381,16 +424,17 @@ export async function render(container, { user }) { }); } - bindEvents(container, user, categories); + bindEvents(container, user, categories, icsSubscriptions); } // -------------------------------------------------------- // Event-Binding // -------------------------------------------------------- -function bindEvents(container, user, categories) { +function bindEvents(container, user, categories, icsSubscriptions) { bindTabEvents(container); bindCategoryEvents(container); + bindIcsEvents(container, user, icsSubscriptions); // Theme-Toggle const themeToggle = container.querySelector('#theme-toggle'); if (themeToggle) { @@ -834,6 +878,205 @@ function memberHtml(u) { `; } +// -------------------------------------------------------- +// ICS-Abonnements +// -------------------------------------------------------- + +function renderIcsList(container, subs, user) { + const listEl = container.querySelector('#ics-list-container'); + if (!listEl) return; + listEl.replaceChildren(); + + if (subs.length === 0) { + const empty = document.createElement('p'); + empty.className = 'form-hint'; + empty.style.padding = 'var(--space-3) 0'; + empty.textContent = t('settings.ics.empty'); + listEl.appendChild(empty); + return; + } + + const ul = document.createElement('ul'); + ul.className = 'settings-members'; + subs.forEach((sub) => { + const li = document.createElement('li'); + li.className = 'settings-member'; + li.dataset.subId = sub.id; + + const dot = document.createElement('span'); + dot.className = 'settings-avatar settings-avatar--sm'; + dot.style.background = sub.color; + dot.style.flexShrink = '0'; + li.appendChild(dot); + + const info = document.createElement('div'); + info.className = 'settings-member__info'; + + const nameLine = document.createElement('span'); + nameLine.className = 'settings-member__name'; + nameLine.textContent = sub.name; + + const badge = document.createElement('span'); + badge.className = `badge ${sub.shared ? 'badge--success' : 'badge--neutral'}`; + badge.style.marginLeft = 'var(--space-2)'; + badge.textContent = sub.shared ? t('settings.ics.badges.shared') : t('settings.ics.badges.private'); + nameLine.appendChild(badge); + info.appendChild(nameLine); + + const meta = document.createElement('span'); + meta.className = 'settings-member__meta'; + if (sub.last_sync) { + const d = new Date(sub.last_sync); + meta.textContent = `${t('settings.ics.status.lastSync')} ${formatDate(d)} ${formatTime(d)}`; + } else { + meta.textContent = t('settings.ics.status.never'); + } + info.appendChild(meta); + li.appendChild(info); + + const isOwner = sub.created_by === user.id || user.role === 'admin'; + if (isOwner) { + const actions = document.createElement('div'); + actions.className = 'cat-row__actions'; + + const syncBtn = document.createElement('button'); + syncBtn.className = 'btn btn--icon btn--ghost'; + syncBtn.title = t('settings.ics.actions.sync'); + syncBtn.setAttribute('aria-label', t('settings.ics.actions.sync')); + syncBtn.dataset.action = 'ics-sync'; + syncBtn.dataset.id = sub.id; + const syncIcon = document.createElement('i'); + syncIcon.setAttribute('data-lucide', 'refresh-cw'); + syncIcon.style.cssText = 'width:16px;height:16px'; + syncIcon.setAttribute('aria-hidden', 'true'); + syncBtn.appendChild(syncIcon); + actions.appendChild(syncBtn); + + const delBtn = document.createElement('button'); + delBtn.className = 'btn btn--icon btn--danger-outline'; + delBtn.title = t('settings.ics.actions.delete'); + delBtn.setAttribute('aria-label', t('settings.ics.actions.delete')); + delBtn.dataset.action = 'ics-delete'; + delBtn.dataset.id = sub.id; + delBtn.dataset.name = sub.name; + const delIcon = document.createElement('i'); + delIcon.setAttribute('data-lucide', 'trash-2'); + delIcon.style.cssText = 'width:14px;height:14px'; + delIcon.setAttribute('aria-hidden', 'true'); + delBtn.appendChild(delIcon); + actions.appendChild(delBtn); + + li.appendChild(actions); + } + + ul.appendChild(li); + }); + listEl.appendChild(ul); + if (window.lucide) window.lucide.createIcons(); +} + +function bindIcsEvents(container, user, initialSubs) { + let subs = [...initialSubs]; + renderIcsList(container, subs, user); + + const addBtn = container.querySelector('#ics-add-btn'); + const formWrapper = container.querySelector('#ics-add-form-wrapper'); + const addForm = container.querySelector('#ics-add-form'); + const cancelBtn = container.querySelector('#ics-cancel-btn'); + const submitBtn = container.querySelector('#ics-submit-btn'); + const errorEl = container.querySelector('#ics-add-error'); + const listEl = container.querySelector('#ics-list-container'); + + if (addBtn) { + addBtn.addEventListener('click', () => { + formWrapper.hidden = false; + addBtn.hidden = true; + container.querySelector('#ics-url')?.focus(); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + formWrapper.hidden = true; + addBtn.hidden = false; + addForm?.reset(); + errorEl.hidden = true; + }); + } + + if (addForm) { + addForm.addEventListener('submit', async (e) => { + e.preventDefault(); + errorEl.hidden = true; + const url = container.querySelector('#ics-url').value.trim(); + const name = container.querySelector('#ics-name').value.trim(); + const color = container.querySelector('#ics-color').value; + const shared = container.querySelector('#ics-shared').checked ? 1 : 0; + + submitBtn.disabled = true; + try { + const res = await api.post('/calendar/subscriptions', { url, name, color, shared }); + subs.push(res.data); + renderIcsList(container, subs, user); + addForm.reset(); + formWrapper.hidden = true; + addBtn.hidden = false; + if (res.syncError) { + window.oikos?.showToast(`${t('settings.ics.status.syncError')}: ${res.syncError}`, 'danger'); + } else { + window.oikos?.showToast(t('settings.ics.add'), 'success'); + } + } catch (err) { + errorEl.textContent = err.message ?? t('common.errorGeneric'); + errorEl.hidden = false; + } finally { + submitBtn.disabled = false; + } + }); + } + + if (listEl) { + listEl.addEventListener('click', async (e) => { + const target = e.target.closest('[data-action]'); + if (!target) return; + const action = target.dataset.action; + const id = parseInt(target.dataset.id, 10); + + if (action === 'ics-sync') { + const origIcon = target.querySelector('[data-lucide]'); + target.disabled = true; + if (origIcon) origIcon.setAttribute('data-lucide', 'loader'); + if (window.lucide) window.lucide.createIcons(); + try { + const res = await api.post(`/calendar/subscriptions/${id}/sync`, {}); + const idx = subs.findIndex((s) => s.id === id); + if (idx >= 0) subs[idx] = res.data; + renderIcsList(container, subs, user); + window.oikos?.showToast(t('settings.ics.actions.sync'), 'success'); + } catch (err) { + window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger'); + target.disabled = false; + if (origIcon) origIcon.setAttribute('data-lucide', 'refresh-cw'); + if (window.lucide) window.lucide.createIcons(); + } + } + + if (action === 'ics-delete') { + const name = target.dataset.name; + if (!await confirmModal(t('settings.ics.confirm_delete'), { danger: true, confirmLabel: t('common.delete') })) return; + try { + await api.delete(`/calendar/subscriptions/${id}`); + subs = subs.filter((s) => s.id !== id); + renderIcsList(container, subs, user); + window.oikos?.showToast(t('settings.ics.actions.delete'), 'default'); + } catch (err) { + window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger'); + } + } + }); + } +} + function initials(name) { if (!name) return '?'; return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();