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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-14 21:07:26 +02:00
parent 3d58e8fef8
commit 137694ed98
6 changed files with 413 additions and 332 deletions

View File

@@ -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 =>
`<button type="button" class="group-icon-opt ${cur === k ? 'on' : ''}" data-share-icon="${k}" title="${k}">${shareIconSvg(k, 20)}</button>`
).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 = '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0020 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 004 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>';
const RESET_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></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 =>
`<div class="settings-table-section">${escHtml(t(group.titleKey))}</div>` +
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 = `<select data-skey="${def.key}">` + def.opts.map(o => {
const lbl = o.tk ? t(o.tk) : o.label;
const sel = String(o.v) === String(val) ? ' selected' : '';
return `<option value="${o.v}"${sel}>${escHtml(lbl)}</option>`;
}).join('') + '</select>';
} else if (def.type === 'toggle') {
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
} else if (def.type === 'color') {
const hex = normalizeHex(val, DEFAULT_COLORS[def.key]);
valueHtml = `<div class="settings-color-ctl">
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}" />
<div class="ev-color-preview" data-sprev="${def.key}" style="background:${hex}" title="${escHtml(t('color_pick'))}"></div>
<button type="button" class="icon-btn ev-color-reset" data-sreset="${def.key}" title="${escHtml(t('reset'))}">${RESET_ICON_SVG}</button>
</div>`;
} else if (def.type === 'icon') {
const cur = val || 'share';
valueHtml = `<div class="settings-icon-ctl" data-sicon="${def.key}">` + SHARE_ICON_KEYS.map(k =>
`<button type="button" class="group-icon-opt ${cur === k ? 'on' : ''}" data-share-icon="${k}" title="${k}">${shareIconSvg(k, 18)}</button>`
).join('') + '</div>';
}
return `<div class="settings-row" data-row="${def.key}">
<button type="button" class="sync-icon ${synced ? 'on' : ''}" data-sync="${def.key}" role="switch" aria-checked="${synced}" title="${escHtml(t('settings_sync_this'))}">${SYNC_ICON_SVG}</button>
<div class="settings-row-name">${escHtml(t(def.labelKey))}</div>
<div class="settings-row-value">${valueHtml}</div>
</div>`;
}
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');

View File

@@ -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',

View File

@@ -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;
}