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:
@@ -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"])
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
194
backend/routers/admin_router.py
Normal file
194
backend/routers/admin_router.py
Normal 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}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { api } from './api.js';
|
||||
import { initCalendar, showToast, openProfileModal } from './calendar.js';
|
||||
import { t } from './i18n.js';
|
||||
import { loadInstance } from './instance.js';
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────
|
||||
async function boot() {
|
||||
// Apply instance branding (logo/favicon/default theme) ASAP so the login and
|
||||
// setup screens are already branded. Public endpoint — no token needed.
|
||||
loadInstance();
|
||||
// Check if setup is required
|
||||
let setupRequired = false;
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { renderQuarter } from './views/quarter.js';
|
||||
import { openColorPicker } from './color-picker.js';
|
||||
import { openDatePicker, formatDtDisplay } from './date-picker.js';
|
||||
import { t, setLang, getLang } from './i18n.js';
|
||||
import { DEFAULT_COLORS, SETTING_GROUPS, SYNCABLE_KEYS, loadLocal, saveLocal, mergeEffective } from './settings-sync.js';
|
||||
import { DEFAULT_COLORS, SETTING_GROUPS, SYNCABLE_KEYS, loadLocal, saveLocal, mergeEffective, baseColor, setInstanceDefaults } from './settings-sync.js';
|
||||
import { loadInstance, reloadInstance, instanceConfig } from './instance.js';
|
||||
import { APP_VERSION } from './version.js';
|
||||
|
||||
// Version im Impressum/Sidebar sichtbar, nicht im Tab-Titel.
|
||||
@@ -123,6 +124,9 @@ export async function initCalendar() {
|
||||
api.get('/google/accounts').catch(() => []),
|
||||
api.get('/homeassistant/accounts').catch(() => []),
|
||||
]);
|
||||
// Ensure the instance default theme + branding are loaded before the first
|
||||
// applyTheme(), so per-colour fallbacks resolve to the admin default.
|
||||
await loadInstance();
|
||||
|
||||
// Per-setting sync: the server's raw response is the shared source; effective
|
||||
// settings resolve each syncable key against the account-wide sync flags and
|
||||
@@ -3359,7 +3363,7 @@ function populateSettings() {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const usersNavBtn = document.getElementById('settings-nav-users');
|
||||
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
|
||||
if (user.is_admin) loadUsers();
|
||||
if (user.is_admin) { loadUsers(); loadAdminPanel(); }
|
||||
|
||||
// Activate panel from URL or fall back to first visible
|
||||
const urlTab = readUrlState().stab;
|
||||
@@ -3408,7 +3412,7 @@ function settingRowHtml(def) {
|
||||
} else if (def.type === 'toggle') {
|
||||
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
|
||||
} else if (def.type === 'color') {
|
||||
const hex = normalizeHex(val, DEFAULT_COLORS[def.key]);
|
||||
const hex = normalizeHex(val, baseColor(def.key));
|
||||
valueHtml = `<div class="settings-color-ctl">
|
||||
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}" />
|
||||
<div class="ev-color-preview" data-sprev="${def.key}" style="background:${hex}" title="${escHtml(t('color_pick'))}"></div>
|
||||
@@ -3466,7 +3470,7 @@ function wireSettingRow(def) {
|
||||
applyTheme(state.settings);
|
||||
};
|
||||
prev.addEventListener('click', async () => {
|
||||
const picked = await openColorPicker(prev, hex.value || DEFAULT_COLORS[def.key]);
|
||||
const picked = await openColorPicker(prev, hex.value || baseColor(def.key));
|
||||
if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
|
||||
});
|
||||
hex.addEventListener('input', () => {
|
||||
@@ -3474,11 +3478,11 @@ function wireSettingRow(def) {
|
||||
if (norm) apply(norm);
|
||||
});
|
||||
hex.addEventListener('change', () => {
|
||||
const norm = normalizeHex(hex.value, DEFAULT_COLORS[def.key]);
|
||||
const norm = normalizeHex(hex.value, baseColor(def.key));
|
||||
hex.value = norm; apply(norm);
|
||||
});
|
||||
reset.addEventListener('click', () => {
|
||||
const d = DEFAULT_COLORS[def.key];
|
||||
const d = baseColor(def.key);
|
||||
hex.value = d; apply(d);
|
||||
});
|
||||
} else if (def.type === 'icon') {
|
||||
@@ -3508,7 +3512,7 @@ function readAppearanceTable() {
|
||||
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
|
||||
} else if (def.type === 'color') {
|
||||
const el = document.querySelector(`[data-shex="${def.key}"]`);
|
||||
out[def.key] = normalizeHex(el && el.value, DEFAULT_COLORS[def.key]);
|
||||
out[def.key] = normalizeHex(el && el.value, baseColor(def.key));
|
||||
} else if (def.type === 'icon') {
|
||||
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
|
||||
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
|
||||
@@ -3563,7 +3567,7 @@ function exportTheme() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${stamp}.theme`;
|
||||
a.download = `${stamp}.theme.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
@@ -4436,11 +4440,152 @@ async function loadUsers() {
|
||||
} catch (e) { /* not admin */ }
|
||||
}
|
||||
|
||||
// ── Admin: instance default theme + branding ──────────────
|
||||
let adminThemeDraft = {}; // {colorKey: hex} being edited (instance default)
|
||||
let adminPreviewActive = false; // whether the app view currently shows the draft
|
||||
|
||||
// The colour rows offered in the default-theme editor (same keys as the user table).
|
||||
function adminColorDefs() {
|
||||
return SETTING_GROUPS.flatMap(g => g.rows).filter(r => r.type === 'color');
|
||||
}
|
||||
|
||||
// Restore the admin's own (personal) theme after a live preview.
|
||||
function restoreOwnTheme() {
|
||||
adminPreviewActive = false;
|
||||
applyTheme(state.settings);
|
||||
applyFavicon(state.settings.primary_color);
|
||||
}
|
||||
|
||||
function previewAdminTheme() {
|
||||
adminPreviewActive = true;
|
||||
applyTheme(adminThemeDraft);
|
||||
applyFavicon(adminThemeDraft.primary_color);
|
||||
}
|
||||
|
||||
function renderAdminThemeGrid() {
|
||||
const grid = document.getElementById('admin-theme-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = adminColorDefs().map(def => {
|
||||
const val = adminThemeDraft[def.key] || DEFAULT_COLORS[def.key];
|
||||
return `<div class="admin-theme-row" data-arow="${def.key}">
|
||||
<span class="admin-theme-name">${escHtml(t(def.labelKey))}</span>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" class="ev-color-hex" data-ahex="${def.key}" maxlength="7" spellcheck="false" value="${val}" />
|
||||
<div class="ev-color-preview" data-aprev="${def.key}" style="background:${val}" title="${escHtml(t('color_pick'))}"></div>
|
||||
<button type="button" class="icon-btn ev-color-reset" data-areset="${def.key}" title="${escHtml(t('reset'))}">${RESET_ICON_SVG}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
adminColorDefs().forEach(def => {
|
||||
const row = grid.querySelector(`.admin-theme-row[data-arow="${def.key}"]`);
|
||||
if (!row) return;
|
||||
const hex = row.querySelector('[data-ahex]');
|
||||
const prev = row.querySelector('[data-aprev]');
|
||||
const reset = row.querySelector('[data-areset]');
|
||||
const apply = (color) => {
|
||||
adminThemeDraft[def.key] = color;
|
||||
prev.style.background = color;
|
||||
previewAdminTheme();
|
||||
};
|
||||
prev.addEventListener('click', async () => {
|
||||
const picked = await openColorPicker(prev, hex.value || DEFAULT_COLORS[def.key]);
|
||||
if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
|
||||
});
|
||||
hex.addEventListener('input', () => { const n = normalizeHex(hex.value, null); if (n) apply(n); });
|
||||
hex.addEventListener('change', () => { const n = normalizeHex(hex.value, DEFAULT_COLORS[def.key]); hex.value = n; apply(n); });
|
||||
reset.addEventListener('click', () => {
|
||||
// Reset a default-theme colour to the built-in default.
|
||||
const d = DEFAULT_COLORS[def.key];
|
||||
hex.value = d; apply(d);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdminBranding() {
|
||||
const logoPrev = document.getElementById('admin-logo-preview');
|
||||
const favPrev = document.getElementById('admin-favicon-preview');
|
||||
const ph = `<span class="admin-brand-none">${escHtml(t('admin_brand_default'))}</span>`;
|
||||
if (logoPrev) logoPrev.innerHTML = instanceConfig.has_logo ? `<img src="${instanceConfig.logo_url}" alt="">` : ph;
|
||||
if (favPrev) favPrev.innerHTML = instanceConfig.has_favicon ? `<img src="${instanceConfig.favicon_url}" alt="">` : ph;
|
||||
const logoRemove = document.getElementById('admin-logo-remove');
|
||||
const favRemove = document.getElementById('admin-favicon-remove');
|
||||
if (logoRemove) logoRemove.classList.toggle('hidden', !instanceConfig.has_logo);
|
||||
if (favRemove) favRemove.classList.toggle('hidden', !instanceConfig.has_favicon);
|
||||
}
|
||||
|
||||
// Populate the admin panel (default-theme editor + branding). Admin-only.
|
||||
function loadAdminPanel() {
|
||||
adminThemeDraft = { ...(instanceConfig.default_theme || {}) };
|
||||
renderAdminThemeGrid();
|
||||
renderAdminBranding();
|
||||
}
|
||||
|
||||
async function uploadBranding(kind, input) {
|
||||
const file = input.files && input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
await api.upload(`/instance/${kind}`, form);
|
||||
await reloadInstance(); // re-fetch + apply logo/favicon live
|
||||
renderAdminBranding();
|
||||
showToast(t('admin_branding_saved'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
}
|
||||
|
||||
async function removeBranding(kind) {
|
||||
try {
|
||||
await api.delete(`/instance/${kind}`);
|
||||
await reloadInstance();
|
||||
renderAdminBranding();
|
||||
showToast(t('admin_branding_removed'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
}
|
||||
|
||||
function bindAdminPanel() {
|
||||
const save = document.getElementById('admin-theme-save');
|
||||
if (save) save.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.put('/instance/theme', { default_theme: adminThemeDraft });
|
||||
setInstanceDefaults(adminThemeDraft);
|
||||
instanceConfig.default_theme = { ...adminThemeDraft };
|
||||
restoreOwnTheme(); // back to the admin's own view
|
||||
showToast(t('admin_theme_saved'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
});
|
||||
const discard = document.getElementById('admin-theme-discard');
|
||||
if (discard) discard.addEventListener('click', () => {
|
||||
adminThemeDraft = { ...(instanceConfig.default_theme || {}) };
|
||||
renderAdminThemeGrid();
|
||||
restoreOwnTheme();
|
||||
});
|
||||
|
||||
const bindUpload = (btnId, fileId, kind) => {
|
||||
const btn = document.getElementById(btnId);
|
||||
const file = document.getElementById(fileId);
|
||||
if (btn && file) {
|
||||
btn.addEventListener('click', () => file.click());
|
||||
file.addEventListener('change', () => uploadBranding(kind, file));
|
||||
}
|
||||
};
|
||||
bindUpload('admin-logo-upload', 'admin-logo-file', 'logo');
|
||||
bindUpload('admin-favicon-upload', 'admin-favicon-file', 'favicon');
|
||||
const logoRemove = document.getElementById('admin-logo-remove');
|
||||
if (logoRemove) logoRemove.addEventListener('click', () => removeBranding('logo'));
|
||||
const favRemove = document.getElementById('admin-favicon-remove');
|
||||
if (favRemove) favRemove.addEventListener('click', () => removeBranding('favicon'));
|
||||
}
|
||||
|
||||
function bindSettingsModal() {
|
||||
// Global "sync everything" master toggle (per-row toggles are wired per render).
|
||||
const syncAllBtn = document.getElementById('cfg-sync-all');
|
||||
if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll);
|
||||
|
||||
// Admin panel (default-theme editor + branding) — buttons bound once.
|
||||
bindAdminPanel();
|
||||
|
||||
// Theme export / import (client-side .theme files).
|
||||
const themeExportBtn = document.getElementById('cfg-theme-export');
|
||||
if (themeExportBtn) themeExportBtn.addEventListener('click', exportTheme);
|
||||
@@ -4902,6 +5047,8 @@ function closeModal(id) {
|
||||
document.getElementById(id).classList.add('hidden');
|
||||
if (id === 'modal-settings') {
|
||||
uiSettingsOpen = false;
|
||||
// If the admin was live-previewing the default theme, restore their own view.
|
||||
if (adminPreviewActive) restoreOwnTheme();
|
||||
writeUrlState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,21 @@ const translations = {
|
||||
settings_nav_calendars: 'Kalender',
|
||||
settings_nav_google: 'Google Konten',
|
||||
settings_nav_users: 'Benutzerverwaltung',
|
||||
settings_nav_admin: 'Admin',
|
||||
admin_default_theme: 'Standard-Theme',
|
||||
admin_default_theme_desc: 'Gilt für alle Nutzer ohne eigene Farbe. „Zurück" bei einer Farbe springt auf diesen Standard.',
|
||||
admin_theme_save: 'Standard speichern',
|
||||
admin_theme_discard: 'Verwerfen',
|
||||
admin_theme_saved: 'Standard-Theme gespeichert',
|
||||
admin_branding: 'Branding',
|
||||
admin_branding_desc: 'Eigenes Logo (oben links) und Favicon für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.',
|
||||
admin_logo: 'Logo',
|
||||
admin_favicon: 'Favicon',
|
||||
admin_upload: 'Hochladen',
|
||||
admin_remove: 'Entfernen',
|
||||
admin_brand_default: 'Standard',
|
||||
admin_branding_saved: 'Branding gespeichert',
|
||||
admin_branding_removed: 'Branding entfernt',
|
||||
settings_sync_all: 'Alle synchronisieren',
|
||||
settings_sync_all_desc: 'Diese Einstellungen zwischen deinen Geräten teilen',
|
||||
settings_sync_this: 'Zwischen Geräten synchronisieren',
|
||||
@@ -443,6 +458,21 @@ const translations = {
|
||||
settings_nav_calendars: 'Calendars',
|
||||
settings_nav_google: 'Google Accounts',
|
||||
settings_nav_users: 'User Management',
|
||||
settings_nav_admin: 'Admin',
|
||||
admin_default_theme: 'Default theme',
|
||||
admin_default_theme_desc: 'Applies to all users who have not set their own colour. A colour "Reset" returns to this default.',
|
||||
admin_theme_save: 'Save default',
|
||||
admin_theme_discard: 'Discard',
|
||||
admin_theme_saved: 'Default theme saved',
|
||||
admin_branding: 'Branding',
|
||||
admin_branding_desc: 'Custom logo (top-left) and favicon for the whole instance. PNG/JPEG/WebP, max 5 MB.',
|
||||
admin_logo: 'Logo',
|
||||
admin_favicon: 'Favicon',
|
||||
admin_upload: 'Upload',
|
||||
admin_remove: 'Remove',
|
||||
admin_brand_default: 'Default',
|
||||
admin_branding_saved: 'Branding saved',
|
||||
admin_branding_removed: 'Branding removed',
|
||||
settings_sync_all: 'Sync all',
|
||||
settings_sync_all_desc: 'Share these settings across your devices',
|
||||
settings_sync_this: 'Sync across devices',
|
||||
|
||||
56
frontend/js/instance.js
Normal file
56
frontend/js/instance.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Instance-wide branding + default theme (public GET /api/instance/). Loaded as
|
||||
// early as possible so the login screen is already branded. See
|
||||
// backend/routers/admin_router.py.
|
||||
|
||||
import { setInstanceDefaults } from './settings-sync.js';
|
||||
import { setCustomFavicon, applyFavicon } from './utils.js';
|
||||
|
||||
export let instanceConfig = {};
|
||||
|
||||
// Cache the bundled default logo markup so "remove logo" can restore it.
|
||||
const originalLogoHtml = {};
|
||||
|
||||
function setBrandLogo(url) {
|
||||
document.querySelectorAll('.topbar-logo, .auth-logo').forEach(el => {
|
||||
const key = el.classList.contains('topbar-logo') ? 'topbar' : 'auth';
|
||||
if (!(key in originalLogoHtml)) originalLogoHtml[key] = el.innerHTML;
|
||||
if (url) {
|
||||
const cls = key === 'topbar' ? 'topbar-logo-img' : 'auth-logo-img';
|
||||
el.innerHTML = `<img class="${cls}" src="${url}" alt="Logo">`;
|
||||
} else {
|
||||
el.innerHTML = originalLogoHtml[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applyInstanceBranding(cfg) {
|
||||
cfg = cfg || {};
|
||||
setInstanceDefaults(cfg.default_theme || {});
|
||||
setCustomFavicon(cfg.has_favicon ? cfg.favicon_url : null);
|
||||
setBrandLogo(cfg.has_logo ? cfg.logo_url : null);
|
||||
applyFavicon(); // no arg → uses instance/built-in primary as fallback
|
||||
}
|
||||
|
||||
// Memoised so boot() and initCalendar() share a single fetch; callers can await
|
||||
// it to guarantee instance defaults are set before the first applyTheme().
|
||||
let loadPromise = null;
|
||||
export function loadInstance() {
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/instance/', { headers: { Accept: 'application/json' } });
|
||||
instanceConfig = res.ok ? await res.json() : {};
|
||||
} catch (_) {
|
||||
instanceConfig = {};
|
||||
}
|
||||
applyInstanceBranding(instanceConfig);
|
||||
return instanceConfig;
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
// Force a re-fetch (after an admin changes branding/theme).
|
||||
export function reloadInstance() {
|
||||
loadPromise = null;
|
||||
return loadInstance();
|
||||
}
|
||||
@@ -92,6 +92,14 @@ export const SETTING_GROUPS = [
|
||||
// Flat list of every syncable key the web manages (table order).
|
||||
export const SYNCABLE_KEYS = SETTING_GROUPS.flatMap(g => g.rows.map(r => r.key));
|
||||
|
||||
// Instance-wide default theme set by an admin (from GET /api/instance/). It is
|
||||
// the base a user inherits and the target that a per-colour "Reset" returns to.
|
||||
// A user's own value always overrides it. See backend/routers/admin_router.py.
|
||||
export let INSTANCE_DEFAULTS = {};
|
||||
export function setInstanceDefaults(obj) { INSTANCE_DEFAULTS = obj || {}; }
|
||||
// The effective default for a colour key: admin instance default → built-in.
|
||||
export function baseColor(key) { return INSTANCE_DEFAULTS[key] || DEFAULT_COLORS[key]; }
|
||||
|
||||
const LOCAL_KEY = 'settingsLocal';
|
||||
|
||||
export function loadLocal() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_COLORS } from './settings-sync.js';
|
||||
import { DEFAULT_COLORS, INSTANCE_DEFAULTS, baseColor } from './settings-sync.js';
|
||||
|
||||
export function isToday(d) {
|
||||
const now = new Date();
|
||||
@@ -106,15 +106,18 @@ export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
|
||||
|
||||
export function applyTheme(settings) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', settings.primary_color || DEFAULT_COLORS.primary_color);
|
||||
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || DEFAULT_COLORS.primary_color, 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || DEFAULT_COLORS.accent_color);
|
||||
root.style.setProperty('--today-color', settings.today_color || DEFAULT_COLORS.today_color);
|
||||
// Fallback chain for a colour that has no per-user value: admin instance
|
||||
// default → built-in default (baseColor()).
|
||||
const primary = settings.primary_color || baseColor('primary_color');
|
||||
root.style.setProperty('--primary', primary);
|
||||
root.style.setProperty('--primary-dim', hexToRgba(primary, 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || baseColor('accent_color'));
|
||||
root.style.setProperty('--today-color', settings.today_color || baseColor('today_color'));
|
||||
|
||||
// Effektive Farben bestimmen (Override > Default).
|
||||
let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
|
||||
let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
|
||||
let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
|
||||
// Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default).
|
||||
let textColor = settings.text_color || baseColor('text_color');
|
||||
let lineColor = settings.line_color || baseColor('line_color');
|
||||
let bgColor = settings.bg_color || baseColor('bg_color');
|
||||
|
||||
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
|
||||
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
|
||||
@@ -145,43 +148,58 @@ export function applyTheme(settings) {
|
||||
const hh = settings.hour_height || 44;
|
||||
root.style.setProperty('--hour-h', hh + 'px');
|
||||
|
||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || DEFAULT_COLORS.month_divider_color);
|
||||
root.style.setProperty('--month-label-color', settings.month_label_color || DEFAULT_COLORS.month_label_color);
|
||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || baseColor('month_divider_color'));
|
||||
root.style.setProperty('--month-label-color', settings.month_label_color || baseColor('month_label_color'));
|
||||
|
||||
// Fine-grained element colours. Each is applied ONLY when the user set an
|
||||
// explicit value; otherwise the :root default (which references a derived
|
||||
// variable) stays in effect, so the look is unchanged until customised.
|
||||
// day_selected_color / today_bg_color feed a *-base variable that CSS turns
|
||||
// into a subtle tint via color-mix; the rest are applied as-is.
|
||||
const setIf = (varName, value) => { if (value) root.style.setProperty(varName, value); };
|
||||
setIf('--hover-highlight', settings.hover_highlight_color);
|
||||
setIf('--icon-inactive-color', settings.icon_inactive_color);
|
||||
setIf('--icon-active-color', settings.icon_active_color);
|
||||
setIf('--day-hover-color', settings.day_hover_color);
|
||||
setIf('--day-selected-base', settings.day_selected_color);
|
||||
setIf('--day-bg', settings.day_bg_color);
|
||||
setIf('--today-bg-base', settings.today_bg_color);
|
||||
// User value → admin instance default (if any). When neither is set the
|
||||
// property stays unset so the derived :root default keeps the current look.
|
||||
const setIf = (varName, key) => {
|
||||
const value = settings[key] || INSTANCE_DEFAULTS[key];
|
||||
if (value) root.style.setProperty(varName, value);
|
||||
};
|
||||
setIf('--hover-highlight', 'hover_highlight_color');
|
||||
setIf('--icon-inactive-color', 'icon_inactive_color');
|
||||
setIf('--icon-active-color', 'icon_active_color');
|
||||
setIf('--day-hover-color', 'day_hover_color');
|
||||
setIf('--day-selected-base', 'day_selected_color');
|
||||
setIf('--day-bg', 'day_bg_color');
|
||||
setIf('--today-bg-base', 'today_bg_color');
|
||||
}
|
||||
|
||||
// Instance custom favicon URL (set by the branding loader). When present it
|
||||
// overrides the primary-colour tinting.
|
||||
let customFaviconUrl = null;
|
||||
export function setCustomFavicon(url) { customFaviconUrl = url || null; }
|
||||
|
||||
// Tint the favicon (and browser theme-colour) to the current primary colour so
|
||||
// the tab icon reflects the user's theme. Called at load and after saving —
|
||||
// NOT on every live keystroke. Reuses the calendar glyph from favicon.svg.
|
||||
export function applyFavicon(primaryColor) {
|
||||
const color = primaryColor || DEFAULT_COLORS.primary_color;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
|
||||
+ '<rect x="3" y="4" width="18" height="18" rx="2"/>'
|
||||
+ '<line x1="16" y1="2" x2="16" y2="6"/>'
|
||||
+ '<line x1="8" y1="2" x2="8" y2="6"/>'
|
||||
+ '<line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
const href = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
const color = primaryColor || baseColor('primary_color');
|
||||
let link = document.querySelector('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v84';
|
||||
export const APP_VERSION = 'v85';
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user