diff --git a/backend/main.py b/backend/main.py index 6a1a4b5..5d85994 100644 --- a/backend/main.py +++ b/backend/main.py @@ -210,6 +210,13 @@ def _migrate(): logging.info("Migration: added reminders_enabled to %s", tbl) except Exception: pass + # Synced default duration (minutes) for newly created events. + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_event_duration_minutes INTEGER DEFAULT 60")) + conn.commit() + logging.info("Migration: added default_event_duration_minutes to user_settings") + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index bcc8443..a12de57 100644 --- a/backend/models.py +++ b/backend/models.py @@ -106,6 +106,8 @@ class UserSettings(Base): # Default reminder in minutes-before-start applied to all events client-side # (0 = at start time). NULL = no default reminder. default_reminder_minutes = Column(Integer, nullable=True) + # Default duration (in minutes) applied to a newly created event's end time. + default_event_duration_minutes = Column(Integer, default=60) user = relationship("User", back_populates="settings") diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index 59a418b..bcd787d 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -30,6 +30,7 @@ class SettingsUpdate(BaseModel): private_event_visibility: Optional[str] = None group_visible_calendar_id: Optional[int] = None default_reminder_minutes: Optional[int] = None # null = off + default_event_duration_minutes: Optional[int] = None def _settings_dict(s: models.UserSettings) -> dict: @@ -52,6 +53,7 @@ def _settings_dict(s: models.UserSettings) -> dict: "private_event_visibility": s.private_event_visibility or "busy", "group_visible_calendar_id": s.group_visible_calendar_id, "default_reminder_minutes": s.default_reminder_minutes, + "default_event_duration_minutes": s.default_event_duration_minutes or 60, } diff --git a/frontend/css/app.css b/frontend/css/app.css index fa535f4..d073533 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -355,9 +355,16 @@ a { color: var(--primary); text-decoration: none; } .ev-color-row { display: flex; align-items: center; gap: 8px; } .ev-reminders-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; } -.ev-reminder-row { display: flex; align-items: center; gap: 8px; } -.ev-reminder-row select { flex: 1; } +.ev-reminder-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.ev-reminder-row > select { flex: 1; min-width: 120px; } .ev-reminder-remove { flex-shrink: 0; } +.ev-reminder-custom { display: inline-flex; align-items: center; gap: 6px; flex-basis: 100%; } +.ev-reminder-custom input[type="number"] { width: 64px; } +.ev-reminder-before { color: var(--text-3); font-size: 12px; } +.form-hint { font-size: 12px; color: var(--text-3); margin-bottom: 6px; } +.reminders-disabled .ev-reminders-list, +.reminders-disabled #ev-reminder-add { opacity: 0.5; } +#ev-reminders-hint { color: var(--accent); } .ev-color-hex { flex: 1; height: 36px; padding: 0 10px; background: var(--bg-hover); border: 1px solid var(--border); @@ -1193,6 +1200,10 @@ a { color: var(--primary); text-decoration: none; } #popup-time { color: var(--text-1); font-weight: 500; } .popup-row-desc { color: var(--text-1); } .popup-row-desc span { white-space: pre-wrap; } +#popup-description { overflow-wrap: anywhere; } +#popup-description a { color: var(--primary); text-decoration: underline; } +#popup-description p { margin: 0 0 6px; } +#popup-description ul, #popup-description ol { margin: 4px 0; padding-left: 20px; } #popup-creator { font-style: italic; } .popup-copy-menu { diff --git a/frontend/index.html b/frontend/index.html index 97abaf7..508b35b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -375,6 +375,7 @@ @@ -843,6 +844,18 @@ Vergangene Termine ausgrauen +
+ + +

Ausgeblendete Kalender

Keine ausgeblendeten Kalender
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 20b1d21..b50f181 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -1,5 +1,5 @@ import { api } from './api.js'; -import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js'; +import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js'; import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; import { renderAgenda } from './views/agenda.js'; @@ -1401,7 +1401,7 @@ function showEventPopup(ev, anchor) { document.getElementById('popup-location').textContent = ev.location || ''; document.getElementById('popup-row-location').style.display = ev.location ? '' : 'none'; - document.getElementById('popup-description').textContent = ev.description || ''; + document.getElementById('popup-description').innerHTML = renderDescriptionHtml(ev.description || ''); document.getElementById('popup-row-description').style.display = ev.description ? '' : 'none'; document.getElementById('popup-calendar').textContent = ev.calendar_name || ''; document.getElementById('popup-row-calendar').style.display = ev.calendar_name ? '' : 'none'; @@ -1700,8 +1700,8 @@ function openNewEventModal(date) { document.getElementById('ev-allday').checked = false; const start = new Date(date); - const end = new Date(date); - end.setHours(end.getHours() + 1); + const durMin = (state.settings && state.settings.default_event_duration_minutes) || 60; + const end = new Date(start.getTime() + durMin * 60000); setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime'); setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime'); setDtValue('ev-start-date', toDateInput(start), 'date'); @@ -1835,7 +1835,15 @@ function resetColorPicker(color) { } // ── Reminders (local events only) ───────────────────────── -const REMINDER_OPTIONS = [0, 5, 15, 30, 60, 1440, 10080]; +// A few quick presets; anything else is entered via the "custom" mode +// (number + unit). Reminders are stored as minutes-before-start integers. +const REMINDER_PRESETS = [0, 30, 1440]; // at start, 30 min, 1 day +const REMINDER_UNITS = [ + { unit: 'minutes', mult: 1 }, + { unit: 'hours', mult: 60 }, + { unit: 'days', mult: 1440 }, + { unit: 'weeks', mult: 10080 }, +]; function reminderLabel(min) { if (min <= 0) return t('reminder_at_start'); @@ -1845,6 +1853,15 @@ function reminderLabel(min) { const w = min / 10080; return w === 1 ? t('reminder_week_one') : t('reminder_weeks', { n: w }); } +// Split a minutes value into the largest exact {value, unit} for the custom picker. +function splitReminder(min) { + for (let i = REMINDER_UNITS.length - 1; i >= 0; i--) { + const u = REMINDER_UNITS[i]; + if (min > 0 && min % u.mult === 0) return { value: min / u.mult, unit: u.unit }; + } + return { value: Math.max(1, min), unit: 'minutes' }; +} + function setEventReminders(arr) { state.eventReminders = Array.isArray(arr) ? arr.map(Number).filter(n => !isNaN(n)) : []; @@ -1858,19 +1875,63 @@ function renderReminderRows() { state.eventReminders.forEach((min, idx) => { const row = document.createElement('div'); row.className = 'ev-reminder-row'; + + const isPreset = REMINDER_PRESETS.includes(min); const sel = document.createElement('select'); - // Keep a non-catalog value (from an old/imported reminder) selectable. - const opts = REMINDER_OPTIONS.includes(min) ? REMINDER_OPTIONS : [min, ...REMINDER_OPTIONS]; - opts.forEach(v => { + REMINDER_PRESETS.forEach(v => { const o = document.createElement('option'); o.value = String(v); o.textContent = reminderLabel(v); - if (v === min) o.selected = true; + if (isPreset && v === min) o.selected = true; sel.appendChild(o); }); - sel.addEventListener('change', () => { - state.eventReminders[idx] = parseInt(sel.value, 10); + const customOpt = document.createElement('option'); + customOpt.value = 'custom'; + customOpt.textContent = t('reminder_custom'); + if (!isPreset) customOpt.selected = true; + sel.appendChild(customOpt); + + // Custom (number + unit) inputs, shown only in custom mode. + const customWrap = document.createElement('span'); + customWrap.className = 'ev-reminder-custom'; + customWrap.style.display = isPreset ? 'none' : ''; + const numInput = document.createElement('input'); + numInput.type = 'number'; + numInput.min = '1'; + const unitSel = document.createElement('select'); + REMINDER_UNITS.forEach(u => { + const o = document.createElement('option'); + o.value = u.unit; + o.textContent = t('reminder_unit_' + u.unit); + unitSel.appendChild(o); }); + const beforeLbl = document.createElement('span'); + beforeLbl.className = 'ev-reminder-before'; + beforeLbl.textContent = t('reminder_before'); + const sp = splitReminder(isPreset ? 30 : min); + numInput.value = String(sp.value); + unitSel.value = sp.unit; + customWrap.appendChild(numInput); + customWrap.appendChild(unitSel); + customWrap.appendChild(beforeLbl); + + function commitCustom() { + const n = Math.max(1, parseInt(numInput.value, 10) || 1); + const mult = REMINDER_UNITS.find(u => u.unit === unitSel.value).mult; + state.eventReminders[idx] = n * mult; + } + sel.addEventListener('change', () => { + if (sel.value === 'custom') { + customWrap.style.display = ''; + commitCustom(); + } else { + customWrap.style.display = 'none'; + state.eventReminders[idx] = parseInt(sel.value, 10); + } + }); + numInput.addEventListener('input', commitCustom); + unitSel.addEventListener('change', commitCustom); + const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'icon-btn ev-reminder-remove'; @@ -1880,6 +1941,7 @@ function renderReminderRows() { renderReminderRows(); }); row.appendChild(sel); + row.appendChild(customWrap); row.appendChild(rm); list.appendChild(row); }); @@ -1887,16 +1949,30 @@ function renderReminderRows() { function addReminderRow() { const def = (state.settings && state.settings.default_reminder_minutes != null) - ? state.settings.default_reminder_minutes : 10; - state.eventReminders.push(REMINDER_OPTIONS.includes(def) ? def : 10); + ? state.settings.default_reminder_minutes : 30; + state.eventReminders.push(def >= 0 ? def : 30); renderReminderRows(); } // Reminders apply to local events only (the server stores them on local events). +// When the selected calendar has its reminders disabled, we keep any existing +// reminders intact (never delete them) but grey out the controls and explain +// that they won't fire — matching the apps' behaviour. function updateRemindersRow() { const calVal = document.getElementById('ev-calendar').value || ''; const isLocal = calVal.startsWith('local-'); - document.getElementById('ev-reminders-group').style.display = isLocal ? '' : 'none'; + const group = document.getElementById('ev-reminders-group'); + group.style.display = isLocal ? '' : 'none'; + if (!isLocal) return; + + const calId = parseInt(calVal.slice('local-'.length), 10); + const cal = (state.localCalendars || []).find(c => c.id === calId); + const disabled = !!(cal && cal.reminders_enabled === false); + + const hint = document.getElementById('ev-reminders-hint'); + if (hint) hint.style.display = disabled ? '' : 'none'; + group.classList.toggle('reminders-disabled', disabled); + group.querySelectorAll('select, input, button').forEach(el => { el.disabled = disabled; }); } function buildRruleFromUI() { @@ -2928,6 +3004,7 @@ function openSettingsModal() { prev.style.background = val || fallback; }); document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; + document.getElementById('cfg-default-duration').value = String(s.default_event_duration_minutes || 60); document.getElementById('cfg-language').value = getLang(); document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy'; renderGroupVisibleList(s.group_visible_calendar_id); @@ -3438,6 +3515,7 @@ function bindSettingsModal() { line_color: colourOrNull('cfg-line-color-hex'), bg_color: colourOrNull('cfg-bg-color-hex'), dim_past_events: document.getElementById('cfg-dim-past').checked, + default_event_duration_minutes: parseInt(document.getElementById('cfg-default-duration').value, 10) || 60, hour_height: getActive('cfg-hour-height') || 44, language: document.getElementById('cfg-language').value, private_event_visibility: document.getElementById('cfg-private-visibility').value, diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 3d5bc4e..7d57d28 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -87,6 +87,7 @@ const translations = { settings_week_start: 'Erster Wochentag', week_start_monday: 'Montag', week_start_sunday: 'Sonntag', settings_dim_past: 'Vergangene Termine ausgrauen', + settings_default_duration: 'Standard-Termindauer', settings_privacy: 'Privatsphäre', settings_private_visibility: 'Private Termine für Gruppenmitglieder', settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen', @@ -104,6 +105,13 @@ const translations = { reminder_days: '{n} Tage vorher', reminder_week_one: '1 Woche vorher', reminder_weeks: '{n} Wochen vorher', + reminder_custom: 'Benutzerdefiniert…', + reminder_unit_minutes: 'Minuten', + reminder_unit_hours: 'Stunden', + reminder_unit_days: 'Tage', + reminder_unit_weeks: 'Wochen', + reminder_before: 'vorher', + reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.', calendar_reminders_on: 'Benachrichtigungen aktiviert', calendar_reminders_off: 'Benachrichtigungen deaktiviert', share: 'Teilen', @@ -372,6 +380,7 @@ const translations = { settings_week_start: 'First day of week', week_start_monday: 'Monday', week_start_sunday: 'Sunday', settings_dim_past: 'Dim past events', + settings_default_duration: 'Default event duration', settings_privacy: 'Privacy', settings_private_visibility: 'Private events for group members', settings_private_visibility_desc: 'How your private events appear to other group members', @@ -389,6 +398,13 @@ const translations = { reminder_days: '{n} days before', reminder_week_one: '1 week before', reminder_weeks: '{n} weeks before', + reminder_custom: 'Custom…', + reminder_unit_minutes: 'minutes', + reminder_unit_hours: 'hours', + reminder_unit_days: 'days', + reminder_unit_weeks: 'weeks', + reminder_before: 'before', + reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.', calendar_reminders_on: 'Reminders enabled', calendar_reminders_off: 'Reminders disabled', share: 'Share', diff --git a/frontend/js/utils.js b/frontend/js/utils.js index b0e9b36..466b9bf 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -142,6 +142,79 @@ function contrastRatio(c1, c2) { } catch { return 21; } } +// ── Safe HTML rendering for event descriptions ─────────────────────────── +// External calendars (CalDAV, iCal) often store rich-text descriptions as raw +// HTML. We render a small allowlist of formatting tags so links/line breaks +// look right, but strip everything dangerous (scripts, event handlers, inline +// styles) — we never execute code from a description. +const ALLOWED_TAGS = new Set(['A', 'BR', 'P', 'DIV', 'B', 'STRONG', 'I', 'EM', 'U', 'UL', 'OL', 'LI', 'SPAN']); +const URL_RE = /(https?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"])/g; + +function makeSafeLink(href, text) { + const a = document.createElement('a'); + a.href = href; + a.textContent = text; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + return a; +} + +// Replace bare URLs inside a text node with clickable elements. +function linkifyTextNode(node, out) { + const text = node.nodeValue; + let last = 0; + let m; + URL_RE.lastIndex = 0; + while ((m = URL_RE.exec(text)) !== null) { + if (m.index > last) out.appendChild(document.createTextNode(text.slice(last, m.index))); + out.appendChild(makeSafeLink(m[0], m[0])); + last = m.index + m[0].length; + } + if (last < text.length) out.appendChild(document.createTextNode(text.slice(last))); +} + +// Recursively copy `src` into `dest`, keeping only allowlisted tags/attributes. +function sanitizeInto(src, dest) { + src.childNodes.forEach(node => { + if (node.nodeType === Node.TEXT_NODE) { + linkifyTextNode(node, dest); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + const tag = node.tagName; + if (!ALLOWED_TAGS.has(tag)) { + // Drop the tag but keep its (sanitized) contents. + sanitizeInto(node, dest); + return; + } + let el; + if (tag === 'A') { + const href = node.getAttribute('href') || ''; + if (/^(https?:|mailto:)/i.test(href)) { + el = makeSafeLink(href, ''); + } else { + // Unsafe/relative href: render as plain text container. + el = document.createElement('span'); + } + } else { + el = document.createElement(tag.toLowerCase()); + } + sanitizeInto(node, el); + dest.appendChild(el); + }); +} + +// Returns a sanitized HTML string for an event description, safe for innerHTML. +export function renderDescriptionHtml(raw) { + if (!raw) return ''; + const hasHtml = /<[a-z][\s\S]*>/i.test(raw); + const doc = new DOMParser().parseFromString( + hasHtml ? raw : raw.replace(/\n/g, '
'), 'text/html'); + const out = document.createElement('div'); + sanitizeInto(doc.body, out); + return out.innerHTML; +} + function hexToRgba(hex, alpha) { const r = parseInt(hex.slice(1,3), 16); const g = parseInt(hex.slice(3,5), 16);