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:
@@ -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; }
|
||||
|
||||
@@ -762,9 +762,6 @@
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="cfg-email" placeholder="Keine E-Mail hinterlegt" />
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="btn btn-primary btn-sm" id="cfg-profile-save" data-i18n="save">Speichern</button>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="app_pw_title">App-Passwörter (CalDAV)</h4>
|
||||
<p class="panel-desc" data-i18n="app_pw_desc">Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.</p>
|
||||
@@ -800,16 +797,6 @@
|
||||
<p class="panel-desc" data-i18n="settings_directory_hidden_desc">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.</p>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4>
|
||||
<div class="contrast-selector" id="cfg-event-duration">
|
||||
<button class="contrast-btn" data-val="15">15 min</button>
|
||||
<button class="contrast-btn" data-val="30">30 min</button>
|
||||
<button class="contrast-btn" data-val="45">45 min</button>
|
||||
<button class="contrast-btn" data-val="60">1 h</button>
|
||||
<button class="contrast-btn" data-val="90">1,5 h</button>
|
||||
<button class="contrast-btn" data-val="120">2 h</button>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4>
|
||||
<p class="panel-desc" data-i18n="settings_group_visible_desc">Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist</p>
|
||||
<div class="form-group">
|
||||
@@ -818,116 +805,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Darstellung: Kalenderansicht, Sprache, Stundenhöhe, Farben -->
|
||||
<!-- Darstellung: einheitliche Sync-Tabelle (Sync-Icon | Name | Wert) -->
|
||||
<div class="settings-panel" id="settings-panel-general">
|
||||
|
||||
<h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_default_view">Standardansicht</label>
|
||||
<select id="cfg-default-view">
|
||||
<option value="month" data-i18n="view_month">Monat</option>
|
||||
<option value="week" data-i18n="view_week">Woche</option>
|
||||
<option value="day" data-i18n="view_day">Tag</option>
|
||||
<option value="quarter" data-i18n="view_quarter">Quartal</option>
|
||||
<option value="agenda" data-i18n="view_agenda">Termine</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_week_start">Erster Wochentag</label>
|
||||
<select id="cfg-week-start">
|
||||
<option value="monday" data-i18n="week_start_monday">Montag</option>
|
||||
<option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="cfg-dim-past" />
|
||||
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_language">Sprache</h4>
|
||||
<div class="form-group">
|
||||
<select id="cfg-language">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- & Tagesansicht)</h4>
|
||||
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
|
||||
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
|
||||
<button class="contrast-btn" data-val="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
|
||||
<button class="contrast-btn" data-val="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
|
||||
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
|
||||
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px">Teilen-Symbol</h4>
|
||||
<p class="panel-desc">Symbol neben deinem geteilten Kalender in der Seitenleiste</p>
|
||||
<div class="group-icon-picker" id="cfg-share-icon"></div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_colors">Farben</h4>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_primary_color">Primärfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-primary-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-primary-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_accent_color">Akzentfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-accent-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-accent-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_today_color">Heutige-Tag-Farbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-today-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_month_divider_color">Monatswechsel-Linie</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-month-divider-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-month-divider-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_month_label_color">Monatskürzel</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-month-label-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-month-label-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_text_color">Schriftfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-text-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||
<div class="ev-color-preview" id="cfg-text-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="cfg-text-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_line_color">Linienfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-line-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||
<div class="ev-color-preview" id="cfg-line-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="cfg-line-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_bg_color">Hintergrundfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-bg-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||
<div class="ev-color-preview" id="cfg-bg-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||
<div class="sync-global-row">
|
||||
<div class="sync-global-text">
|
||||
<span class="sync-global-title" data-i18n="settings_sync_all">Alle synchronisieren</span>
|
||||
<span class="panel-desc" data-i18n="settings_sync_all_desc">Diese Einstellungen zwischen deinen Geräten teilen</span>
|
||||
</div>
|
||||
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button>
|
||||
</div>
|
||||
<div class="settings-table" id="settings-appearance-table"></div>
|
||||
</div>
|
||||
|
||||
<!-- Kalender: einheitliche Tabelle aller Kalender aus allen Quellen -->
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
104
frontend/js/settings-sync.js
Normal file
104
frontend/js/settings-sync.js
Normal 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;
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user