feat(web): theme import/export, more themable colors, hideable local/ical calendars
Theme: - Export/import themes via UI as <date>_<time>.theme (JSON, readable keys, link to THEME.md). Import is partial-aware: only present params are written (server accepts partial); prompts before importing files with unknown params. - Dynamic favicon + theme-color tinted to the primary colour on load/save. - New themable colours: general hover-highlight, day hover/selected/bg, today background, plus two unified sidebar action-icon colours (inactive/active) covering bell, hide, delete and read-only icons. - All new colours are per-setting syncable; documented in THEME.md. UX: - Styled confirm dialog (#modal-confirm) replaces window.confirm() for calendar delete and account disconnect. - Birthday/local calendars and iCal subscriptions can now be hidden from the sidebar via Settings (new sidebar_hidden column + hide toggle). Backend: additive nullable columns + idempotent migrations for user_settings colours and local_calendars/ical_subscriptions.sidebar_hidden. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { api } from './api.js';
|
||||
import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml } from './utils.js';
|
||||
import { applyTheme, applyFavicon, 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';
|
||||
@@ -151,6 +151,7 @@ export async function initCalendar() {
|
||||
|
||||
setLang(settings.language || 'de');
|
||||
applyTheme(settings);
|
||||
applyFavicon(settings.primary_color);
|
||||
updateViewButtons();
|
||||
renderCalendarList();
|
||||
renderMiniCal();
|
||||
@@ -775,7 +776,7 @@ function renderCalendarList() {
|
||||
});
|
||||
});
|
||||
const groupVisibleId = state.settings && state.settings.group_visible_calendar_id;
|
||||
state.localCalendars.filter(c => c.owned !== false && !c.group).forEach(cal => {
|
||||
state.localCalendars.filter(c => c.owned !== false && !c.group && !c.sidebar_hidden).forEach(cal => {
|
||||
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||
name: cal.name, color: cal.color, enabled: cal.enabled,
|
||||
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
|
||||
@@ -802,7 +803,7 @@ function renderCalendarList() {
|
||||
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`,
|
||||
isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null });
|
||||
});
|
||||
state.icalSubscriptions.forEach(sub => {
|
||||
state.icalSubscriptions.filter(s => !s.sidebar_hidden).forEach(sub => {
|
||||
entries.push({ key: `ical:${sub.id}`, source: 'ical', dataId: `data-sub-id="${sub.id}"`,
|
||||
name: sub.name, color: sub.color, enabled: sub.enabled,
|
||||
reminders: true, remindersEnabled: sub.reminders_enabled !== false,
|
||||
@@ -1095,13 +1096,13 @@ function renderCalendarList() {
|
||||
}
|
||||
cacheCalId = calId;
|
||||
} else if (source === 'local') {
|
||||
if (!confirm(t('confirm_delete_local_cal'))) return;
|
||||
if (!await confirmModal(t('confirm_delete_local_cal'), { title: t('confirm_delete_cal_title'), okLabel: t('delete') })) return;
|
||||
const calId = parseInt(btn.dataset.calId);
|
||||
await api.delete(`/local/calendars/${calId}`);
|
||||
state.localCalendars = state.localCalendars.filter(c => c.id !== calId);
|
||||
cacheCalId = `local-${calId}`;
|
||||
} else if (source === 'ical') {
|
||||
if (!confirm(t('confirm_remove_ical'))) return;
|
||||
if (!await confirmModal(t('confirm_remove_ical'), { title: t('confirm_remove_ical_title'), okLabel: t('delete') })) return;
|
||||
const subId = parseInt(btn.dataset.subId);
|
||||
await api.delete(`/ical/subscriptions/${subId}`);
|
||||
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
|
||||
@@ -1490,6 +1491,30 @@ function showDeleteConfirm(ev) {
|
||||
});
|
||||
}
|
||||
|
||||
// Styled yes/no dialog — a drop-in replacement for window.confirm().
|
||||
// Returns a Promise<boolean>. `danger` (default true) shows a red confirm button.
|
||||
function confirmModal(message, { title, okLabel, danger = true } = {}) {
|
||||
return new Promise(resolve => {
|
||||
const modal = document.getElementById('modal-confirm');
|
||||
document.getElementById('confirm-title').textContent = title || t('confirm_generic_title');
|
||||
document.getElementById('confirm-text').textContent = message || '';
|
||||
const okBtn = document.getElementById('confirm-ok');
|
||||
okBtn.textContent = okLabel || t('confirm');
|
||||
okBtn.classList.toggle('btn-danger', danger);
|
||||
okBtn.classList.toggle('btn-primary', !danger);
|
||||
openModal('modal-confirm');
|
||||
|
||||
const cleanup = () => {
|
||||
okBtn.onclick = null;
|
||||
modal.querySelectorAll('[data-modal="modal-confirm"]').forEach(b => b.onclick = null);
|
||||
};
|
||||
okBtn.onclick = () => { cleanup(); closeModal('modal-confirm'); resolve(true); };
|
||||
modal.querySelectorAll('[data-modal="modal-confirm"]').forEach(b => {
|
||||
b.onclick = () => { cleanup(); closeModal('modal-confirm'); resolve(false); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showDayContextMenu(date, mouseEvent) {
|
||||
document.querySelectorAll('.cal-context-menu').forEach(m => m.remove());
|
||||
|
||||
@@ -3511,6 +3536,114 @@ function toggleSyncAll() {
|
||||
updateSyncAllToggle();
|
||||
}
|
||||
|
||||
// Human-facing docs describing every theme parameter (linked from export files).
|
||||
const THEME_DOCS_URL = 'https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md';
|
||||
|
||||
// Keys that are colours (validated on import).
|
||||
function colorSettingKeys() {
|
||||
const out = new Set();
|
||||
SETTING_GROUPS.forEach(g => g.rows.forEach(r => { if (r.type === 'color') out.add(r.key); }));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Export the current appearance (colours + display settings) as a .theme file
|
||||
// named after the local date+time, with a link to the parameter docs.
|
||||
function exportTheme() {
|
||||
const now = new Date();
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}_${p(now.getHours())}-${p(now.getMinutes())}`;
|
||||
const payload = {
|
||||
_format: 'calendarr-theme',
|
||||
_version: 1,
|
||||
_docs: THEME_DOCS_URL,
|
||||
exported_at: now.toISOString(),
|
||||
settings: readAppearanceTable(),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${stamp}.theme`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Read a chosen .theme file locally and apply its values. A theme may be
|
||||
// partial (only some parameters) — only the keys present are written; omitted
|
||||
// keys are left untouched (the server accepts partial updates). If the file
|
||||
// contains parameters this version doesn't know, the user is asked whether to
|
||||
// import the rest anyway.
|
||||
async function importThemeFile(input) {
|
||||
const file = input.files && input.files[0];
|
||||
input.value = ''; // allow re-importing the same file later
|
||||
if (!file) return;
|
||||
|
||||
let incoming;
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
incoming = (data && typeof data === 'object' && data.settings && typeof data.settings === 'object')
|
||||
? data.settings : data;
|
||||
if (!incoming || typeof incoming !== 'object') throw new Error('bad');
|
||||
} catch (e) {
|
||||
showToast(t('theme_import_invalid'), true);
|
||||
return;
|
||||
}
|
||||
|
||||
const known = new Set(SYNCABLE_KEYS);
|
||||
const unknownKeys = Object.keys(incoming).filter(k => !known.has(k));
|
||||
if (unknownKeys.length) {
|
||||
const ok = await confirmModal(
|
||||
t('theme_unknown_params', { count: unknownKeys.length, names: unknownKeys.join(', ') }),
|
||||
{ title: t('theme_unknown_title'), okLabel: t('theme_import_rest'), danger: false }
|
||||
);
|
||||
if (!ok) return; // user declined → import nothing
|
||||
}
|
||||
|
||||
// Collect the known, valid parameters to apply.
|
||||
const colorKeys = colorSettingKeys();
|
||||
const toApply = {};
|
||||
for (const key of SYNCABLE_KEYS) {
|
||||
if (!(key in incoming)) continue;
|
||||
let val = incoming[key];
|
||||
if (colorKeys.has(key)) {
|
||||
const norm = normalizeHex(val, null);
|
||||
if (!norm) continue; // skip invalid colour values
|
||||
val = norm;
|
||||
}
|
||||
toApply[key] = val;
|
||||
}
|
||||
const applied = Object.keys(toApply);
|
||||
if (!applied.length) { showToast(t('theme_import_invalid'), true); return; }
|
||||
|
||||
// Persist ONLY the present keys (partial). Keys whose sync flag is on are sent
|
||||
// to the server; the rest just update this browser's local copy.
|
||||
const local = loadLocal();
|
||||
const serverPayload = {};
|
||||
for (const key of applied) {
|
||||
state.settings[key] = toApply[key];
|
||||
local[key] = toApply[key];
|
||||
if (state.syncFlags[key]) serverPayload[key] = toApply[key];
|
||||
}
|
||||
saveLocal(local);
|
||||
try {
|
||||
if (Object.keys(serverPayload).length) await api.put('/settings/', serverPayload);
|
||||
state.serverSettings = { ...state.serverSettings, ...serverPayload };
|
||||
state.settings = mergeEffective(state.serverSettings, state.syncFlags, loadLocal());
|
||||
renderSettingsTable();
|
||||
applyTheme(state.settings);
|
||||
applyFavicon(state.settings.primary_color);
|
||||
setLang(state.settings.language);
|
||||
renderCalendarList();
|
||||
renderMiniCal();
|
||||
fetchAndRender();
|
||||
showToast(t('theme_imported_saved', { count: applied.length }));
|
||||
} catch (e) {
|
||||
showToast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Save profile identity fields (name/login/email/hidden) — server rotates the
|
||||
// JWT if the login name changed.
|
||||
async function saveProfileFields() {
|
||||
@@ -3572,7 +3705,7 @@ function renderGoogleAccounts() {
|
||||
});
|
||||
list.querySelectorAll('[data-disconnect-acc]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(t('confirm_google_disconnect'))) return;
|
||||
if (!await confirmModal(t('confirm_google_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
|
||||
try {
|
||||
await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`);
|
||||
state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc));
|
||||
@@ -3617,7 +3750,7 @@ function renderAllAccounts() {
|
||||
});
|
||||
caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(t('confirm_caldav_disconnect'))) return;
|
||||
if (!await confirmModal(t('confirm_caldav_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
|
||||
try {
|
||||
await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`);
|
||||
state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect));
|
||||
@@ -3691,7 +3824,7 @@ function renderAllAccounts() {
|
||||
).join('');
|
||||
icalList.querySelectorAll('[data-ical-delete]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(t('confirm_remove_ical'))) return;
|
||||
if (!await confirmModal(t('confirm_remove_ical'), { title: t('confirm_remove_ical_title'), okLabel: t('delete') })) return;
|
||||
try {
|
||||
await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`);
|
||||
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete));
|
||||
@@ -3735,7 +3868,7 @@ function renderAllAccounts() {
|
||||
});
|
||||
haList.querySelectorAll('[data-ha-disconnect]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Home Assistant Konto wirklich trennen?')) return;
|
||||
if (!await confirmModal(t('confirm_ha_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
|
||||
try {
|
||||
await api.delete(`/homeassistant/accounts/${btn.dataset.haDisconnect}`);
|
||||
state.haAccounts = state.haAccounts.filter(a => a.id !== parseInt(btn.dataset.haDisconnect));
|
||||
@@ -3951,7 +4084,7 @@ function renderCalendarTable() {
|
||||
rows += `<tr>
|
||||
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
||||
<td class="ct-src">Lokal</td>
|
||||
<td>—</td>
|
||||
<td>${hid('local', cal.id, !cal.sidebar_hidden)}</td>
|
||||
<td>${rem('local', cal.id, cal.reminders_enabled !== false)}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
|
||||
@@ -4095,9 +4228,13 @@ function renderCalendarTable() {
|
||||
const hidden = btn.dataset.ctVisible === '1'; // currently visible → we're hiding it
|
||||
try {
|
||||
if (src === 'ical') {
|
||||
await api.put(`/ical/subscriptions/${id}`, { sidebar_hidden: hidden });
|
||||
await api.put(`/ical/subscriptions/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
|
||||
const s = state.icalSubscriptions.find(s => s.id === id);
|
||||
if (s) s.sidebar_hidden = hidden;
|
||||
if (s) { s.sidebar_hidden = hidden; s.enabled = !hidden; }
|
||||
} else if (src === 'local') {
|
||||
await api.put(`/local/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
|
||||
const c = state.localCalendars.find(c => c.id === id);
|
||||
if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; }
|
||||
} else if (src === 'caldav') {
|
||||
await api.put(`/caldav/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
|
||||
for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } }
|
||||
@@ -4164,8 +4301,8 @@ function renderCalendarTable() {
|
||||
const src = btn.dataset.ctDisc, id = parseInt(btn.dataset.ctId);
|
||||
const msg = src === 'caldav' ? t('confirm_caldav_disconnect')
|
||||
: src === 'google' ? t('confirm_google_disconnect')
|
||||
: 'Konto wirklich trennen?';
|
||||
if (!confirm(msg)) return;
|
||||
: t('confirm_ha_disconnect');
|
||||
if (!await confirmModal(msg, { title: t('disconnect'), okLabel: t('disconnect') })) return;
|
||||
try {
|
||||
if (src === 'caldav') {
|
||||
await api.delete(`/caldav/accounts/${id}`);
|
||||
@@ -4240,7 +4377,8 @@ function renderCalendarTable() {
|
||||
btn.addEventListener('click', async () => {
|
||||
const src = btn.dataset.ctDel, id = parseInt(btn.dataset.ctId);
|
||||
const msg = src === 'local' ? t('confirm_delete_local_cal') : t('confirm_remove_ical');
|
||||
if (!confirm(msg)) return;
|
||||
const title = src === 'local' ? t('confirm_delete_cal_title') : t('confirm_remove_ical_title');
|
||||
if (!await confirmModal(msg, { title, okLabel: t('delete') })) return;
|
||||
try {
|
||||
if (src === 'local') {
|
||||
await api.delete(`/local/calendars/${id}`);
|
||||
@@ -4303,6 +4441,16 @@ function bindSettingsModal() {
|
||||
const syncAllBtn = document.getElementById('cfg-sync-all');
|
||||
if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll);
|
||||
|
||||
// Theme export / import (client-side .theme files).
|
||||
const themeExportBtn = document.getElementById('cfg-theme-export');
|
||||
if (themeExportBtn) themeExportBtn.addEventListener('click', exportTheme);
|
||||
const themeImportBtn = document.getElementById('cfg-theme-import');
|
||||
const themeFileInput = document.getElementById('cfg-theme-file');
|
||||
if (themeImportBtn && themeFileInput) {
|
||||
themeImportBtn.addEventListener('click', () => themeFileInput.click());
|
||||
themeFileInput.addEventListener('change', () => importThemeFile(themeFileInput));
|
||||
}
|
||||
|
||||
// Panel navigation
|
||||
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel));
|
||||
@@ -4357,6 +4505,7 @@ function bindSettingsModal() {
|
||||
weekStartDay = state.settings.week_start_day;
|
||||
setLang(appearance.language);
|
||||
applyTheme(state.settings);
|
||||
applyFavicon(state.settings.primary_color);
|
||||
showToast(t('settings_saved'));
|
||||
closeModal('modal-settings');
|
||||
renderCalendarList();
|
||||
|
||||
@@ -78,6 +78,27 @@ const translations = {
|
||||
settings_line_color: 'Linienfarbe',
|
||||
settings_bg_color: 'Hintergrundfarbe',
|
||||
settings_surface_color: 'Seitenleisten-/Oberflächenfarbe',
|
||||
settings_hover_highlight_color: 'Hover-Highlight (Buttons, Menüs)',
|
||||
settings_icon_inactive_color: 'Sidebar-Icons inaktiv (Ruhe/aus)',
|
||||
settings_icon_active_color: 'Sidebar-Icons aktiv (Hover/an)',
|
||||
settings_day_hover_color: 'Kalendertag Hover',
|
||||
settings_day_selected_color: 'Ausgewählter Tag',
|
||||
settings_day_bg_color: 'Tag-Hintergrund',
|
||||
settings_today_bg_color: 'Heutiger Tag Hintergrund',
|
||||
theme_export: 'Theme exportieren',
|
||||
theme_import: 'Theme importieren',
|
||||
theme_imported: 'Theme importiert – zum Übernehmen speichern',
|
||||
theme_imported_saved: '{count} Parameter importiert & gespeichert',
|
||||
theme_import_invalid: 'Ungültige Theme-Datei',
|
||||
theme_docs_hint: 'Erklärung der Theme-Parameter',
|
||||
theme_unknown_title: 'Unbekannte Parameter',
|
||||
theme_unknown_params: 'Diese Theme-Datei enthält {count} Parameter, die diese Version nicht kennt ({names}). Den Rest trotzdem importieren?',
|
||||
theme_import_rest: 'Rest importieren',
|
||||
confirm_generic_title: 'Bestätigen',
|
||||
confirm: 'OK',
|
||||
confirm_delete_cal_title: 'Kalender löschen',
|
||||
confirm_remove_ical_title: 'Abo entfernen',
|
||||
confirm_ha_disconnect: 'Home Assistant Konto wirklich trennen?',
|
||||
reset: 'Reset',
|
||||
settings_text_contrast: 'Schriftkontrast',
|
||||
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
|
||||
@@ -435,6 +456,27 @@ const translations = {
|
||||
settings_line_color: 'Line color',
|
||||
settings_bg_color: 'Background color',
|
||||
settings_surface_color: 'Sidebar / surface color',
|
||||
settings_hover_highlight_color: 'Hover highlight (buttons, menus)',
|
||||
settings_icon_inactive_color: 'Sidebar icons inactive (resting/off)',
|
||||
settings_icon_active_color: 'Sidebar icons active (hover/on)',
|
||||
settings_day_hover_color: 'Calendar day hover',
|
||||
settings_day_selected_color: 'Selected day',
|
||||
settings_day_bg_color: 'Day background',
|
||||
settings_today_bg_color: 'Today background',
|
||||
theme_export: 'Export theme',
|
||||
theme_import: 'Import theme',
|
||||
theme_imported: 'Theme imported – click Save to apply',
|
||||
theme_imported_saved: '{count} parameters imported & saved',
|
||||
theme_import_invalid: 'Invalid theme file',
|
||||
theme_docs_hint: 'Explanation of theme parameters',
|
||||
theme_unknown_title: 'Unknown parameters',
|
||||
theme_unknown_params: 'This theme file contains {count} parameters this version does not know ({names}). Import the rest anyway?',
|
||||
theme_import_rest: 'Import the rest',
|
||||
confirm_generic_title: 'Confirm',
|
||||
confirm: 'OK',
|
||||
confirm_delete_cal_title: 'Delete calendar',
|
||||
confirm_remove_ical_title: 'Remove subscription',
|
||||
confirm_ha_disconnect: 'Really disconnect the Home Assistant account?',
|
||||
reset: 'Reset',
|
||||
settings_text_contrast: 'Text contrast',
|
||||
settings_text_contrast_desc: 'Brightness of labels and text',
|
||||
|
||||
@@ -16,6 +16,16 @@ export const DEFAULT_COLORS = {
|
||||
surface_color: '#1A1A1A',
|
||||
month_divider_color: '#7090C0',
|
||||
month_label_color: '#7090C0',
|
||||
// Fine-grained element colours. Defaults equal the previously-derived look
|
||||
// (see THEME.md). day_selected/today_bg are applied as a subtle tint of the
|
||||
// chosen colour; the rest are applied solid.
|
||||
hover_highlight_color: '#2A2A38',
|
||||
icon_inactive_color: '#9090AA',
|
||||
icon_active_color: '#E8E8F0',
|
||||
day_hover_color: '#2A2A38',
|
||||
day_selected_color: '#4285F4',
|
||||
day_bg_color: '#000000',
|
||||
today_bg_color: '#4285F4',
|
||||
};
|
||||
|
||||
// The syncable settings the web client exposes, grouped into table sections.
|
||||
@@ -68,6 +78,13 @@ export const SETTING_GROUPS = [
|
||||
{ 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' },
|
||||
{ key: 'hover_highlight_color', labelKey: 'settings_hover_highlight_color', type: 'color' },
|
||||
{ key: 'icon_inactive_color', labelKey: 'settings_icon_inactive_color', type: 'color' },
|
||||
{ key: 'icon_active_color', labelKey: 'settings_icon_active_color', type: 'color' },
|
||||
{ key: 'day_hover_color', labelKey: 'settings_day_hover_color', type: 'color' },
|
||||
{ key: 'day_selected_color', labelKey: 'settings_day_selected_color', type: 'color' },
|
||||
{ key: 'day_bg_color', labelKey: 'settings_day_bg_color', type: 'color' },
|
||||
{ key: 'today_bg_color', labelKey: 'settings_today_bg_color', type: 'color' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -147,6 +147,43 @@ export function applyTheme(settings) {
|
||||
|
||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || DEFAULT_COLORS.month_divider_color);
|
||||
root.style.setProperty('--month-label-color', settings.month_label_color || DEFAULT_COLORS.month_label_color);
|
||||
|
||||
// Fine-grained element colours. Each is applied ONLY when the user set an
|
||||
// explicit value; otherwise the :root default (which references a derived
|
||||
// variable) stays in effect, so the look is unchanged until customised.
|
||||
// day_selected_color / today_bg_color feed a *-base variable that CSS turns
|
||||
// into a subtle tint via color-mix; the rest are applied as-is.
|
||||
const setIf = (varName, value) => { if (value) root.style.setProperty(varName, value); };
|
||||
setIf('--hover-highlight', settings.hover_highlight_color);
|
||||
setIf('--icon-inactive-color', settings.icon_inactive_color);
|
||||
setIf('--icon-active-color', settings.icon_active_color);
|
||||
setIf('--day-hover-color', settings.day_hover_color);
|
||||
setIf('--day-selected-base', settings.day_selected_color);
|
||||
setIf('--day-bg', settings.day_bg_color);
|
||||
setIf('--today-bg-base', settings.today_bg_color);
|
||||
}
|
||||
|
||||
// Tint the favicon (and browser theme-colour) to the current primary colour so
|
||||
// the tab icon reflects the user's theme. Called at load and after saving —
|
||||
// NOT on every live keystroke. Reuses the calendar glyph from favicon.svg.
|
||||
export function applyFavicon(primaryColor) {
|
||||
const color = primaryColor || DEFAULT_COLORS.primary_color;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
|
||||
+ '<rect x="3" y="4" width="18" height="18" rx="2"/>'
|
||||
+ '<line x1="16" y1="2" x2="16" y2="6"/>'
|
||||
+ '<line x1="8" y1="2" x2="8" y2="6"/>'
|
||||
+ '<line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
const href = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
let link = document.querySelector('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = href;
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', color);
|
||||
}
|
||||
|
||||
function luminance(hex) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v82';
|
||||
export const APP_VERSION = 'v84';
|
||||
|
||||
Reference in New Issue
Block a user