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 @@
-
+
@@ -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 = `

`;
+ } 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 => {