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
@@ -273,11 +253,7 @@
—
-<<<<<<< HEAD
-=======
-
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -339,11 +311,7 @@
—
-<<<<<<< HEAD
-=======
-
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -407,7 +375,6 @@
@@ -667,22 +622,29 @@
- Schriftkontrast
- Helligkeit der Beschriftungen und Texte
-
-
-
-
-
+
-
-
Linienkontrast
-
Sichtbarkeit von Trennlinien und Rahmen
-
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
})
);
});