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

@@ -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"])

View File

@@ -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).

View File

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

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);
}
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 = href;
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 => {