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:
Scarriffle
2026-07-20 14:19:15 +02:00
parent 6316ed3a6b
commit cea96660d9
13 changed files with 561 additions and 42 deletions

View File

@@ -1390,6 +1390,26 @@ a { color: var(--primary); text-decoration: none; }
font-size: 12px; font-weight: 700; text-decoration: none;
}
.theme-io-help:hover { background: var(--hover-highlight); color: var(--text-1); }
/* Custom instance logo (replaces the glyph+text). Hard-capped so it can only
scale DOWN and never pushes the topbar/auth layout. */
.topbar-logo-img { max-height: 40px; max-width: 150px; width: auto; height: auto; object-fit: contain; display: block; }
.auth-logo-img { max-height: 56px; max-width: 220px; width: auto; height: auto; object-fit: contain; display: block; }
/* Admin panel: default-theme editor + branding */
.admin-theme-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.admin-theme-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
.admin-theme-name { font-size: 13px; color: var(--text-2); }
.admin-theme-actions { display: flex; gap: 8px; margin-top: 14px; }
.admin-brand-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.admin-brand-label { width: 70px; font-size: 13px; color: var(--text-2); }
.admin-brand-preview {
width: 48px; height: 48px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg-hover); border-radius: var(--radius-sm); overflow: hidden;
}
.admin-brand-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
.admin-brand-none { font-size: 10px; color: var(--text-3); text-align: center; padding: 2px; }
.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;

View File

@@ -759,7 +759,7 @@
<button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button>
<button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button>
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_admin">Admin</button>
</nav>
<div class="settings-panels">
@@ -837,7 +837,7 @@
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-export" data-i18n="theme_export">Theme exportieren</button>
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-import" data-i18n="theme_import">Theme importieren</button>
<a class="theme-io-help" id="cfg-theme-help" href="https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md" target="_blank" rel="noopener noreferrer" data-i18n-title="theme_docs_hint" title="Erklärung der Theme-Parameter">?</a>
<input type="file" id="cfg-theme-file" accept=".theme,application/json,.json" hidden />
<input type="file" id="cfg-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
</div>
</div>
@@ -858,7 +858,7 @@
<div id="birthday-settings"></div>
</div>
<!-- Benutzerverwaltung -->
<!-- Admin: Benutzerverwaltung + Standard-Theme + Branding -->
<div class="settings-panel" id="settings-panel-users">
<h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4>
<div id="users-list"></div>
@@ -877,6 +877,33 @@
</label>
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
</div>
<!-- Instanz-Standardtheme -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
<div id="admin-theme-grid" class="admin-theme-grid"></div>
<div class="admin-theme-actions">
<button class="btn btn-primary btn-sm" id="admin-theme-save" data-i18n="admin_theme_save">Standard speichern</button>
<button class="btn btn-ghost btn-sm" id="admin-theme-discard" data-i18n="admin_theme_discard">Verwerfen</button>
</div>
<!-- Branding: Logo & Favicon -->
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_branding">Branding</h4>
<p class="panel-desc" data-i18n="admin_branding_desc">Eigenes Logo (oben links) und Favicon (Tab-Symbol) für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.</p>
<div class="admin-brand-row">
<span class="admin-brand-label" data-i18n="admin_logo">Logo</span>
<div class="admin-brand-preview" id="admin-logo-preview"></div>
<button class="btn btn-secondary btn-sm" id="admin-logo-upload" data-i18n="admin_upload">Hochladen</button>
<button class="btn btn-ghost btn-sm" id="admin-logo-remove" data-i18n="admin_remove">Entfernen</button>
<input type="file" id="admin-logo-file" accept="image/png,image/jpeg,image/webp" hidden />
</div>
<div class="admin-brand-row">
<span class="admin-brand-label" data-i18n="admin_favicon">Favicon</span>
<div class="admin-brand-preview" id="admin-favicon-preview"></div>
<button class="btn btn-secondary btn-sm" id="admin-favicon-upload" data-i18n="admin_upload">Hochladen</button>
<button class="btn btn-ghost btn-sm" id="admin-favicon-remove" data-i18n="admin_remove">Entfernen</button>
<input type="file" id="admin-favicon-file" accept="image/png,image/jpeg,image/webp" hidden />
</div>
</div>
</div><!-- settings-panels -->

View File

@@ -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 {

View File

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

View File

@@ -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
View 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();
}

View File

@@ -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() {

View File

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

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v84';
export const APP_VERSION = 'v85';

View File

@@ -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-v32';
const CACHE_VERSION = 'calendarr-v33';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {