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))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from database import Base, engine
|
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)
|
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(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
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(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
|
# 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.
|
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||||
app.include_router(dav_router.router, tags=["dav"])
|
app.include_router(dav_router.router, tags=["dav"])
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ class UserSettings(Base):
|
|||||||
user = relationship("User", back_populates="settings")
|
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):
|
class AppPassword(Base):
|
||||||
"""Per-device app-specific password for CalDAV (Basic Auth).
|
"""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;
|
font-size: 12px; font-weight: 700; text-decoration: none;
|
||||||
}
|
}
|
||||||
.theme-io-help:hover { background: var(--hover-highlight); color: var(--text-1); }
|
.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 {
|
.settings-table-section {
|
||||||
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||||
color: var(--text-3); margin: 18px 0 6px; padding: 0 2px;
|
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 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="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" 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>
|
</nav>
|
||||||
|
|
||||||
<div class="settings-panels">
|
<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-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>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -858,7 +858,7 @@
|
|||||||
<div id="birthday-settings"></div>
|
<div id="birthday-settings"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benutzerverwaltung -->
|
<!-- Admin: Benutzerverwaltung + Standard-Theme + Branding -->
|
||||||
<div class="settings-panel" id="settings-panel-users">
|
<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>
|
<h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4>
|
||||||
<div id="users-list"></div>
|
<div id="users-list"></div>
|
||||||
@@ -877,6 +877,33 @@
|
|||||||
</label>
|
</label>
|
||||||
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
|
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div><!-- settings-panels -->
|
</div><!-- settings-panels -->
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { initCalendar, showToast, openProfileModal } from './calendar.js';
|
import { initCalendar, showToast, openProfileModal } from './calendar.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
|
import { loadInstance } from './instance.js';
|
||||||
|
|
||||||
// ── Bootstrap ─────────────────────────────────────────────
|
// ── Bootstrap ─────────────────────────────────────────────
|
||||||
async function boot() {
|
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
|
// Check if setup is required
|
||||||
let setupRequired = false;
|
let setupRequired = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { renderQuarter } from './views/quarter.js';
|
|||||||
import { openColorPicker } from './color-picker.js';
|
import { openColorPicker } from './color-picker.js';
|
||||||
import { openDatePicker, formatDtDisplay } from './date-picker.js';
|
import { openDatePicker, formatDtDisplay } from './date-picker.js';
|
||||||
import { t, setLang, getLang } from './i18n.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';
|
import { APP_VERSION } from './version.js';
|
||||||
|
|
||||||
// Version im Impressum/Sidebar sichtbar, nicht im Tab-Titel.
|
// Version im Impressum/Sidebar sichtbar, nicht im Tab-Titel.
|
||||||
@@ -123,6 +124,9 @@ export async function initCalendar() {
|
|||||||
api.get('/google/accounts').catch(() => []),
|
api.get('/google/accounts').catch(() => []),
|
||||||
api.get('/homeassistant/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
|
// 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
|
// 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 user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
const usersNavBtn = document.getElementById('settings-nav-users');
|
const usersNavBtn = document.getElementById('settings-nav-users');
|
||||||
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
|
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
|
// Activate panel from URL or fall back to first visible
|
||||||
const urlTab = readUrlState().stab;
|
const urlTab = readUrlState().stab;
|
||||||
@@ -3408,7 +3412,7 @@ function settingRowHtml(def) {
|
|||||||
} else if (def.type === 'toggle') {
|
} else if (def.type === 'toggle') {
|
||||||
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
|
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
|
||||||
} else if (def.type === 'color') {
|
} 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">
|
valueHtml = `<div class="settings-color-ctl">
|
||||||
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}" />
|
<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>
|
<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);
|
applyTheme(state.settings);
|
||||||
};
|
};
|
||||||
prev.addEventListener('click', async () => {
|
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); }
|
if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
|
||||||
});
|
});
|
||||||
hex.addEventListener('input', () => {
|
hex.addEventListener('input', () => {
|
||||||
@@ -3474,11 +3478,11 @@ function wireSettingRow(def) {
|
|||||||
if (norm) apply(norm);
|
if (norm) apply(norm);
|
||||||
});
|
});
|
||||||
hex.addEventListener('change', () => {
|
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);
|
hex.value = norm; apply(norm);
|
||||||
});
|
});
|
||||||
reset.addEventListener('click', () => {
|
reset.addEventListener('click', () => {
|
||||||
const d = DEFAULT_COLORS[def.key];
|
const d = baseColor(def.key);
|
||||||
hex.value = d; apply(d);
|
hex.value = d; apply(d);
|
||||||
});
|
});
|
||||||
} else if (def.type === 'icon') {
|
} else if (def.type === 'icon') {
|
||||||
@@ -3508,7 +3512,7 @@ function readAppearanceTable() {
|
|||||||
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
|
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
|
||||||
} else if (def.type === 'color') {
|
} else if (def.type === 'color') {
|
||||||
const el = document.querySelector(`[data-shex="${def.key}"]`);
|
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') {
|
} else if (def.type === 'icon') {
|
||||||
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
|
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
|
||||||
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
|
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
|
||||||
@@ -3563,7 +3567,7 @@ function exportTheme() {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${stamp}.theme`;
|
a.download = `${stamp}.theme.json`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
@@ -4436,11 +4440,152 @@ async function loadUsers() {
|
|||||||
} catch (e) { /* not admin */ }
|
} 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() {
|
function bindSettingsModal() {
|
||||||
// Global "sync everything" master toggle (per-row toggles are wired per render).
|
// Global "sync everything" master toggle (per-row toggles are wired per render).
|
||||||
const syncAllBtn = document.getElementById('cfg-sync-all');
|
const syncAllBtn = document.getElementById('cfg-sync-all');
|
||||||
if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll);
|
if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll);
|
||||||
|
|
||||||
|
// Admin panel (default-theme editor + branding) — buttons bound once.
|
||||||
|
bindAdminPanel();
|
||||||
|
|
||||||
// Theme export / import (client-side .theme files).
|
// Theme export / import (client-side .theme files).
|
||||||
const themeExportBtn = document.getElementById('cfg-theme-export');
|
const themeExportBtn = document.getElementById('cfg-theme-export');
|
||||||
if (themeExportBtn) themeExportBtn.addEventListener('click', exportTheme);
|
if (themeExportBtn) themeExportBtn.addEventListener('click', exportTheme);
|
||||||
@@ -4902,6 +5047,8 @@ function closeModal(id) {
|
|||||||
document.getElementById(id).classList.add('hidden');
|
document.getElementById(id).classList.add('hidden');
|
||||||
if (id === 'modal-settings') {
|
if (id === 'modal-settings') {
|
||||||
uiSettingsOpen = false;
|
uiSettingsOpen = false;
|
||||||
|
// If the admin was live-previewing the default theme, restore their own view.
|
||||||
|
if (adminPreviewActive) restoreOwnTheme();
|
||||||
writeUrlState();
|
writeUrlState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ const translations = {
|
|||||||
settings_nav_calendars: 'Kalender',
|
settings_nav_calendars: 'Kalender',
|
||||||
settings_nav_google: 'Google Konten',
|
settings_nav_google: 'Google Konten',
|
||||||
settings_nav_users: 'Benutzerverwaltung',
|
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: 'Alle synchronisieren',
|
||||||
settings_sync_all_desc: 'Diese Einstellungen zwischen deinen Geräten teilen',
|
settings_sync_all_desc: 'Diese Einstellungen zwischen deinen Geräten teilen',
|
||||||
settings_sync_this: 'Zwischen Geräten synchronisieren',
|
settings_sync_this: 'Zwischen Geräten synchronisieren',
|
||||||
@@ -443,6 +458,21 @@ const translations = {
|
|||||||
settings_nav_calendars: 'Calendars',
|
settings_nav_calendars: 'Calendars',
|
||||||
settings_nav_google: 'Google Accounts',
|
settings_nav_google: 'Google Accounts',
|
||||||
settings_nav_users: 'User Management',
|
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: 'Sync all',
|
||||||
settings_sync_all_desc: 'Share these settings across your devices',
|
settings_sync_all_desc: 'Share these settings across your devices',
|
||||||
settings_sync_this: 'Sync across 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).
|
// 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));
|
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';
|
const LOCAL_KEY = 'settingsLocal';
|
||||||
|
|
||||||
export function loadLocal() {
|
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) {
|
export function isToday(d) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -106,15 +106,18 @@ export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
|
|||||||
|
|
||||||
export function applyTheme(settings) {
|
export function applyTheme(settings) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty('--primary', settings.primary_color || DEFAULT_COLORS.primary_color);
|
// Fallback chain for a colour that has no per-user value: admin instance
|
||||||
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || DEFAULT_COLORS.primary_color, 0.15));
|
// default → built-in default (baseColor()).
|
||||||
root.style.setProperty('--accent', settings.accent_color || DEFAULT_COLORS.accent_color);
|
const primary = settings.primary_color || baseColor('primary_color');
|
||||||
root.style.setProperty('--today-color', settings.today_color || DEFAULT_COLORS.today_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).
|
// Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default).
|
||||||
let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
|
let textColor = settings.text_color || baseColor('text_color');
|
||||||
let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
|
let lineColor = settings.line_color || baseColor('line_color');
|
||||||
let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
|
let bgColor = settings.bg_color || baseColor('bg_color');
|
||||||
|
|
||||||
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
|
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
|
||||||
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
|
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
|
||||||
@@ -145,43 +148,58 @@ export function applyTheme(settings) {
|
|||||||
const hh = settings.hour_height || 44;
|
const hh = settings.hour_height || 44;
|
||||||
root.style.setProperty('--hour-h', hh + 'px');
|
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-divider-color', settings.month_divider_color || baseColor('month_divider_color'));
|
||||||
root.style.setProperty('--month-label-color', settings.month_label_color || DEFAULT_COLORS.month_label_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
|
// Fine-grained element colours. Each is applied ONLY when the user set an
|
||||||
// explicit value; otherwise the :root default (which references a derived
|
// explicit value; otherwise the :root default (which references a derived
|
||||||
// variable) stays in effect, so the look is unchanged until customised.
|
// variable) stays in effect, so the look is unchanged until customised.
|
||||||
// day_selected_color / today_bg_color feed a *-base variable that CSS turns
|
// 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.
|
// into a subtle tint via color-mix; the rest are applied as-is.
|
||||||
const setIf = (varName, value) => { if (value) root.style.setProperty(varName, value); };
|
// User value → admin instance default (if any). When neither is set the
|
||||||
setIf('--hover-highlight', settings.hover_highlight_color);
|
// property stays unset so the derived :root default keeps the current look.
|
||||||
setIf('--icon-inactive-color', settings.icon_inactive_color);
|
const setIf = (varName, key) => {
|
||||||
setIf('--icon-active-color', settings.icon_active_color);
|
const value = settings[key] || INSTANCE_DEFAULTS[key];
|
||||||
setIf('--day-hover-color', settings.day_hover_color);
|
if (value) root.style.setProperty(varName, value);
|
||||||
setIf('--day-selected-base', settings.day_selected_color);
|
};
|
||||||
setIf('--day-bg', settings.day_bg_color);
|
setIf('--hover-highlight', 'hover_highlight_color');
|
||||||
setIf('--today-bg-base', settings.today_bg_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
|
// 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 —
|
// 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.
|
// NOT on every live keystroke. Reuses the calendar glyph from favicon.svg.
|
||||||
export function applyFavicon(primaryColor) {
|
export function applyFavicon(primaryColor) {
|
||||||
const color = primaryColor || DEFAULT_COLORS.primary_color;
|
const color = primaryColor || baseColor('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);
|
|
||||||
let link = document.querySelector('link[rel="icon"]');
|
let link = document.querySelector('link[rel="icon"]');
|
||||||
if (!link) {
|
if (!link) {
|
||||||
link = document.createElement('link');
|
link = document.createElement('link');
|
||||||
link.rel = 'icon';
|
link.rel = 'icon';
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
link.type = 'image/svg+xml';
|
if (customFaviconUrl) {
|
||||||
link.href = href;
|
// Admin-uploaded favicon: use as-is (no primary-colour tinting).
|
||||||
|
link.type = 'image/png';
|
||||||
|
link.href = customFaviconUrl;
|
||||||
|
} else {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
|
||||||
|
+ '<rect x="3" y="4" width="18" height="18" rx="2"/>'
|
||||||
|
+ '<line x1="16" y1="2" x2="16" y2="6"/>'
|
||||||
|
+ '<line x1="8" y1="2" x2="8" y2="6"/>'
|
||||||
|
+ '<line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||||
|
link.type = 'image/svg+xml';
|
||||||
|
link.href = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||||
|
}
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (meta) meta.setAttribute('content', color);
|
if (meta) meta.setAttribute('content', color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// 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
|
// the entry HTML / version files). New releases take effect on the next
|
||||||
// reload, no manual SW unregister required.
|
// reload, no manual SW unregister required.
|
||||||
|
|
||||||
const CACHE_VERSION = 'calendarr-v32';
|
const CACHE_VERSION = 'calendarr-v33';
|
||||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
|
|||||||
Reference in New Issue
Block a user