Files
Calendarr/frontend/js/utils.js
Scarriffle 8f9eafe561 feat(settings): Schriftfarbe, Linienfarbe und Hintergrundfarbe per Color-Picker
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.
2026-05-19 09:49:45 +02:00

162 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export function isToday(d) {
const now = new Date();
return d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
}
export function isSameDay(a, b) {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
export function isPast(ev) {
const end = new Date(ev.end);
return end < new Date();
}
export function formatDate(d, opts = {}) {
return d.toLocaleDateString('de', opts);
}
export function dateKey(d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
export function toLocalDatetimeInput(d) {
const Y = d.getFullYear();
const M = String(d.getMonth()+1).padStart(2,'0');
const D = String(d.getDate()).padStart(2,'0');
const h = String(d.getHours()).padStart(2,'0');
const m = String(d.getMinutes()).padStart(2,'0');
return `${Y}-${M}-${D}T${h}:${m}`;
}
export function toDateInput(d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
// Monday-first: returns 0=Mo, 1=Di, ..., 6=So
export function dayOfWeek(d, weekStartDay = 'monday') {
if (weekStartDay === 'sunday') {
return d.getDay(); // 0=So, 1=Mo, ..., 6=Sa
}
return (d.getDay() + 6) % 7; // 0=Mo, 1=Di, ..., 6=So
}
// Returns the start-of-week date for d
export function weekStart(d, weekStartDay = 'monday') {
const m = new Date(d);
m.setDate(m.getDate() - dayOfWeek(m, weekStartDay));
m.setHours(0, 0, 0, 0);
return m;
}
// Returns the ISO week number (Monday-based, ISO 8601)
export function getISOWeekNumber(d) {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
// ISO week: weeks start on Monday, week 1 contains the first Thursday
const day = date.getUTCDay() || 7; // make Sunday = 7
date.setUTCDate(date.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
}
const TEXT_CONTRAST = {
1: { t1: '#606070', t2: '#484858', t3: '#303040' },
2: { t1: '#9090a8', t2: '#6a6a80', t3: '#484860' },
3: { t1: '#c8c8d8', t2: '#9090aa', t3: '#55556a' },
4: { t1: '#ffffff', t2: '#c0c0d8', t3: '#8888a0' },
};
const LINE_CONTRAST = {
1: { border: '#1e1e2c', light: '#181826' },
2: { border: '#2a2a3c', light: '#222230' },
3: { border: '#3a3a52', light: '#2e2e40' },
4: { border: '#5a5a78', light: '#484860' },
};
export function applyTheme(settings) {
const root = document.documentElement;
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15));
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
// Text colour: a custom hex (settings.text_color) wins over the legacy
// 14 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);
}
// 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');
root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0');
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
}
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
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);
}