diff --git a/backend/main.py b/backend/main.py index 5686d18..5633848 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate" sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine -from routers import auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router +from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router logging.basicConfig(level=logging.INFO) @@ -395,6 +395,7 @@ app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) +app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"]) # CalDAV publishing lives at root scope (no /api prefix) and must be registered # before the SPA catch-all so /dav/... isn't swallowed by the index fallback. app.include_router(dav_router.router, tags=["dav"]) diff --git a/backend/models.py b/backend/models.py index a83c40e..460964b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -139,6 +139,20 @@ class UserSettings(Base): user = relationship("User", back_populates="settings") +class InstanceSettings(Base): + """Server-wide (singleton, id=1) branding + default theme set by an admin. + Applies to everyone; a user's own settings still override the default theme.""" + __tablename__ = "instance_settings" + + id = Column(Integer, primary_key=True) # always 1 + # JSON {colorKey: "#RRGGBB"} — the instance default theme. Empty/NULL = use the + # client's built-in defaults. A user's own colour wins over this. + default_theme = Column(Text, nullable=True) + # Uploaded branding files (stored under DATA_DIR/branding). NULL = use bundled. + logo_filename = Column(String(255), nullable=True) + favicon_filename = Column(String(255), nullable=True) + + class AppPassword(Base): """Per-device app-specific password for CalDAV (Basic Auth). diff --git a/backend/routers/admin_router.py b/backend/routers/admin_router.py new file mode 100644 index 0000000..12596bd --- /dev/null +++ b/backend/routers/admin_router.py @@ -0,0 +1,194 @@ +"""Instance-wide (singleton) settings: an admin-defined default theme plus a +custom logo and favicon. The GET endpoint is public (needed on the login screen, +before auth); all writes require admin. Branding files are stored under +DATA_DIR/branding and served via FileResponse, mirroring the avatar pattern.""" + +import io +import json +import re +from typing import Optional + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import FileResponse +from PIL import Image +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import models +from auth import get_current_admin +from database import DATA_DIR, get_db + +router = APIRouter() + +BRANDING_DIR = DATA_DIR / "branding" +BRANDING_DIR.mkdir(parents=True, exist_ok=True) +MAX_BRANDING_SIZE = 5 * 1024 * 1024 # 5 MB +ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"} + +# Colour keys an admin may set as the instance default theme. Keep in sync with +# the client's DEFAULT_COLORS / DEFAULT_SYNC colour keys (settings-sync.js). +THEME_COLOR_KEYS = { + "primary_color", "accent_color", "today_color", "text_color", "bg_color", + "line_color", "surface_color", "month_divider_color", "month_label_color", + "hover_highlight_color", "icon_inactive_color", "icon_active_color", + "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", +} +HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$") + + +def _get_or_create(db: Session) -> models.InstanceSettings: + inst = db.query(models.InstanceSettings).filter(models.InstanceSettings.id == 1).first() + if not inst: + inst = models.InstanceSettings(id=1) + db.add(inst) + db.commit() + db.refresh(inst) + return inst + + +def _mtime(filename: Optional[str]) -> int: + if not filename: + return 0 + p = BRANDING_DIR / filename + try: + return int(p.stat().st_mtime) + except OSError: + return 0 + + +def _public_dict(inst: models.InstanceSettings) -> dict: + theme = {} + if inst.default_theme: + try: + theme = json.loads(inst.default_theme) or {} + except (ValueError, TypeError): + theme = {} + has_logo = bool(inst.logo_filename) and (BRANDING_DIR / (inst.logo_filename or "")).exists() + has_favicon = bool(inst.favicon_filename) and (BRANDING_DIR / (inst.favicon_filename or "")).exists() + return { + "default_theme": theme, + "has_logo": has_logo, + "has_favicon": has_favicon, + # Cache-busted URLs so a freshly uploaded asset is fetched immediately. + "logo_url": f"/api/instance/logo?v={_mtime(inst.logo_filename)}" if has_logo else None, + "favicon_url": f"/api/instance/favicon?v={_mtime(inst.favicon_filename)}" if has_favicon else None, + } + + +# ── Public read ─────────────────────────────────────────── +@router.get("/") +def get_instance(db: Session = Depends(get_db)): + return _public_dict(_get_or_create(db)) + + +@router.get("/logo") +def get_logo(db: Session = Depends(get_db)): + inst = _get_or_create(db) + if not inst.logo_filename: + raise HTTPException(404, "No logo") + path = BRANDING_DIR / inst.logo_filename + if not path.exists(): + raise HTTPException(404, "No logo") + return FileResponse(str(path), headers={"Cache-Control": "no-cache"}) + + +@router.get("/favicon") +def get_favicon(db: Session = Depends(get_db)): + inst = _get_or_create(db) + if not inst.favicon_filename: + raise HTTPException(404, "No favicon") + path = BRANDING_DIR / inst.favicon_filename + if not path.exists(): + raise HTTPException(404, "No favicon") + return FileResponse(str(path), headers={"Cache-Control": "no-cache"}) + + +# ── Admin writes ────────────────────────────────────────── +class ThemeUpdate(BaseModel): + default_theme: dict # {colorKey: "#RRGGBB"}; empty = reset to built-in + + +@router.put("/theme") +def set_default_theme( + data: ThemeUpdate, + db: Session = Depends(get_db), + admin: models.User = Depends(get_current_admin), +): + # Keep only known colour keys with valid hex values. + clean = { + k: v.upper() + for k, v in (data.default_theme or {}).items() + if k in THEME_COLOR_KEYS and isinstance(v, str) and HEX_RE.match(v) + } + inst = _get_or_create(db) + inst.default_theme = json.dumps(clean) if clean else None + db.commit() + return {"ok": True, "default_theme": clean} + + +async def _save_branding(file: UploadFile, kind: str) -> str: + """Validate + normalise an uploaded image and store it. Returns the filename.""" + if file.content_type not in ALLOWED_TYPES: + raise HTTPException(400, "Only JPEG, PNG or WebP allowed") + raw = await file.read() + if len(raw) > MAX_BRANDING_SIZE: + raise HTTPException(400, "File too large (max 5 MB)") + try: + img = Image.open(io.BytesIO(raw)).convert("RGBA") + except Exception: + raise HTTPException(400, "Invalid image") + if kind == "favicon": + img = img.resize((128, 128), Image.LANCZOS) + else: # logo: keep aspect ratio, cap the longest edge at 256px + img.thumbnail((256, 256), Image.LANCZOS) + filename = f"{kind}.png" + img.save(str(BRANDING_DIR / filename), "PNG") + return filename + + +@router.post("/logo") +async def upload_logo( + file: UploadFile = File(...), + db: Session = Depends(get_db), + admin: models.User = Depends(get_current_admin), +): + inst = _get_or_create(db) + inst.logo_filename = await _save_branding(file, "logo") + db.commit() + return {"ok": True} + + +@router.delete("/logo") +def delete_logo(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)): + inst = _get_or_create(db) + if inst.logo_filename: + p = BRANDING_DIR / inst.logo_filename + if p.exists(): + p.unlink() + inst.logo_filename = None + db.commit() + return {"ok": True} + + +@router.post("/favicon") +async def upload_favicon( + file: UploadFile = File(...), + db: Session = Depends(get_db), + admin: models.User = Depends(get_current_admin), +): + inst = _get_or_create(db) + inst.favicon_filename = await _save_branding(file, "favicon") + db.commit() + return {"ok": True} + + +@router.delete("/favicon") +def delete_favicon(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)): + inst = _get_or_create(db) + if inst.favicon_filename: + p = BRANDING_DIR / inst.favicon_filename + if p.exists(): + p.unlink() + inst.favicon_filename = None + db.commit() + return {"ok": True} diff --git a/frontend/css/app.css b/frontend/css/app.css index 7ce1b6b..de2b3e4 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -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; diff --git a/frontend/index.html b/frontend/index.html index 575acb7..267614b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -759,7 +759,7 @@ - +
@@ -837,7 +837,7 @@ ? - +
@@ -858,7 +858,7 @@
- +

Benutzerverwaltung Admin

@@ -877,6 +877,33 @@
+ + +

Standard-Theme

+

Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.

+
+
+ + +
+ + +

Branding

+

Eigenes Logo (oben links) und Favicon (Tab-Symbol) für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.

+
+ Logo +
+ + + +
+
+ Favicon +
+ + + +
diff --git a/frontend/js/app.js b/frontend/js/app.js index 5b1f26c..b49d222 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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 { diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 7c9412f..a63006c 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -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 = ``; } else if (def.type === 'color') { - const hex = normalizeHex(val, DEFAULT_COLORS[def.key]); + const hex = normalizeHex(val, baseColor(def.key)); valueHtml = `
@@ -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 `
+ ${escHtml(t(def.labelKey))} +
+ +
+ +
+
`; + }).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 = `${escHtml(t('admin_brand_default'))}`; + if (logoPrev) logoPrev.innerHTML = instanceConfig.has_logo ? `` : ph; + if (favPrev) favPrev.innerHTML = instanceConfig.has_favicon ? `` : 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(); } } diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index aeabd7b..c4713b0 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -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', diff --git a/frontend/js/instance.js b/frontend/js/instance.js new file mode 100644 index 0000000..5bb0cf7 --- /dev/null +++ b/frontend/js/instance.js @@ -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 = `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(); +} diff --git a/frontend/js/settings-sync.js b/frontend/js/settings-sync.js index 7513ca9..c20d007 100644 --- a/frontend/js/settings-sync.js +++ b/frontend/js/settings-sync.js @@ -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() { diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 882a7d4..972d550 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -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 = `` - + '' - + '' - + '' - + ''; - 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 = `` + + '' + + '' + + '' + + ''; + 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); } diff --git a/frontend/js/version.js b/frontend/js/version.js index 174046b..b4a4318 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v84'; +export const APP_VERSION = 'v85'; diff --git a/frontend/sw.js b/frontend/sw.js index 293c635..ecb6f52 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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 => {