From 8f9eafe56183d4c7cebc462b48706d334ab88d2c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 19 May 2026 09:49:45 +0200 Subject: [PATCH] feat(settings): Schriftfarbe, Linienfarbe und Hintergrundfarbe per Color-Picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die bisherigen Stufen-Wähler ("Dunkel/Mittel/Hell/Maximum" und "Kaum/Subtil/Normal/Stark") für Schrift- bzw. Linienkontrast sind durch echte Hex-Color-Picker ersetzt. Zusätzlich kann jetzt auch die Hintergrundfarbe der Seite frei gewählt werden. Wenn ein Override gesetzt ist: - text_color → setzt --text-1 direkt, --text-2/--text-3 werden daraus per shadeHex(-0.25 / -0.55) abgeleitet, damit der Hue passt - line_color → setzt --border, --border-light wird leicht abgedunkelt - bg_color → setzt --bg-app, daraus werden Topbar/Sidebar/Surface/ Hover/Active per shadeHex(+0.10…+0.40) konsistent hochskaliert Per "Reset"-Knopf wird der Override geleert und die alte Stufen-Logik (falls noch vorhanden) bzw. der Default-Theme greift wieder. Backend: - 3 neue nullable VARCHAR(7)-Spalten in user_settings (text_color, line_color, bg_color) inkl. Migrationen in main.py - settings_router nutzt model_dump(exclude_unset=True) und respektiert explizite null-Werte nur für diese 3 Override-Felder, damit Reset funktioniert Auch enthalten: Auflösen der Merge-Konflikte in sw.js, index.html, version.js (HEAD-Stand v17 behalten) und Bump auf v18. --- backend/main.py | 18 ++++++ backend/models.py | 3 + backend/routers/settings_router.py | 18 +++++- frontend/index.html | 96 +++++++++--------------------- frontend/js/calendar.js | 54 ++++++++++++++++- frontend/js/i18n.js | 8 +++ frontend/js/utils.js | 68 ++++++++++++++++++--- frontend/js/version.js | 6 +- frontend/sw.js | 86 +------------------------- 9 files changed, 187 insertions(+), 170 deletions(-) diff --git a/backend/main.py b/backend/main.py index 5225ef6..3312035 100644 --- a/backend/main.py +++ b/backend/main.py @@ -114,6 +114,24 @@ def _migrate(): except Exception: pass + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) diff --git a/backend/models.py b/backend/models.py index 1892076..18f6cc7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -84,6 +84,9 @@ class UserSettings(Base): language = Column(String(5), default="de") month_divider_color = Column(String(7), default="#7090c0") month_label_color = Column(String(7), default="#7090c0") + text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast) + line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast) + bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default) user = relationship("User", back_populates="settings") diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index a82ecb0..b5c639d 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -24,6 +24,9 @@ class SettingsUpdate(BaseModel): language: Optional[str] = None month_divider_color: Optional[str] = None month_label_color: Optional[str] = None + text_color: Optional[str] = None + line_color: Optional[str] = None + bg_color: Optional[str] = None def _settings_dict(s: models.UserSettings) -> dict: @@ -40,6 +43,9 @@ def _settings_dict(s: models.UserSettings) -> dict: "language": s.language or "de", "month_divider_color": s.month_divider_color or "#7090c0", "month_label_color": s.month_label_color or "#7090c0", + "text_color": s.text_color, + "line_color": s.line_color, + "bg_color": s.bg_color, } @@ -76,8 +82,16 @@ def update_settings( settings = models.UserSettings(user_id=current_user.id) db.add(settings) - for field, value in data.model_dump(exclude_none=True).items(): - setattr(settings, field, value) + # For these three override colours, an explicit null is meaningful + # ("reset to default") and must be persisted as NULL. All other fields + # keep the previous behaviour where a null/missing value is ignored. + NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color"} + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if field in NULLABLE_OVERRIDES: + setattr(settings, field, value or None) + elif value is not None: + setattr(settings, field, value) db.commit() return {"ok": True} diff --git a/frontend/index.html b/frontend/index.html index 8526ce5..827f1eb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,14 +1,10 @@ - + -<<<<<<< HEAD - Calendarr v17 -======= - Calendarr v11 ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 + Calendarr v18 @@ -84,11 +80,7 @@ -<<<<<<< HEAD - -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 + @@ -207,11 +199,7 @@
-<<<<<<< HEAD - -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 + @@ -247,11 +235,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -259,11 +243,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -273,11 +253,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -285,11 +261,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -339,11 +311,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -407,7 +375,6 @@ -

Schriftkontrast

-

Helligkeit der Beschriftungen und Texte

-
- - - - +
+ +
+ +
+ +
- -

Linienkontrast

-

Sichtbarkeit von Trennlinien und Rahmen

-
- - - - +
+ +
+ +
+ +
+
+
+ +
+ +
+ +

Kalenderansicht

@@ -933,11 +895,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index abe53c4..10f07e7 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -2045,6 +2045,24 @@ function openSettingsModal() { document.getElementById(id + '-hex').value = val.toUpperCase(); document.getElementById(id + '-preview').style.background = val; }); + + // Optional colour overrides — empty hex input means "auto" + [ + { id: 'cfg-text-color', val: s.text_color }, + { id: 'cfg-line-color', val: s.line_color }, + { id: 'cfg-bg-color', val: s.bg_color }, + ].forEach(({ id, val }) => { + const hex = document.getElementById(id + '-hex'); + const prev = document.getElementById(id + '-preview'); + if (!hex || !prev) return; + if (val) { + hex.value = String(val).toUpperCase(); + prev.style.background = val; + } else { + hex.value = ''; + prev.style.background = 'transparent'; + } + }); document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; document.getElementById('cfg-language').value = getLang(); @@ -2376,6 +2394,32 @@ function bindSettingsModal() { }); }); + // Optional override colours (text / line / background) — empty = use default + [ + { prefix: 'cfg-text-color', defaultColor: '#c8c8d8' }, + { prefix: 'cfg-line-color', defaultColor: '#3a3a52' }, + { prefix: 'cfg-bg-color', defaultColor: '#0e0e14' }, + ].forEach(({ prefix, defaultColor }) => { + const preview = document.getElementById(prefix + '-preview'); + const hex = document.getElementById(prefix + '-hex'); + const reset = document.getElementById(prefix + '-reset'); + if (!preview || !hex || !reset) return; + preview.addEventListener('click', async () => { + const picked = await openColorPicker(preview, hex.value || defaultColor); + if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; } + }); + hex.addEventListener('change', () => { + let val = hex.value.trim(); + if (!val) { preview.style.background = 'transparent'; return; } + if (!val.startsWith('#')) val = '#' + val; + if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; } + }); + reset.addEventListener('click', () => { + hex.value = ''; + preview.style.background = 'transparent'; + }); + }); + // Panel navigation document.querySelectorAll('.settings-nav-btn').forEach(btn => { btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel)); @@ -2415,6 +2459,11 @@ function bindSettingsModal() { const btn = document.querySelector(`#${id} .contrast-btn.active`); return btn ? Number(btn.dataset.val) : null; }; + // Optional override colours: empty input → null (use default) + const colourOrNull = (id) => { + const v = (document.getElementById(id).value || '').trim(); + return /^#[0-9a-fA-F]{6}$/.test(v) ? v : null; + }; const settings = { default_view: document.getElementById('cfg-default-view').value, week_start_day: document.getElementById('cfg-week-start').value, @@ -2423,9 +2472,10 @@ function bindSettingsModal() { today_color: document.getElementById('cfg-today-hex').value, month_divider_color: document.getElementById('cfg-month-divider-hex').value, month_label_color: document.getElementById('cfg-month-label-hex').value, + text_color: colourOrNull('cfg-text-color-hex'), + line_color: colourOrNull('cfg-line-color-hex'), + bg_color: colourOrNull('cfg-bg-color-hex'), dim_past_events: document.getElementById('cfg-dim-past').checked, - text_contrast: getActive('cfg-text-contrast') || 3, - line_contrast: getActive('cfg-line-contrast') || 3, hour_height: getActive('cfg-hour-height') || 44, language: document.getElementById('cfg-language').value, }; diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 250b214..c918a7e 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -67,6 +67,10 @@ const translations = { settings_today_color: 'Heutige-Tag-Farbe', settings_month_divider_color: 'Monatswechsel-Linie', settings_month_label_color: 'Monatskürzel-Farbe', + settings_text_color: 'Schriftfarbe', + settings_line_color: 'Linienfarbe', + settings_bg_color: 'Hintergrundfarbe', + reset: 'Reset', settings_text_contrast: 'Schriftkontrast', settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte', contrast_dark: 'Dunkel', contrast_medium: 'Mittel', @@ -282,6 +286,10 @@ const translations = { settings_today_color: 'Today highlight color', settings_month_divider_color: 'Month divider line', settings_month_label_color: 'Month label color', + settings_text_color: 'Text color', + settings_line_color: 'Line color', + settings_bg_color: 'Background color', + reset: 'Reset', settings_text_contrast: 'Text contrast', settings_text_contrast_desc: 'Brightness of labels and text', contrast_dark: 'Dark', contrast_medium: 'Medium', diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 5db6786..4b71860 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -83,14 +83,47 @@ export function applyTheme(settings) { root.style.setProperty('--accent', settings.accent_color || '#ea4335'); root.style.setProperty('--today-color', settings.today_color || '#4285f4'); - const tc = TEXT_CONTRAST[settings.text_contrast || 3]; - root.style.setProperty('--text-1', tc.t1); - root.style.setProperty('--text-2', tc.t2); - root.style.setProperty('--text-3', tc.t3); + // Text colour: a custom hex (settings.text_color) wins over the legacy + // 1–4 contrast step. We derive --text-2/--text-3 by darkening the + // chosen colour so the secondary/tertiary text stays in the same hue. + if (settings.text_color) { + root.style.setProperty('--text-1', settings.text_color); + root.style.setProperty('--text-2', shadeHex(settings.text_color, -0.25)); + root.style.setProperty('--text-3', shadeHex(settings.text_color, -0.55)); + } else { + const tc = TEXT_CONTRAST[settings.text_contrast || 3]; + root.style.setProperty('--text-1', tc.t1); + root.style.setProperty('--text-2', tc.t2); + root.style.setProperty('--text-3', tc.t3); + } - const lc = LINE_CONTRAST[settings.line_contrast || 3]; - root.style.setProperty('--border', lc.border); - root.style.setProperty('--border-light', lc.light); + // Line colour: custom hex overrides the legacy contrast step. + if (settings.line_color) { + root.style.setProperty('--border', settings.line_color); + root.style.setProperty('--border-light', shadeHex(settings.line_color, -0.25)); + } else { + const lc = LINE_CONTRAST[settings.line_contrast || 3]; + root.style.setProperty('--border', lc.border); + root.style.setProperty('--border-light', lc.light); + } + + // Background colour: optional. If set, also tint the topbar/sidebar + // and surface variants so the whole UI stays coherent. + if (settings.bg_color) { + root.style.setProperty('--bg-app', settings.bg_color); + root.style.setProperty('--bg-topbar', shadeHex(settings.bg_color, 0.10)); + root.style.setProperty('--bg-sidebar', shadeHex(settings.bg_color, 0.10)); + root.style.setProperty('--bg-surface', shadeHex(settings.bg_color, 0.18)); + root.style.setProperty('--bg-hover', shadeHex(settings.bg_color, 0.26)); + root.style.setProperty('--bg-active', shadeHex(settings.bg_color, 0.40)); + } else { + root.style.removeProperty('--bg-app'); + root.style.removeProperty('--bg-topbar'); + root.style.removeProperty('--bg-sidebar'); + root.style.removeProperty('--bg-surface'); + root.style.removeProperty('--bg-hover'); + root.style.removeProperty('--bg-active'); + } const hh = settings.hour_height || 44; root.style.setProperty('--hour-h', hh + 'px'); @@ -105,3 +138,24 @@ function hexToRgba(hex, alpha) { const b = parseInt(hex.slice(5,7), 16); return `rgba(${r},${g},${b},${alpha})`; } + +// Brighten (positive amount) or darken (negative) a hex colour. +// Used to derive supporting shades (sidebar bg, hover bg, secondary text…) +// from a single user-picked colour so the whole UI stays in the same family. +function shadeHex(hex, amount) { + let r = parseInt(hex.slice(1,3), 16); + let g = parseInt(hex.slice(3,5), 16); + let b = parseInt(hex.slice(5,7), 16); + if (amount >= 0) { + r = Math.round(r + (255 - r) * amount); + g = Math.round(g + (255 - g) * amount); + b = Math.round(b + (255 - b) * amount); + } else { + const a = 1 + amount; // amount is negative: e.g. -0.25 → keep 75% + r = Math.round(r * a); + g = Math.round(g * a); + b = Math.round(b * a); + } + const h = n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0'); + return '#' + h(r) + h(g) + h(b); +} diff --git a/frontend/js/version.js b/frontend/js/version.js index 9d0c41b..eefbcf1 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,6 +1,2 @@ // Increment APP_VERSION with every code change -<<<<<<< HEAD -export const APP_VERSION = 'v17'; -======= -export const APP_VERSION = 'v11'; ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 +export const APP_VERSION = 'v18'; diff --git a/frontend/sw.js b/frontend/sw.js index 1d172c7..24dd649 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,3 @@ -<<<<<<< HEAD // Calendarr Service Worker — minimal-cache strategy // // Strategy: network-first for everything. The cache is only used as a @@ -8,52 +7,15 @@ // the entry HTML / version files). New releases take effect on the next // reload, no manual SW unregister required. -const CACHE_VERSION = 'calendarr-v17'; +const CACHE_VERSION = 'calendarr-v18'; const OFFLINE_SHELL = ['/', '/index.html']; -======= -// Calendarr Service Worker -// Cache-first for static assets, network-first for /api/* (graceful offline) - -const CACHE_VERSION = 'calendarr-v11'; -const STATIC_ASSETS = [ - '/', - '/index.html', - '/manifest.json', - '/static/css/app.css', - '/static/favicon.svg', - '/static/js/app.js', - '/static/js/api.js', - '/static/js/calendar.js', - '/static/js/color-picker.js', - '/static/js/date-picker.js', - '/static/js/i18n.js', - '/static/js/utils.js', - '/static/js/version.js', - '/static/js/views/agenda.js', - '/static/js/views/month.js', - '/static/js/views/quarter.js', - '/static/js/views/week.js', - '/icons/icon-192.png', - '/icons/icon-512.png', - '/icons/icon.svg', -]; ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_VERSION).then(cache => -<<<<<<< HEAD Promise.all(OFFLINE_SHELL.map(url => cache.add(url).catch(err => console.warn('[SW] skip', url, err)) )) -======= - // Use addAll with a fallback so a single missing file doesn't abort install - Promise.all( - STATIC_ASSETS.map(url => - cache.add(url).catch(err => console.warn('[SW] skip', url, err)) - ) - ) ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 ).then(() => self.skipWaiting()) ); }); @@ -72,12 +34,8 @@ self.addEventListener('fetch', event => { const url = new URL(req.url); -<<<<<<< HEAD // API routes: always go to the network, no offline fallback (we'd just // be returning stale account/event data otherwise). -======= - // Network-first for API routes — fail silently if offline ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 if (url.pathname.startsWith('/api/')) { event.respondWith( fetch(req).catch(() => @@ -90,7 +48,6 @@ self.addEventListener('fetch', event => { return; } -<<<<<<< HEAD // Everything else: network-first. The browser's HTTP cache (driven by // the server's Cache-Control headers) already throttles re-fetches — // the SW just makes sure offline still works for the entry HTML. @@ -114,47 +71,6 @@ self.addEventListener('fetch', event => { return caches.match(req).then(c => c || caches.match('/index.html')); } return new Response('', { status: 503 }); -======= - // Network-first for navigation (HTML) and the version-defining files — - // ensures users always get the freshest entry point so new releases - // take effect on the next reload without a manual SW unregister. - const isHtml = req.mode === 'navigate' - || url.pathname === '/' - || url.pathname === '/index.html'; - const isVersionFile = url.pathname === '/static/js/version.js'; - - if (isHtml || isVersionFile) { - event.respondWith( - fetch(req).then(resp => { - if (resp && resp.status === 200) { - const clone = resp.clone(); - caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); - } - return resp; - }).catch(() => - caches.match(req).then(c => c || caches.match('/index.html')) - ) - ); - return; - } - - // Cache-first for everything else (static) - event.respondWith( - caches.match(req).then(cached => { - if (cached) return cached; - return fetch(req).then(resp => { - // Only cache successful, basic-origin responses - if (resp && resp.status === 200 && resp.type === 'basic') { - const clone = resp.clone(); - caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); - } - return resp; - }).catch(() => { - // Offline fallback for navigation requests - if (req.mode === 'navigate') return caches.match('/index.html'); - return new Response('', { status: 503 }); - }); ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 }) ); });