feat(web): admin area with instance default theme, custom logo/favicon; .theme.json export
- Admin settings tab (admin-only) now holds user management plus a server-wide default theme editor (live preview + discard) and logo/favicon branding. - New singleton InstanceSettings model + /api/instance router (public GET for the login screen; admin-gated theme PUT and logo/favicon upload/delete, reusing the avatar PIL/validation pattern). - Instance default theme is the base users inherit and the target a per-colour "Reset" returns to (baseColor: instance default -> built-in). - Custom favicon overrides the primary-colour tinting; custom logo replaces the top-left glyph+text, hard-capped so it only scales down and never breaks layout. Branding + defaults load before login (public endpoint). - Theme export now uses the recognised .theme.json extension (old .theme still imports); import stays partial/unknown-key aware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import { api } from './api.js';
|
||||
import { initCalendar, showToast, openProfileModal } from './calendar.js';
|
||||
import { t } from './i18n.js';
|
||||
import { loadInstance } from './instance.js';
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────
|
||||
async function boot() {
|
||||
// Apply instance branding (logo/favicon/default theme) ASAP so the login and
|
||||
// setup screens are already branded. Public endpoint — no token needed.
|
||||
loadInstance();
|
||||
// Check if setup is required
|
||||
let setupRequired = false;
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,8 @@ 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 { DEFAULT_COLORS, SETTING_GROUPS, SYNCABLE_KEYS, loadLocal, saveLocal, mergeEffective, baseColor, setInstanceDefaults } from './settings-sync.js';
|
||||
import { loadInstance, reloadInstance, instanceConfig } from './instance.js';
|
||||
import { APP_VERSION } from './version.js';
|
||||
|
||||
// Version im Impressum/Sidebar sichtbar, nicht im Tab-Titel.
|
||||
@@ -123,6 +124,9 @@ export async function initCalendar() {
|
||||
api.get('/google/accounts').catch(() => []),
|
||||
api.get('/homeassistant/accounts').catch(() => []),
|
||||
]);
|
||||
// Ensure the instance default theme + branding are loaded before the first
|
||||
// applyTheme(), so per-colour fallbacks resolve to the admin default.
|
||||
await loadInstance();
|
||||
|
||||
// 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
|
||||
@@ -3359,7 +3363,7 @@ function populateSettings() {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const usersNavBtn = document.getElementById('settings-nav-users');
|
||||
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
|
||||
if (user.is_admin) loadUsers();
|
||||
if (user.is_admin) { loadUsers(); loadAdminPanel(); }
|
||||
|
||||
// Activate panel from URL or fall back to first visible
|
||||
const urlTab = readUrlState().stab;
|
||||
@@ -3408,7 +3412,7 @@ function settingRowHtml(def) {
|
||||
} 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]);
|
||||
const hex = normalizeHex(val, baseColor(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>
|
||||
@@ -3466,7 +3470,7 @@ function wireSettingRow(def) {
|
||||
applyTheme(state.settings);
|
||||
};
|
||||
prev.addEventListener('click', async () => {
|
||||
const picked = await openColorPicker(prev, hex.value || DEFAULT_COLORS[def.key]);
|
||||
const picked = await openColorPicker(prev, hex.value || baseColor(def.key));
|
||||
if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
|
||||
});
|
||||
hex.addEventListener('input', () => {
|
||||
@@ -3474,11 +3478,11 @@ function wireSettingRow(def) {
|
||||
if (norm) apply(norm);
|
||||
});
|
||||
hex.addEventListener('change', () => {
|
||||
const norm = normalizeHex(hex.value, DEFAULT_COLORS[def.key]);
|
||||
const norm = normalizeHex(hex.value, baseColor(def.key));
|
||||
hex.value = norm; apply(norm);
|
||||
});
|
||||
reset.addEventListener('click', () => {
|
||||
const d = DEFAULT_COLORS[def.key];
|
||||
const d = baseColor(def.key);
|
||||
hex.value = d; apply(d);
|
||||
});
|
||||
} else if (def.type === 'icon') {
|
||||
@@ -3508,7 +3512,7 @@ function readAppearanceTable() {
|
||||
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]);
|
||||
out[def.key] = normalizeHex(el && el.value, baseColor(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');
|
||||
@@ -3563,7 +3567,7 @@ function exportTheme() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${stamp}.theme`;
|
||||
a.download = `${stamp}.theme.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
@@ -4436,11 +4440,152 @@ async function loadUsers() {
|
||||
} catch (e) { /* not admin */ }
|
||||
}
|
||||
|
||||
// ── Admin: instance default theme + branding ──────────────
|
||||
let adminThemeDraft = {}; // {colorKey: hex} being edited (instance default)
|
||||
let adminPreviewActive = false; // whether the app view currently shows the draft
|
||||
|
||||
// The colour rows offered in the default-theme editor (same keys as the user table).
|
||||
function adminColorDefs() {
|
||||
return SETTING_GROUPS.flatMap(g => g.rows).filter(r => r.type === 'color');
|
||||
}
|
||||
|
||||
// Restore the admin's own (personal) theme after a live preview.
|
||||
function restoreOwnTheme() {
|
||||
adminPreviewActive = false;
|
||||
applyTheme(state.settings);
|
||||
applyFavicon(state.settings.primary_color);
|
||||
}
|
||||
|
||||
function previewAdminTheme() {
|
||||
adminPreviewActive = true;
|
||||
applyTheme(adminThemeDraft);
|
||||
applyFavicon(adminThemeDraft.primary_color);
|
||||
}
|
||||
|
||||
function renderAdminThemeGrid() {
|
||||
const grid = document.getElementById('admin-theme-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = adminColorDefs().map(def => {
|
||||
const val = adminThemeDraft[def.key] || DEFAULT_COLORS[def.key];
|
||||
return `<div class="admin-theme-row" data-arow="${def.key}">
|
||||
<span class="admin-theme-name">${escHtml(t(def.labelKey))}</span>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" class="ev-color-hex" data-ahex="${def.key}" maxlength="7" spellcheck="false" value="${val}" />
|
||||
<div class="ev-color-preview" data-aprev="${def.key}" style="background:${val}" title="${escHtml(t('color_pick'))}"></div>
|
||||
<button type="button" class="icon-btn ev-color-reset" data-areset="${def.key}" title="${escHtml(t('reset'))}">${RESET_ICON_SVG}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
adminColorDefs().forEach(def => {
|
||||
const row = grid.querySelector(`.admin-theme-row[data-arow="${def.key}"]`);
|
||||
if (!row) return;
|
||||
const hex = row.querySelector('[data-ahex]');
|
||||
const prev = row.querySelector('[data-aprev]');
|
||||
const reset = row.querySelector('[data-areset]');
|
||||
const apply = (color) => {
|
||||
adminThemeDraft[def.key] = color;
|
||||
prev.style.background = color;
|
||||
previewAdminTheme();
|
||||
};
|
||||
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 n = normalizeHex(hex.value, null); if (n) apply(n); });
|
||||
hex.addEventListener('change', () => { const n = normalizeHex(hex.value, DEFAULT_COLORS[def.key]); hex.value = n; apply(n); });
|
||||
reset.addEventListener('click', () => {
|
||||
// Reset a default-theme colour to the built-in default.
|
||||
const d = DEFAULT_COLORS[def.key];
|
||||
hex.value = d; apply(d);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdminBranding() {
|
||||
const logoPrev = document.getElementById('admin-logo-preview');
|
||||
const favPrev = document.getElementById('admin-favicon-preview');
|
||||
const ph = `<span class="admin-brand-none">${escHtml(t('admin_brand_default'))}</span>`;
|
||||
if (logoPrev) logoPrev.innerHTML = instanceConfig.has_logo ? `<img src="${instanceConfig.logo_url}" alt="">` : ph;
|
||||
if (favPrev) favPrev.innerHTML = instanceConfig.has_favicon ? `<img src="${instanceConfig.favicon_url}" alt="">` : ph;
|
||||
const logoRemove = document.getElementById('admin-logo-remove');
|
||||
const favRemove = document.getElementById('admin-favicon-remove');
|
||||
if (logoRemove) logoRemove.classList.toggle('hidden', !instanceConfig.has_logo);
|
||||
if (favRemove) favRemove.classList.toggle('hidden', !instanceConfig.has_favicon);
|
||||
}
|
||||
|
||||
// Populate the admin panel (default-theme editor + branding). Admin-only.
|
||||
function loadAdminPanel() {
|
||||
adminThemeDraft = { ...(instanceConfig.default_theme || {}) };
|
||||
renderAdminThemeGrid();
|
||||
renderAdminBranding();
|
||||
}
|
||||
|
||||
async function uploadBranding(kind, input) {
|
||||
const file = input.files && input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
await api.upload(`/instance/${kind}`, form);
|
||||
await reloadInstance(); // re-fetch + apply logo/favicon live
|
||||
renderAdminBranding();
|
||||
showToast(t('admin_branding_saved'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
}
|
||||
|
||||
async function removeBranding(kind) {
|
||||
try {
|
||||
await api.delete(`/instance/${kind}`);
|
||||
await reloadInstance();
|
||||
renderAdminBranding();
|
||||
showToast(t('admin_branding_removed'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
}
|
||||
|
||||
function bindAdminPanel() {
|
||||
const save = document.getElementById('admin-theme-save');
|
||||
if (save) save.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.put('/instance/theme', { default_theme: adminThemeDraft });
|
||||
setInstanceDefaults(adminThemeDraft);
|
||||
instanceConfig.default_theme = { ...adminThemeDraft };
|
||||
restoreOwnTheme(); // back to the admin's own view
|
||||
showToast(t('admin_theme_saved'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
});
|
||||
const discard = document.getElementById('admin-theme-discard');
|
||||
if (discard) discard.addEventListener('click', () => {
|
||||
adminThemeDraft = { ...(instanceConfig.default_theme || {}) };
|
||||
renderAdminThemeGrid();
|
||||
restoreOwnTheme();
|
||||
});
|
||||
|
||||
const bindUpload = (btnId, fileId, kind) => {
|
||||
const btn = document.getElementById(btnId);
|
||||
const file = document.getElementById(fileId);
|
||||
if (btn && file) {
|
||||
btn.addEventListener('click', () => file.click());
|
||||
file.addEventListener('change', () => uploadBranding(kind, file));
|
||||
}
|
||||
};
|
||||
bindUpload('admin-logo-upload', 'admin-logo-file', 'logo');
|
||||
bindUpload('admin-favicon-upload', 'admin-favicon-file', 'favicon');
|
||||
const logoRemove = document.getElementById('admin-logo-remove');
|
||||
if (logoRemove) logoRemove.addEventListener('click', () => removeBranding('logo'));
|
||||
const favRemove = document.getElementById('admin-favicon-remove');
|
||||
if (favRemove) favRemove.addEventListener('click', () => removeBranding('favicon'));
|
||||
}
|
||||
|
||||
function bindSettingsModal() {
|
||||
// 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);
|
||||
|
||||
// Admin panel (default-theme editor + branding) — buttons bound once.
|
||||
bindAdminPanel();
|
||||
|
||||
// Theme export / import (client-side .theme files).
|
||||
const themeExportBtn = document.getElementById('cfg-theme-export');
|
||||
if (themeExportBtn) themeExportBtn.addEventListener('click', exportTheme);
|
||||
@@ -4902,6 +5047,8 @@ function closeModal(id) {
|
||||
document.getElementById(id).classList.add('hidden');
|
||||
if (id === 'modal-settings') {
|
||||
uiSettingsOpen = false;
|
||||
// If the admin was live-previewing the default theme, restore their own view.
|
||||
if (adminPreviewActive) restoreOwnTheme();
|
||||
writeUrlState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,21 @@ const translations = {
|
||||
settings_nav_calendars: 'Kalender',
|
||||
settings_nav_google: 'Google Konten',
|
||||
settings_nav_users: 'Benutzerverwaltung',
|
||||
settings_nav_admin: 'Admin',
|
||||
admin_default_theme: 'Standard-Theme',
|
||||
admin_default_theme_desc: 'Gilt für alle Nutzer ohne eigene Farbe. „Zurück" bei einer Farbe springt auf diesen Standard.',
|
||||
admin_theme_save: 'Standard speichern',
|
||||
admin_theme_discard: 'Verwerfen',
|
||||
admin_theme_saved: 'Standard-Theme gespeichert',
|
||||
admin_branding: 'Branding',
|
||||
admin_branding_desc: 'Eigenes Logo (oben links) und Favicon für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.',
|
||||
admin_logo: 'Logo',
|
||||
admin_favicon: 'Favicon',
|
||||
admin_upload: 'Hochladen',
|
||||
admin_remove: 'Entfernen',
|
||||
admin_brand_default: 'Standard',
|
||||
admin_branding_saved: 'Branding gespeichert',
|
||||
admin_branding_removed: 'Branding entfernt',
|
||||
settings_sync_all: 'Alle synchronisieren',
|
||||
settings_sync_all_desc: 'Diese Einstellungen zwischen deinen Geräten teilen',
|
||||
settings_sync_this: 'Zwischen Geräten synchronisieren',
|
||||
@@ -443,6 +458,21 @@ const translations = {
|
||||
settings_nav_calendars: 'Calendars',
|
||||
settings_nav_google: 'Google Accounts',
|
||||
settings_nav_users: 'User Management',
|
||||
settings_nav_admin: 'Admin',
|
||||
admin_default_theme: 'Default theme',
|
||||
admin_default_theme_desc: 'Applies to all users who have not set their own colour. A colour "Reset" returns to this default.',
|
||||
admin_theme_save: 'Save default',
|
||||
admin_theme_discard: 'Discard',
|
||||
admin_theme_saved: 'Default theme saved',
|
||||
admin_branding: 'Branding',
|
||||
admin_branding_desc: 'Custom logo (top-left) and favicon for the whole instance. PNG/JPEG/WebP, max 5 MB.',
|
||||
admin_logo: 'Logo',
|
||||
admin_favicon: 'Favicon',
|
||||
admin_upload: 'Upload',
|
||||
admin_remove: 'Remove',
|
||||
admin_brand_default: 'Default',
|
||||
admin_branding_saved: 'Branding saved',
|
||||
admin_branding_removed: 'Branding removed',
|
||||
settings_sync_all: 'Sync all',
|
||||
settings_sync_all_desc: 'Share these settings across your devices',
|
||||
settings_sync_this: 'Sync across devices',
|
||||
|
||||
56
frontend/js/instance.js
Normal file
56
frontend/js/instance.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Instance-wide branding + default theme (public GET /api/instance/). Loaded as
|
||||
// early as possible so the login screen is already branded. See
|
||||
// backend/routers/admin_router.py.
|
||||
|
||||
import { setInstanceDefaults } from './settings-sync.js';
|
||||
import { setCustomFavicon, applyFavicon } from './utils.js';
|
||||
|
||||
export let instanceConfig = {};
|
||||
|
||||
// Cache the bundled default logo markup so "remove logo" can restore it.
|
||||
const originalLogoHtml = {};
|
||||
|
||||
function setBrandLogo(url) {
|
||||
document.querySelectorAll('.topbar-logo, .auth-logo').forEach(el => {
|
||||
const key = el.classList.contains('topbar-logo') ? 'topbar' : 'auth';
|
||||
if (!(key in originalLogoHtml)) originalLogoHtml[key] = el.innerHTML;
|
||||
if (url) {
|
||||
const cls = key === 'topbar' ? 'topbar-logo-img' : 'auth-logo-img';
|
||||
el.innerHTML = `<img class="${cls}" src="${url}" alt="Logo">`;
|
||||
} else {
|
||||
el.innerHTML = originalLogoHtml[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applyInstanceBranding(cfg) {
|
||||
cfg = cfg || {};
|
||||
setInstanceDefaults(cfg.default_theme || {});
|
||||
setCustomFavicon(cfg.has_favicon ? cfg.favicon_url : null);
|
||||
setBrandLogo(cfg.has_logo ? cfg.logo_url : null);
|
||||
applyFavicon(); // no arg → uses instance/built-in primary as fallback
|
||||
}
|
||||
|
||||
// Memoised so boot() and initCalendar() share a single fetch; callers can await
|
||||
// it to guarantee instance defaults are set before the first applyTheme().
|
||||
let loadPromise = null;
|
||||
export function loadInstance() {
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/instance/', { headers: { Accept: 'application/json' } });
|
||||
instanceConfig = res.ok ? await res.json() : {};
|
||||
} catch (_) {
|
||||
instanceConfig = {};
|
||||
}
|
||||
applyInstanceBranding(instanceConfig);
|
||||
return instanceConfig;
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
// Force a re-fetch (after an admin changes branding/theme).
|
||||
export function reloadInstance() {
|
||||
loadPromise = null;
|
||||
return loadInstance();
|
||||
}
|
||||
@@ -92,6 +92,14 @@ export const SETTING_GROUPS = [
|
||||
// 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));
|
||||
|
||||
// Instance-wide default theme set by an admin (from GET /api/instance/). It is
|
||||
// the base a user inherits and the target that a per-colour "Reset" returns to.
|
||||
// A user's own value always overrides it. See backend/routers/admin_router.py.
|
||||
export let INSTANCE_DEFAULTS = {};
|
||||
export function setInstanceDefaults(obj) { INSTANCE_DEFAULTS = obj || {}; }
|
||||
// The effective default for a colour key: admin instance default → built-in.
|
||||
export function baseColor(key) { return INSTANCE_DEFAULTS[key] || DEFAULT_COLORS[key]; }
|
||||
|
||||
const LOCAL_KEY = 'settingsLocal';
|
||||
|
||||
export function loadLocal() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_COLORS } from './settings-sync.js';
|
||||
import { DEFAULT_COLORS, INSTANCE_DEFAULTS, baseColor } from './settings-sync.js';
|
||||
|
||||
export function isToday(d) {
|
||||
const now = new Date();
|
||||
@@ -106,15 +106,18 @@ export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
|
||||
|
||||
export function applyTheme(settings) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', settings.primary_color || DEFAULT_COLORS.primary_color);
|
||||
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || DEFAULT_COLORS.primary_color, 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || DEFAULT_COLORS.accent_color);
|
||||
root.style.setProperty('--today-color', settings.today_color || DEFAULT_COLORS.today_color);
|
||||
// Fallback chain for a colour that has no per-user value: admin instance
|
||||
// default → built-in default (baseColor()).
|
||||
const primary = settings.primary_color || baseColor('primary_color');
|
||||
root.style.setProperty('--primary', primary);
|
||||
root.style.setProperty('--primary-dim', hexToRgba(primary, 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || baseColor('accent_color'));
|
||||
root.style.setProperty('--today-color', settings.today_color || baseColor('today_color'));
|
||||
|
||||
// Effektive Farben bestimmen (Override > Default).
|
||||
let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
|
||||
let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
|
||||
let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
|
||||
// Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default).
|
||||
let textColor = settings.text_color || baseColor('text_color');
|
||||
let lineColor = settings.line_color || baseColor('line_color');
|
||||
let bgColor = settings.bg_color || baseColor('bg_color');
|
||||
|
||||
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
|
||||
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
|
||||
@@ -145,43 +148,58 @@ export function applyTheme(settings) {
|
||||
const hh = settings.hour_height || 44;
|
||||
root.style.setProperty('--hour-h', hh + 'px');
|
||||
|
||||
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);
|
||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || baseColor('month_divider_color'));
|
||||
root.style.setProperty('--month-label-color', settings.month_label_color || baseColor('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);
|
||||
// User value → admin instance default (if any). When neither is set the
|
||||
// property stays unset so the derived :root default keeps the current look.
|
||||
const setIf = (varName, key) => {
|
||||
const value = settings[key] || INSTANCE_DEFAULTS[key];
|
||||
if (value) root.style.setProperty(varName, value);
|
||||
};
|
||||
setIf('--hover-highlight', 'hover_highlight_color');
|
||||
setIf('--icon-inactive-color', 'icon_inactive_color');
|
||||
setIf('--icon-active-color', 'icon_active_color');
|
||||
setIf('--day-hover-color', 'day_hover_color');
|
||||
setIf('--day-selected-base', 'day_selected_color');
|
||||
setIf('--day-bg', 'day_bg_color');
|
||||
setIf('--today-bg-base', 'today_bg_color');
|
||||
}
|
||||
|
||||
// Instance custom favicon URL (set by the branding loader). When present it
|
||||
// overrides the primary-colour tinting.
|
||||
let customFaviconUrl = null;
|
||||
export function setCustomFavicon(url) { customFaviconUrl = url || null; }
|
||||
|
||||
// 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);
|
||||
const color = primaryColor || baseColor('primary_color');
|
||||
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;
|
||||
if (customFaviconUrl) {
|
||||
// Admin-uploaded favicon: use as-is (no primary-colour tinting).
|
||||
link.type = 'image/png';
|
||||
link.href = customFaviconUrl;
|
||||
} else {
|
||||
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>';
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
}
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', color);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v84';
|
||||
export const APP_VERSION = 'v85';
|
||||
|
||||
Reference in New Issue
Block a user