From 137694ed98f931d52715bc87e55e34eb609938ce Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 14 Jul 2026 21:07:26 +0200 Subject: [PATCH] Web: unified settings sync table with per-setting sync toggles Replace the ad-hoc appearance panel with a uniform table (sync icon | name | value): dropdowns instead of button groups, every colour with a reset to a canonical default, a per-row sync toggle, and a global "sync all" master switch. - new settings-sync.js: canonical default colours, declarative table definition, browser-local value copy, effective-value resolution - calendar.js: generalised sync engine (pull applies only synced keys from the server, save pushes only synced keys + the flag map), unified the two save buttons into one, dropped the dead text/line-contrast code, moved default duration into the appearance table, effective UI language on load - account settings (private visibility, group calendar, hide profile) stay in a dedicated profile block, not the sync table - app.css: settings table / sync toggle / sync icon styles - i18n: sync-all / sync-this / share-icon strings; sw cache bump Co-Authored-By: Claude Opus 4.8 --- frontend/css/app.css | 63 +++++ frontend/index.html | 127 +--------- frontend/js/calendar.js | 441 ++++++++++++++++++----------------- frontend/js/i18n.js | 8 + frontend/js/settings-sync.js | 104 +++++++++ frontend/sw.js | 2 +- 6 files changed, 413 insertions(+), 332 deletions(-) create mode 100644 frontend/js/settings-sync.js diff --git a/frontend/css/app.css b/frontend/css/app.css index 69b1152..7750937 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1352,6 +1352,69 @@ a { color: var(--primary); text-decoration: none; } } .contrast-btn.active .hour-preview { color: #fff; } +/* ── Settings sync table (Sync-Icon | Name | Wert) ─────────── */ +.sync-global-row { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 12px 14px; margin-bottom: 12px; + background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; +} +.sync-global-text { display: flex; flex-direction: column; gap: 2px; } +.sync-global-title { font-size: 14px; font-weight: 600; color: var(--text-1); } +.sync-global-row .panel-desc { margin: 0; } + +.settings-table { display: flex; flex-direction: column; } +.settings-table-section { + font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; + color: var(--text-3); margin: 18px 0 6px; padding: 0 2px; +} +.settings-table-section:first-child { margin-top: 0; } +.settings-row { + display: grid; grid-template-columns: 40px 1fr minmax(140px, auto); + align-items: center; gap: 12px; + padding: 10px 4px; border-bottom: 1px solid var(--border-light); +} +.settings-row:last-child { border-bottom: none; } +.settings-row-name { font-size: 14px; color: var(--text-1); } +.settings-row-value { display: flex; align-items: center; justify-content: flex-end; gap: 8px; } +.settings-row-value select { + min-width: 130px; max-width: 100%; +} + +/* Sync toggle switch — used per row and (larger) globally */ +.sync-toggle { + position: relative; flex: 0 0 auto; + width: 34px; height: 20px; padding: 0; + border: none; border-radius: 999px; cursor: pointer; + background: var(--bg-active); transition: background var(--transition); +} +.sync-toggle::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 16px; height: 16px; border-radius: 50%; + background: #fff; transition: transform var(--transition); +} +.sync-toggle.on { background: var(--primary); } +.sync-toggle.on::after { transform: translateX(14px); } +.sync-toggle-lg { width: 44px; height: 26px; } +.sync-toggle-lg::after { width: 22px; height: 22px; } +.sync-toggle-lg.on::after { transform: translateX(18px); } +.settings-row .sync-toggle { justify-self: center; } + +/* Per-row colour control: swatch + hex + reset, right-aligned */ +.settings-color-ctl { display: flex; align-items: center; gap: 6px; } +.settings-color-ctl .ev-color-hex { width: 92px; } +.settings-color-ctl .ev-color-reset { + display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; padding: 0; color: var(--text-3); +} + +/* Compact inline share-icon picker inside a value cell */ +.settings-icon-ctl { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; max-width: 220px; } + +@media (max-width: 640px) { + .settings-row { grid-template-columns: 34px 1fr; grid-auto-rows: auto; } + .settings-row-value { grid-column: 1 / -1; justify-content: flex-start; padding-left: 46px; } +} + /* ── Settings (legacy) ──────────────────────────────────── */ .settings-section { margin-bottom: 28px; } .settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } diff --git a/frontend/index.html b/frontend/index.html index 18d6e62..e6a507f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -762,9 +762,6 @@ -
- -

App-Passwörter (CalDAV)

Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.

@@ -800,16 +797,6 @@

Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.

-

Standard-Termindauer

-
- - - - - - -
-

Geteilter Kalender

Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist

@@ -818,116 +805,16 @@
- +
- -

Kalenderansicht

-
- - -
-
- - -
-
- -
- -

Sprache

-
- -
- -

Stundenhöhe (Wochen- & Tagesansicht)

-

Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt

-
- - - - -
- -

Teilen-Symbol

-

Symbol neben deinem geteilten Kalender in der Seitenleiste

-
- -

Farben

-
- -
- -
-
-
-
- -
- -
-
-
-
- -
- -
-
-
-
- -
- -
-
-
-
- -
- -
-
-
- -
- -
- -
- -
-
-
- -
- -
- -
-
-
- -
- -
- +
+
+ Alle synchronisieren + Diese Einstellungen zwischen deinen Geräten teilen
+
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index d5e845a..8eab897 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -1,5 +1,5 @@ import { api } from './api.js'; -import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js'; +import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml } from './utils.js'; import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; import { renderAgenda } from './views/agenda.js'; @@ -7,6 +7,7 @@ import { renderQuarter } from './views/quarter.js'; import { openColorPicker } from './color-picker.js'; import { openDatePicker, formatDtDisplay } from './date-picker.js'; import { t, setLang, getLang } from './i18n.js'; +import { DEFAULT_COLORS, SETTING_GROUPS, SYNCABLE_KEYS, loadLocal, saveLocal, mergeEffective } from './settings-sync.js'; import { APP_VERSION } from './version.js'; // Version im Impressum/Sidebar sichtbar, nicht im Tab-Titel. @@ -123,15 +124,22 @@ export async function initCalendar() { api.get('/homeassistant/accounts').catch(() => []), ]); - state.settings = settings; + // Per-setting sync: the server's raw response is the shared source; effective + // settings resolve each syncable key against the account-wide sync flags and + // this browser's local copy (see settings-sync.js / backend/SETTINGS_SYNC.md). + state.serverSettings = settings; + state.syncFlags = { ...(settings.sync_flags || {}) }; + state.settings = mergeEffective(settings, state.syncFlags, loadLocal()); state.accounts = accounts; state.localCalendars = localCalendars; state.icalSubscriptions = icalSubscriptions; state.googleAccounts = googleAccounts; state.haAccounts = haAccounts; - state.currentView = settings.default_view || 'month'; - state.dimPast = settings.dim_past_events; - weekStartDay = settings.week_start_day || 'monday'; + state.currentView = state.settings.default_view || 'month'; + state.dimPast = state.settings.dim_past_events; + weekStartDay = state.settings.week_start_day || 'monday'; + // Follow the effective (possibly synced) UI language. + if (state.settings.language) setLang(state.settings.language); // URL state takes precedence over defaults (settings + today) const urlState = readUrlState(); @@ -3296,54 +3304,14 @@ function openSettingsModal() { } function populateSettings() { - const s = state.settings; - document.getElementById('cfg-default-view').value = s.default_view || 'month'; - document.getElementById('cfg-week-start').value = s.week_start_day || 'monday'; - const colors = [ - { id: 'cfg-primary', val: s.primary_color || '#4285f4' }, - { id: 'cfg-accent', val: s.accent_color || '#ea4335' }, - { id: 'cfg-today', val: s.today_color || '#4285f4' }, - { id: 'cfg-month-divider', val: s.month_divider_color || '#7090c0' }, - { id: 'cfg-month-label', val: s.month_label_color || '#7090c0' }, - ]; - colors.forEach(({ id, val }) => { - document.getElementById(id + '-hex').value = val.toUpperCase(); - document.getElementById(id + '-preview').style.background = val; - }); + // Appearance / syncable settings render into the unified sync table. + renderSettingsTable(); - // Override-Farben — leeres Hex-Input bedeutet "Default verwenden", - // aber die Preview zeigt trotzdem die aktuell wirksame Farbe. - [ - { id: 'cfg-text-color', val: s.text_color, fallback: DEFAULT_TEXT_COLOR }, - { id: 'cfg-line-color', val: s.line_color, fallback: DEFAULT_LINE_COLOR }, - { id: 'cfg-bg-color', val: s.bg_color, fallback: DEFAULT_BG_COLOR }, - ].forEach(({ id, val, fallback }) => { - const hex = document.getElementById(id + '-hex'); - const prev = document.getElementById(id + '-preview'); - if (!hex || !prev) return; - hex.value = val ? String(val).toUpperCase() : ''; - prev.style.background = val || fallback; - }); - document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; - document.getElementById('cfg-language').value = getLang(); + // Account settings (kept as a dedicated profile block, not synced per-device). + const s = state.settings; document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy'; renderGroupVisibleList(s.group_visible_calendar_id); - // Share-Icon-Picker in Darstellung - const shareIconPicker = document.getElementById('cfg-share-icon'); - if (shareIconPicker) { - const cur = s.share_calendar_icon || 'share'; - shareIconPicker.innerHTML = SHARE_ICON_KEYS.map(k => - `` - ).join(''); - shareIconPicker.querySelectorAll('.group-icon-opt').forEach(btn => { - btn.addEventListener('click', () => { - shareIconPicker.querySelectorAll('.group-icon-opt').forEach(b => b.classList.remove('on')); - btn.classList.add('on'); - }); - }); - } - // Profile chapter: name (from cached user) + email (fresh from /profile). const pu = JSON.parse(localStorage.getItem('user') || '{}'); document.getElementById('cfg-display-name').value = pu.display_name || pu.username || ''; @@ -3355,20 +3323,6 @@ function populateSettings() { }).catch(() => {}); initAppPasswords(); - // Set active contrast/hour-height/duration buttons - [ - { id: 'cfg-text-contrast', val: s.text_contrast || 3 }, - { id: 'cfg-line-contrast', val: s.line_contrast || 3 }, - { id: 'cfg-hour-height', val: s.hour_height || 60 }, - { id: 'cfg-event-duration', val: s.default_event_duration_minutes || 60 }, - ].forEach(({ id, val }) => { - const sel = document.getElementById(id); - if (!sel) return; - sel.querySelectorAll('.contrast-btn').forEach(btn => { - btn.classList.toggle('active', String(btn.dataset.val) === String(val)); - }); - }); - // Show users nav button only for admins const user = JSON.parse(localStorage.getItem('user') || '{}'); const usersNavBtn = document.getElementById('settings-nav-users'); @@ -3385,6 +3339,189 @@ function populateSettings() { renderAllAccounts(); } +// ── Settings sync table ─────────────────────────────────── +const SYNC_ICON_SVG = ''; +const RESET_ICON_SVG = ''; + +function normalizeHex(raw, fallback) { + let v = (raw || '').trim(); + if (!v) return fallback; + if (!v.startsWith('#')) v = '#' + v; + return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : fallback; +} + +function renderSettingsTable() { + const container = document.getElementById('settings-appearance-table'); + if (!container) return; + container.innerHTML = SETTING_GROUPS.map(group => + `
${escHtml(t(group.titleKey))}
` + + group.rows.map(settingRowHtml).join('') + ).join(''); + SETTING_GROUPS.forEach(g => g.rows.forEach(wireSettingRow)); + updateSyncAllToggle(); +} + +function settingRowHtml(def) { + const synced = !!state.syncFlags[def.key]; + const val = state.settings[def.key]; + let valueHtml = ''; + if (def.type === 'select') { + valueHtml = `'; + } else if (def.type === 'toggle') { + valueHtml = ``; + } else if (def.type === 'color') { + const hex = normalizeHex(val, DEFAULT_COLORS[def.key]); + valueHtml = `
+ +
+ +
`; + } else if (def.type === 'icon') { + const cur = val || 'share'; + valueHtml = `
` + SHARE_ICON_KEYS.map(k => + `` + ).join('') + '
'; + } + return `
+ +
${escHtml(t(def.labelKey))}
+
${valueHtml}
+
`; +} + +function wireSettingRow(def) { + const row = document.querySelector(`.settings-row[data-row="${def.key}"]`); + if (!row) return; + + const syncBtn = row.querySelector('[data-sync]'); + if (syncBtn) syncBtn.addEventListener('click', () => { + const on = !state.syncFlags[def.key]; + state.syncFlags[def.key] = on; + syncBtn.classList.toggle('on', on); + syncBtn.setAttribute('aria-checked', String(on)); + updateSyncAllToggle(); + }); + + if (def.type === 'select') { + // Hour height affects the theme — preview it live; other selects apply on Save. + if (def.key === 'hour_height') { + const sel = row.querySelector('[data-skey]'); + if (sel) sel.addEventListener('change', () => { + state.settings.hour_height = Number(sel.value); + applyTheme(state.settings); + }); + } + } else if (def.type === 'toggle') { + const vt = row.querySelector('[data-vtoggle]'); + if (vt) vt.addEventListener('click', () => { + const on = !vt.classList.contains('on'); + vt.classList.toggle('on', on); + vt.setAttribute('aria-checked', String(on)); + }); + } else if (def.type === 'color') { + const hex = row.querySelector('[data-shex]'); + const prev = row.querySelector('[data-sprev]'); + const reset = row.querySelector('[data-sreset]'); + const apply = (color) => { + prev.style.background = color; + state.settings[def.key] = color; + applyTheme(state.settings); + }; + prev.addEventListener('click', async () => { + const picked = await openColorPicker(prev, hex.value || DEFAULT_COLORS[def.key]); + if (picked) { hex.value = picked.toUpperCase(); apply(picked); } + }); + hex.addEventListener('input', () => { + const norm = normalizeHex(hex.value, null); + if (norm) apply(norm); + }); + hex.addEventListener('change', () => { + const norm = normalizeHex(hex.value, DEFAULT_COLORS[def.key]); + hex.value = norm; apply(norm); + }); + reset.addEventListener('click', () => { + const d = DEFAULT_COLORS[def.key]; + hex.value = d; apply(d); + }); + } else if (def.type === 'icon') { + row.querySelectorAll('.group-icon-opt').forEach(btn => { + btn.addEventListener('click', () => { + row.querySelectorAll('.group-icon-opt').forEach(b => b.classList.remove('on')); + btn.classList.add('on'); + }); + }); + } +} + +// Read the current value of every syncable web setting from the table DOM. +function readAppearanceTable() { + const out = {}; + const numKeys = new Set(['hour_height', 'default_event_duration_minutes']); + SETTING_GROUPS.forEach(g => g.rows.forEach(def => { + if (def.type === 'select') { + const el = document.querySelector(`[data-skey="${def.key}"]`); + let v = el ? el.value : state.settings[def.key]; + if (numKeys.has(def.key)) v = Number(v); + out[def.key] = v; + } else if (def.type === 'toggle') { + const el = document.querySelector(`[data-vtoggle="${def.key}"]`); + out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key]; + } else if (def.type === 'color') { + const el = document.querySelector(`[data-shex="${def.key}"]`); + out[def.key] = normalizeHex(el && el.value, DEFAULT_COLORS[def.key]); + } else if (def.type === 'icon') { + const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`); + out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share'); + } + })); + return out; +} + +function updateSyncAllToggle() { + const btn = document.getElementById('cfg-sync-all'); + if (!btn) return; + const allOn = SYNCABLE_KEYS.every(k => state.syncFlags[k]); + btn.classList.toggle('on', allOn); + btn.setAttribute('aria-checked', String(allOn)); +} + +function toggleSyncAll() { + const next = !SYNCABLE_KEYS.every(k => state.syncFlags[k]); + SYNCABLE_KEYS.forEach(k => { state.syncFlags[k] = next; }); + document.querySelectorAll('.settings-row .sync-icon[data-sync]').forEach(el => { + const on = !!state.syncFlags[el.dataset.sync]; + el.classList.toggle('on', on); + el.setAttribute('aria-checked', String(on)); + }); + updateSyncAllToggle(); +} + +// Save profile identity fields (name/login/email/hidden) — server rotates the +// JWT if the login name changed. +async function saveProfileFields() { + const email = document.getElementById('cfg-email').value.trim(); + const displayName = document.getElementById('cfg-display-name').value.trim(); + const loginName = document.getElementById('cfg-login-name').value.trim(); + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const body = { email: email || null }; + if (displayName) body.display_name = displayName; + if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName; + const dh = document.getElementById('cfg-directory-hidden'); + if (dh) body.directory_hidden = dh.checked; + const res = await api.put('/profile/', body); + if (res && res.access_token) localStorage.setItem('token', res.access_token); + const updated = { ...user }; + if (displayName) updated.display_name = displayName; + if (body.username) updated.username = body.username.toLowerCase(); + localStorage.setItem('user', JSON.stringify(updated)); + const dd = document.getElementById('dropdown-username'); + if (dd) dd.textContent = updated.display_name || updated.username || 'Benutzer'; +} + function activateSettingsPanel(panel) { document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.toggle('active', b.dataset.panel === panel)); document.querySelectorAll('.settings-panel').forEach(p => p.classList.toggle('active', p.id === 'settings-panel-' + panel)); @@ -4139,97 +4276,15 @@ async function loadUsers() { } function bindSettingsModal() { - ['cfg-primary','cfg-accent','cfg-today','cfg-month-divider','cfg-month-label'].forEach(prefix => { - const preview = document.getElementById(prefix + '-preview'); - const hex = document.getElementById(prefix + '-hex'); - preview.addEventListener('click', async () => { - const picked = await openColorPicker(preview, hex.value || '#4285f4'); - if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; } - }); - hex.addEventListener('change', () => { - let val = hex.value.trim(); - if (!val.startsWith('#')) val = '#' + val; - if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; } - }); - }); - - // Optional override colours (text / line / background) — empty = use default. - // Live-apply to the page so the user sees the effect while typing, not only after Save. - const overrideFieldMap = { - 'cfg-text-color': 'text_color', - 'cfg-line-color': 'line_color', - 'cfg-bg-color': 'bg_color', - }; - const liveApplyOverride = (prefix, value) => { - const field = overrideFieldMap[prefix]; - if (!field) return; - state.settings[field] = value || null; - applyTheme(state.settings); - }; - [ - { prefix: 'cfg-text-color', defaultColor: DEFAULT_TEXT_COLOR }, - { prefix: 'cfg-line-color', defaultColor: DEFAULT_LINE_COLOR }, - { prefix: 'cfg-bg-color', defaultColor: DEFAULT_BG_COLOR }, - ].forEach(({ prefix, defaultColor }) => { - const preview = document.getElementById(prefix + '-preview'); - const hex = document.getElementById(prefix + '-hex'); - const reset = document.getElementById(prefix + '-reset'); - if (!preview || !hex || !reset) return; - - const normalize = (raw) => { - let v = (raw || '').trim(); - if (!v) return ''; - if (!v.startsWith('#')) v = '#' + v; - return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : null; - }; - - preview.addEventListener('click', async () => { - const picked = await openColorPicker(preview, hex.value || defaultColor); - if (picked) { - hex.value = picked.toUpperCase(); - preview.style.background = picked; - liveApplyOverride(prefix, picked); - } - }); - - const onTyped = () => { - const norm = normalize(hex.value); - if (norm === '') { - preview.style.background = defaultColor; - liveApplyOverride(prefix, null); - } else if (norm) { - preview.style.background = norm; - liveApplyOverride(prefix, norm); - } - }; - hex.addEventListener('input', onTyped); - hex.addEventListener('change', () => { - const norm = normalize(hex.value); - if (norm) hex.value = norm; - }); - - reset.addEventListener('click', () => { - hex.value = ''; - preview.style.background = defaultColor; - liveApplyOverride(prefix, null); - }); - }); + // Global "sync everything" master toggle (per-row toggles are wired per render). + const syncAllBtn = document.getElementById('cfg-sync-all'); + if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll); // Panel navigation document.querySelectorAll('.settings-nav-btn').forEach(btn => { btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel)); }); - // Contrast / hour-height selectors - document.querySelectorAll('.contrast-selector').forEach(sel => { - sel.addEventListener('click', e => { - const btn = e.target.closest('.contrast-btn'); - if (!btn) return; - sel.querySelectorAll('.contrast-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - }); - }); - document.getElementById('btn-add-user').onclick = () => { document.getElementById('add-user-form').classList.toggle('hidden'); }; @@ -4249,71 +4304,35 @@ function bindSettingsModal() { } catch (e) { showToast(e.message, true); } }; - // Profile chapter save (name/login/email → /profile, separate from settings). - const profileSaveBtn = document.getElementById('cfg-profile-save'); - if (profileSaveBtn) profileSaveBtn.onclick = async () => { - const email = document.getElementById('cfg-email').value.trim(); - const displayName = document.getElementById('cfg-display-name').value.trim(); - const loginName = document.getElementById('cfg-login-name').value.trim(); - const user = JSON.parse(localStorage.getItem('user') || '{}'); - const body = { email: email || null }; - if (displayName) body.display_name = displayName; - if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName; - const dh = document.getElementById('cfg-directory-hidden'); - if (dh) body.directory_hidden = dh.checked; - try { - const res = await api.put('/profile/', body); - if (res && res.access_token) localStorage.setItem('token', res.access_token); - const updated = { ...user }; - if (displayName) updated.display_name = displayName; - if (body.username) updated.username = body.username.toLowerCase(); - localStorage.setItem('user', JSON.stringify(updated)); - const dd = document.getElementById('dropdown-username'); - if (dd) dd.textContent = updated.display_name || updated.username || 'Benutzer'; - showToast(t('profile_saved')); - } catch (e) { showToast(e.message, true); } - }; - + // Unified save: appearance sync table + sync flags + account settings + profile. document.getElementById('settings-save').onclick = async () => { - const getActive = (id) => { - const btn = document.querySelector(`#${id} .contrast-btn.active`); - return btn ? Number(btn.dataset.val) : null; - }; - // Optional override colours: empty input → null (use default). - // Tolerant: accepts both "ff0000" and "#ff0000". - const colourOrNull = (id) => { - let v = (document.getElementById(id).value || '').trim(); - if (!v) return null; - if (!v.startsWith('#')) v = '#' + v; - return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : null; - }; - const settings = { - default_view: document.getElementById('cfg-default-view').value, - week_start_day: document.getElementById('cfg-week-start').value, - primary_color: document.getElementById('cfg-primary-hex').value, - accent_color: document.getElementById('cfg-accent-hex').value, - today_color: document.getElementById('cfg-today-hex').value, - month_divider_color: document.getElementById('cfg-month-divider-hex').value, - month_label_color: document.getElementById('cfg-month-label-hex').value, - text_color: colourOrNull('cfg-text-color-hex'), - line_color: colourOrNull('cfg-line-color-hex'), - bg_color: colourOrNull('cfg-bg-color-hex'), - dim_past_events: document.getElementById('cfg-dim-past').checked, - default_event_duration_minutes: getActive('cfg-event-duration') || 60, - hour_height: getActive('cfg-hour-height') || 44, - language: document.getElementById('cfg-language').value, - private_event_visibility: document.getElementById('cfg-private-visibility').value, - }; - const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected; - settings.group_visible_calendar_id = gvVal ? parseInt(gvVal) : null; - const shareIconPicker = document.getElementById('cfg-share-icon'); - settings.share_calendar_icon = shareIconPicker?.querySelector('.group-icon-opt.on')?.dataset.shareIcon || null; try { - await api.put('/settings/', settings); - state.settings = { ...state.settings, ...settings }; - state.dimPast = settings.dim_past_events; - weekStartDay = settings.week_start_day; - setLang(settings.language); + const appearance = readAppearanceTable(); + // Persist every syncable value into this browser's local copy so that + // "not synced" keys survive per-device. + const local = loadLocal(); + for (const k of SYNCABLE_KEYS) local[k] = appearance[k]; + saveLocal(local); + // Account settings (kept out of the sync table — always account-wide). + const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected; + const payload = { + sync_flags: state.syncFlags, + private_event_visibility: document.getElementById('cfg-private-visibility').value, + group_visible_calendar_id: gvVal ? parseInt(gvVal) : null, + }; + // Push only the values whose sync flag is on. + for (const k of SYNCABLE_KEYS) { + if (state.syncFlags[k]) payload[k] = appearance[k]; + } + await api.put('/settings/', payload); + await saveProfileFields(); + // Recompute effective settings: server holds the synced values, unsynced + // keys resolve from the freshly-saved local copy. + state.serverSettings = { ...state.serverSettings, ...payload }; + state.settings = mergeEffective(state.serverSettings, state.syncFlags, loadLocal()); + state.dimPast = state.settings.dim_past_events; + weekStartDay = state.settings.week_start_day; + setLang(appearance.language); applyTheme(state.settings); showToast(t('settings_saved')); closeModal('modal-settings'); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index ab7a2d7..5800f97 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -65,6 +65,10 @@ const translations = { settings_nav_calendars: 'Kalender', settings_nav_google: 'Google Konten', settings_nav_users: 'Benutzerverwaltung', + settings_sync_all: 'Alle synchronisieren', + settings_sync_all_desc: 'Diese Einstellungen zwischen deinen Geräten teilen', + settings_sync_this: 'Zwischen Geräten synchronisieren', + settings_share_icon: 'Teilen-Symbol', settings_colors: 'Farben', settings_primary_color: 'Primärfarbe', settings_accent_color: 'Akzentfarbe', settings_today_color: 'Heutige-Tag-Farbe', @@ -413,6 +417,10 @@ const translations = { settings_nav_calendars: 'Calendars', settings_nav_google: 'Google Accounts', settings_nav_users: 'User Management', + settings_sync_all: 'Sync all', + settings_sync_all_desc: 'Share these settings across your devices', + settings_sync_this: 'Sync across devices', + settings_share_icon: 'Share icon', settings_colors: 'Colors', settings_primary_color: 'Primary color', settings_accent_color: 'Accent color', settings_today_color: 'Today highlight color', diff --git a/frontend/js/settings-sync.js b/frontend/js/settings-sync.js new file mode 100644 index 0000000..7e0deb2 --- /dev/null +++ b/frontend/js/settings-sync.js @@ -0,0 +1,104 @@ +// Per-setting cross-device sync for the web client. +// +// The server is the sole authority for WHICH settings sync (it returns a fully +// resolved `sync_flags` map). This module owns the browser-local copy of every +// syncable value so that "not synced" works per browser, plus the declarative +// table definition the settings UI renders from. See backend/SETTINGS_SYNC.md. + +// Canonical default colours — the single source for the web. Reset writes these. +export const DEFAULT_COLORS = { + primary_color: '#4285F4', + accent_color: '#EA4335', + today_color: '#4285F4', + text_color: '#FFFFFF', + bg_color: '#000000', + line_color: '#3A3A52', + month_divider_color: '#7090C0', + month_label_color: '#7090C0', +}; + +// The syncable settings the web client exposes, grouped into table sections. +// `type`: 'select' | 'toggle' | 'color' | 'icon'. +// Option labels use i18n keys via `tk`, or a literal `label`. +export const SETTING_GROUPS = [ + { + titleKey: 'settings_calendar_view', + rows: [ + { key: 'default_view', labelKey: 'settings_default_view', type: 'select', opts: [ + { v: 'month', tk: 'view_month' }, { v: 'week', tk: 'view_week' }, + { v: 'day', tk: 'view_day' }, { v: 'quarter', tk: 'view_quarter' }, + { v: 'agenda', tk: 'view_agenda' }, + ] }, + { key: 'week_start_day', labelKey: 'settings_week_start', type: 'select', opts: [ + { v: 'monday', tk: 'week_start_monday' }, { v: 'sunday', tk: 'week_start_sunday' }, + ] }, + { key: 'dim_past_events', labelKey: 'settings_dim_past', type: 'toggle' }, + { key: 'hour_height', labelKey: 'settings_hour_height', type: 'select', opts: [ + { v: 28, tk: 'hour_compact' }, { v: 44, tk: 'hour_normal' }, + { v: 60, tk: 'hour_comfort' }, { v: 80, tk: 'hour_large' }, + ] }, + { key: 'default_event_duration_minutes', labelKey: 'settings_default_duration', type: 'select', opts: [ + { v: 15, label: '15 min' }, { v: 30, label: '30 min' }, { v: 45, label: '45 min' }, + { v: 60, label: '1 h' }, { v: 90, label: '1,5 h' }, { v: 120, label: '2 h' }, + ] }, + ], + }, + { + titleKey: 'settings_language', + rows: [ + { key: 'language', labelKey: 'settings_language', type: 'select', opts: [ + { v: 'de', label: 'Deutsch' }, { v: 'en', label: 'English' }, + ] }, + { key: 'share_calendar_icon', labelKey: 'settings_share_icon', type: 'icon' }, + ], + }, + { + titleKey: 'settings_colors', + rows: [ + { key: 'primary_color', labelKey: 'settings_primary_color', type: 'color' }, + { key: 'accent_color', labelKey: 'settings_accent_color', type: 'color' }, + { key: 'today_color', labelKey: 'settings_today_color', type: 'color' }, + { key: 'text_color', labelKey: 'settings_text_color', type: 'color' }, + { key: 'bg_color', labelKey: 'settings_bg_color', type: 'color' }, + { key: 'line_color', labelKey: 'settings_line_color', type: 'color' }, + { key: 'month_divider_color', labelKey: 'settings_month_divider_color', type: 'color' }, + { key: 'month_label_color', labelKey: 'settings_month_label_color', type: 'color' }, + ], + }, +]; + +// Flat list of every syncable key the web manages (table order). +export const SYNCABLE_KEYS = SETTING_GROUPS.flatMap(g => g.rows.map(r => r.key)); + +const LOCAL_KEY = 'settingsLocal'; + +export function loadLocal() { + try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '{}') || {}; } + catch (_) { return {}; } +} + +export function saveLocal(obj) { + try { localStorage.setItem(LOCAL_KEY, JSON.stringify(obj)); } catch (_) {} +} + +// Effective value of a syncable key: synced → server value; otherwise the +// browser-local value (falling back to the server value if we have none yet). +export function effectiveValue(key, server, flags, local) { + if (flags[key]) return server[key]; + return (key in local && local[key] != null) ? local[key] : server[key]; +} + +// Build the effective settings object: a copy of the raw server settings with +// each syncable key resolved to its effective value. As a side effect, mirror +// every effective value into the local copy so that flipping a flag OFF later +// retains the currently-visible value. +export function mergeEffective(server, flags, local) { + const eff = { ...server }; + for (const key of SYNCABLE_KEYS) { + const val = effectiveValue(key, server, flags, local); + eff[key] = val; + if (val != null) local[key] = val; + } + saveLocal(local); + return eff; +} diff --git a/frontend/sw.js b/frontend/sw.js index 9df0597..aecdda6 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -7,7 +7,7 @@ // the entry HTML / version files). New releases take effect on the next // reload, no manual SW unregister required. -const CACHE_VERSION = 'calendarr-v25'; +const CACHE_VERSION = 'calendarr-v26'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => {