import { api } from './api.js'; import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart } from './utils.js'; import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; import { renderAgenda } from './views/agenda.js'; import { renderQuarter } from './views/quarter.js'; import { openColorPicker } from './color-picker.js'; import { openDatePicker, formatDtDisplay } from './date-picker.js'; import { t, setLang, getLang } from './i18n.js'; // Fetch avatar image as blob URL (with auth header) function fetchAvatarBlob() { const token = localStorage.getItem('token'); return fetch(`/api/profile/avatar?t=${Date.now()}`, { headers: token ? { 'Authorization': `Bearer ${token}` } : {} }).then(res => { if (!res.ok) throw new Error('No avatar'); return res.blob(); }).then(blob => URL.createObjectURL(blob)); } // week start day global (loaded from settings) let weekStartDay = 'monday'; // month names come from i18n: t('months') let state = { currentDate: new Date(), selectedDate: null, // separate from currentDate; used for month-view selection currentView: 'month', events: [], accounts: [], localCalendars: [], icalSubscriptions: [], googleAccounts: [], haAccounts: [], settings: {}, dimPast: false, editingEvent: null, // null = new event selectedEventColor: '', // '' = use calendar color }; // ── Public init ─────────────────────────────────────────── export async function initCalendar() { const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts, haAccounts] = await Promise.all([ api.get('/settings/'), api.get('/caldav/accounts'), api.get('/local/calendars').catch(() => []), api.get('/ical/subscriptions').catch(() => []), api.get('/google/accounts').catch(() => []), api.get('/homeassistant/accounts').catch(() => []), ]); state.settings = settings; state.accounts = accounts; state.localCalendars = localCalendars; state.icalSubscriptions = icalSubscriptions; state.googleAccounts = googleAccounts; state.haAccounts = haAccounts; state.currentView = settings.default_view || 'month'; state.dimPast = settings.dim_past_events; weekStartDay = settings.week_start_day || 'monday'; setLang(settings.language || 'de'); applyTheme(settings); updateViewButtons(); renderCalendarList(); renderMiniCal(); await fetchAndRender(); bindTopbar(); bindSidebar(); bindEventModal(); bindAccountModal(); bindLocalCalModal(); bindICalSubModal(); bindHAAccountModal(); bindSettingsModal(); bindProfileModal(); bindSwipeNavigation(); handleHAOAuthReturn(); } function handleHAOAuthReturn() { const params = new URLSearchParams(window.location.search); const errMap = { no_code: 'Home Assistant hat keinen Autorisierungscode zurückgegeben', state_expired: 'Die Anmeldung ist abgelaufen, bitte erneut versuchen', ha_unreachable: 'Home Assistant nicht erreichbar', token_exchange_failed: 'Token-Austausch mit Home Assistant fehlgeschlagen', calendars_failed: 'Kalender konnten nicht geladen werden', }; if (params.has('ha_connected')) { showToast('Home Assistant verbunden'); window.history.replaceState({}, '', window.location.pathname); fetchAndRender(true); api.get('/homeassistant/accounts').then(accs => { state.haAccounts = accs || []; renderCalendarList(); renderAllAccounts?.(); }).catch(() => {}); } else if (params.has('ha_error')) { const code = params.get('ha_error'); showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true); window.history.replaceState({}, '', window.location.pathname); } } // ── Event cache ─────────────────────────────────────────── const CACHE_BUF = 300 * 86400000; // initial ±10 months around the view const PREFETCH_EXT = 180 * 86400000; // extend by ~6 months when triggered const PREFETCH_EDGE = 90 * 86400000; // trigger when within ~3 months of cache edge const eventCache = { start: null, end: null, events: [], _fwdPending: false, _bwdPending: false, }; function invalidateCache() { eventCache.start = null; eventCache.end = null; eventCache.events = []; eventCache._fwdPending = false; eventCache._bwdPending = false; } function _mergeEvents(newEvents) { const seen = new Set(eventCache.events.map(e => e.id + '@@' + (e.url || ''))); for (const e of newEvents) { const k = e.id + '@@' + (e.url || ''); if (!seen.has(k)) { seen.add(k); eventCache.events.push(e); } } } // Patch calendarColor in-place for all cached events belonging to a calendar, // then re-render immediately without a network round-trip. function applyCalendarColor(source, calId, color) { const id = String(calId); eventCache.events.forEach(ev => { if (ev.source !== source) return; const cid = String(ev.calendar_id); if (cid === id || cid === source + '-' + id || cid.replace(source + '-', '') === id) { ev.calendarColor = color; } }); state.events = eventCache.events; renderCalendarList(); renderView(); } // Patch a single event in cache after edit/save, then re-render immediately. // 'patch' is the new field values to merge into the existing cached event. function applyEventPatch(eventId, eventUrl, patch) { const targetUrl = eventUrl || ''; eventCache.events.forEach(ev => { if (ev.id === eventId && (ev.url || '') === targetUrl) { Object.assign(ev, patch); } }); state.events = eventCache.events; renderView(); } // Fire-and-forget: extend the cache toward whichever edge the view is approaching function prefetchIfNeeded(viewStart, viewEnd) { if (!eventCache.start || !eventCache.end) return; if (!eventCache._fwdPending && (eventCache.end - viewEnd) < PREFETCH_EDGE) { eventCache._fwdPending = true; const from = new Date(eventCache.end); const to = new Date(eventCache.end.getTime() + PREFETCH_EXT); api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`) .then(r => { _mergeEvents(r.events || r); eventCache.end = to; }) .catch(() => {}) .finally(() => { eventCache._fwdPending = false; }); } if (!eventCache._bwdPending && (viewStart - eventCache.start) < PREFETCH_EDGE) { eventCache._bwdPending = true; const from = new Date(eventCache.start.getTime() - PREFETCH_EXT); const to = new Date(eventCache.start); api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`) .then(r => { _mergeEvents(r.events || r); eventCache.start = from; }) .catch(() => {}) .finally(() => { eventCache._bwdPending = false; }); } } // ── Data fetching ───────────────────────────────────────── async function fetchAndRender(force = false, silent = false) { const { start, end } = getViewRange(); // Cache hit: requested range is fully within what we already have if (!force && eventCache.start && eventCache.end && start >= eventCache.start && end <= eventCache.end) { state.events = eventCache.events; renderView(); updateTitle(); renderMiniCal(); prefetchIfNeeded(start, end); // extend cache in background if approaching an edge return; } // Cache miss: fetch a wider window (±8 weeks) so subsequent navigation is instant const fetchStart = new Date(start.getTime() - CACHE_BUF); const fetchEnd = new Date(end.getTime() + CACHE_BUF); if (!silent) showLoading(); try { const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`); const events = resp.events || resp; if (resp.errors && resp.errors.length) { for (const err of resp.errors) { showToast(`Google (${err.email}): Token abgelaufen – bitte Konto trennen und neu verbinden`, true); } } eventCache.start = fetchStart; eventCache.end = fetchEnd; eventCache.events = events; state.events = events; } catch (e) { showToast(t('error_load_events') + ': ' + e.message, true); state.events = []; } renderView(); updateTitle(); renderMiniCal(); } function getViewRange() { const d = state.currentDate; let start, end; if (state.currentView === 'month') { // Rolling view: 5 weeks from the start of currentDate's week start = weekStart(d, weekStartDay); start.setDate(start.getDate() - 1); // 1-day buffer end = new Date(start); end.setDate(start.getDate() + 37); // 5 weeks + buffer } else if (state.currentView === 'week') { start = weekStart(d, weekStartDay); end = new Date(start); end.setDate(start.getDate() + 7); } else if (state.currentView === 'day') { start = new Date(d); start.setHours(0, 0, 0, 0); end = new Date(start); end.setDate(end.getDate() + 1); } else if (state.currentView === 'quarter') { const q = Math.floor(d.getMonth() / 3); start = new Date(d.getFullYear(), q * 3, 1); end = new Date(d.getFullYear(), q * 3 + 3, 1); } else { // agenda start = new Date(d); start.setHours(0, 0, 0, 0); end = new Date(start); end.setDate(end.getDate() + 60); } return { start, end }; } // ── Rendering ───────────────────────────────────────────── function renderView() { const container = document.getElementById('view-container'); const evs = filterEvents(state.events); if (state.currentView === 'month') { renderMonth(container, state.currentDate, evs, (date, action, mouseEvent) => { if (action === 'navigate') { state.currentDate = date; state.selectedDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); } else if (action === 'context') { state.selectedDate = date; showDayContextMenu(date, mouseEvent); renderView(); } else { // 'select' — only update selectedDate, don't shift the view state.selectedDate = date; renderView(); } }, showEventPopup, weekStartDay, state.selectedDate ); } else if (state.currentView === 'week') { renderWeek(container, state.currentDate, evs, (date, switchDay) => { if (switchDay) { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); } else openNewEventModal(date); }, showEventPopup, false, weekStartDay, state.settings.hour_height || 44 ); } else if (state.currentView === 'day') { renderWeek(container, state.currentDate, evs, (date, switchDay) => { if (!switchDay) openNewEventModal(date); }, showEventPopup, true, weekStartDay, state.settings.hour_height || 44 ); } else if (state.currentView === 'quarter') { renderQuarter(container, state.currentDate, evs, date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }, showEventPopup, weekStartDay ); } else { renderAgenda(container, state.currentDate, evs, showEventPopup); } } function filterEvents(events) { // If dimPast is enabled, events are still shown but CSS handles opacity via .past class return events; } function showLoading() { document.getElementById('view-container').innerHTML = `
`; } function updateTitle() { const d = state.currentDate; let main = ''; // primary label (months / day range) let year = ''; // year — separated so mobile can wrap to a 2nd line const M = t('months'); if (state.currentView === 'month') { const ws = weekStart(d, weekStartDay); const we = new Date(ws); we.setDate(we.getDate() + 34); const Ms = t('months_short'); if (ws.getFullYear() !== we.getFullYear()) { // Cross-year: keep both years inline in main, no separate year main = `${Ms[ws.getMonth()]} ${ws.getFullYear()} – ${Ms[we.getMonth()]} ${we.getFullYear()}`; } else if (ws.getMonth() !== we.getMonth()) { main = `${Ms[ws.getMonth()]} – ${Ms[we.getMonth()]}`; year = `${we.getFullYear()}`; } else { main = `${M[ws.getMonth()]}`; year = `${ws.getFullYear()}`; } } else if (state.currentView === 'week') { const mon = weekStart(d, weekStartDay); const sun = new Date(mon); sun.setDate(mon.getDate() + 6); const sameMonth = mon.getMonth() === sun.getMonth(); main = sameMonth ? `${mon.getDate()}. – ${sun.getDate()}. ${M[sun.getMonth()]}` : `${mon.getDate()}. ${M[mon.getMonth()]} – ${sun.getDate()}. ${M[sun.getMonth()]}`; year = `${sun.getFullYear()}`; } else if (state.currentView === 'day') { main = `${d.getDate()}. ${M[d.getMonth()]}`; year = `${d.getFullYear()}`; } else if (state.currentView === 'quarter') { const q = Math.floor(d.getMonth() / 3) + 1; main = `Q${q}`; year = `${d.getFullYear()}`; } else { main = `${d.getDate()}. ${M[d.getMonth()]}`; year = `${d.getFullYear()}`; } const fullText = year ? `${main} ${year}` : main; const titleEl = document.getElementById('view-title'); titleEl.innerHTML = `${main}` + (year ? `${year}` : ''); document.title = `Calendarr - ${fullText}`; } function updateViewButtons() { document.querySelectorAll('.view-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === state.currentView); }); } // ── Mini Calendar ───────────────────────────────────────── function renderMiniCal() { const d = state.currentDate; const miniD = new Date(d.getFullYear(), d.getMonth(), 1); document.getElementById('mini-title').textContent = `${t('months')[miniD.getMonth()]} ${miniD.getFullYear()}`; const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1); const gridStart = new Date(firstDay); gridStart.setDate(gridStart.getDate() - dayOfWeek(firstDay, weekStartDay)); // Update mini-cal DOW headers based on weekStartDay const miniDowEls = document.querySelectorAll('.mini-cal-grid .mini-dow'); const DOW_MONDAY = ['Mo','Di','Mi','Do','Fr','Sa','So']; const DOW_SUNDAY = ['So','Mo','Di','Mi','Do','Fr','Sa']; const DOW_LABELS = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY; miniDowEls.forEach((el, i) => { el.textContent = DOW_LABELS[i]; }); // Build event date set const eventDates = new Set(state.events.map(ev => { const s = new Date(ev.start); return `${s.getFullYear()}-${s.getMonth()}-${s.getDate()}`; })); const days = []; const cur = new Date(gridStart); for (let i = 0; i < 42; i++) { days.push(new Date(cur)); cur.setDate(cur.getDate() + 1); } const html = days.map(day => { const isOther = day.getMonth() !== miniD.getMonth(); const isToday_ = isToday(day); const isSelected = isSameDay(day, state.currentDate); const hasEvs = eventDates.has(`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`); const cls = [ 'mini-day', isOther ? 'other-month' : '', isToday_ ? 'today' : '', isSelected && !isToday_ ? 'selected' : '', hasEvs ? 'has-events' : '', ].filter(Boolean).join(' '); return `
${day.getDate()}
`; }).join(''); document.getElementById('mini-days').innerHTML = html; document.querySelectorAll('.mini-day').forEach(el => { el.addEventListener('click', () => { state.currentDate = new Date(el.dataset.date + 'T00:00:00'); if (state.currentView === 'agenda' || state.currentView === 'month') { // Stay in current view but update date } fetchAndRender(); }); }); document.getElementById('mini-prev').onclick = () => { state.currentDate = new Date(state.currentDate.getFullYear(), state.currentDate.getMonth() - 1, 1); renderMiniCal(); fetchAndRender(); }; document.getElementById('mini-next').onclick = () => { state.currentDate = new Date(state.currentDate.getFullYear(), state.currentDate.getMonth() + 1, 1); renderMiniCal(); fetchAndRender(); }; } // ── Calendar List ───────────────────────────────────────── function renderCalendarList() { const container = document.getElementById('cal-list-items'); let html = ''; // ── CalDAV accounts ──────────────────────────────────── if (state.accounts.length) { html += state.accounts.map(acc => { const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden); if (!visibleCals.length) return ''; return `
${escHtml(acc.name)}
` + visibleCals.map(cal => `
${escHtml(cal.name)}
` ).join(''); }).join(''); } // ── Local calendars ──────────────────────────────────── if (state.localCalendars.length) { html += `
${t('cal_local')}
`; html += state.localCalendars.map(cal => `
${escHtml(cal.name)}
` ).join(''); } // ── iCal subscriptions ───────────────────────────────── if (state.icalSubscriptions.length) { html += `
${t('cal_ical')}
`; html += state.icalSubscriptions.map(sub => `
${escHtml(sub.name)}
` ).join(''); } // ── Google accounts ─────────────────────────────────── if (state.googleAccounts.length) { html += state.googleAccounts.map(acc => { const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden); if (!visibleCals.length) return `
${escHtml(acc.email)}
`; return `
${escHtml(acc.email)}
` + visibleCals.map(cal => `
${escHtml(cal.name)}
` ).join(''); }).join(''); } // ── Home Assistant accounts ─────────────────────────── if (state.haAccounts.length) { html += state.haAccounts.map(acc => { const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden); if (!visibleCals.length) return `
${escHtml(acc.name)}
`; return `
${escHtml(acc.name)}
` + visibleCals.map(cal => `
${escHtml(cal.name)}
` ).join(''); }).join(''); } if (!html) { container.innerHTML = `
${t('error_no_calendars')}
`; return; } container.innerHTML = html; // ── Checkbox handlers ────────────────────────────────── container.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', async () => { const source = cb.dataset.source; let cacheCalId = null; // calendar_id value used in cached events if (source === 'caldav') { const calId = parseInt(cb.dataset.calId); await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked }); for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.id === calId) cal.enabled = cb.checked; } } cacheCalId = calId; // numeric integer in cached events } else if (source === 'local') { const calId = parseInt(cb.dataset.calId); await api.put(`/local/calendars/${calId}`, { enabled: cb.checked }); const cal = state.localCalendars.find(c => c.id === calId); if (cal) cal.enabled = cb.checked; cacheCalId = `local-${calId}`; } else if (source === 'ical') { const subId = parseInt(cb.dataset.subId); await api.put(`/ical/subscriptions/${subId}`, { enabled: cb.checked }); const sub = state.icalSubscriptions.find(s => s.id === subId); if (sub) sub.enabled = cb.checked; cacheCalId = `ical-${subId}`; } else if (source === 'google') { const calId = parseInt(cb.dataset.calId); await api.put(`/google/calendars/${calId}`, { enabled: cb.checked }); for (const acc of state.googleAccounts) { const cal = acc.calendars.find(c => c.id === calId); if (cal) cal.enabled = cb.checked; } cacheCalId = `google-${calId}`; } else if (source === 'homeassistant') { const calId = parseInt(cb.dataset.calId); await api.put(`/homeassistant/calendars/${calId}`, { enabled: cb.checked }); for (const acc of state.haAccounts) { const cal = acc.calendars.find(c => c.id === calId); if (cal) cal.enabled = cb.checked; } cacheCalId = `homeassistant-${calId}`; } if (!cb.checked && cacheCalId !== null) { // Hiding: filter from cache instantly, no network call needed eventCache.events = eventCache.events.filter(ev => ev.calendar_id !== cacheCalId); state.events = eventCache.events; renderView(); updateTitle(); renderMiniCal(); } else { // Showing: refetch silently — view stays visible, updates when done fetchAndRender(true, true); } }); }); // ── Color dot handlers ───────────────────────────────── container.querySelectorAll('.cal-item-dot').forEach(dot => { dot.addEventListener('click', async e => { e.stopPropagation(); const source = dot.dataset.source; if (source === 'caldav') { openCalColorPicker(dot, parseInt(dot.dataset.calId)); } else if (source === 'local') { const calId = parseInt(dot.dataset.calId); const cal = state.localCalendars.find(c => c.id === calId); const picked = await openColorPicker(dot, cal?.color || '#34a853'); if (picked) { await api.put(`/local/calendars/${calId}`, { color: picked }); if (cal) cal.color = picked; applyCalendarColor('local', calId, picked); } } else if (source === 'ical') { const subId = parseInt(dot.dataset.subId); const sub = state.icalSubscriptions.find(s => s.id === subId); const picked = await openColorPicker(dot, sub?.color || '#46bdc6'); if (picked) { await api.put(`/ical/subscriptions/${subId}`, { color: picked }); if (sub) sub.color = picked; applyCalendarColor('ical', subId, picked); } } else if (source === 'google') { const calId = parseInt(dot.dataset.calId); let gcal = null; for (const acc of state.googleAccounts) { gcal = acc.calendars.find(c => c.id === calId); if (gcal) break; } const picked = await openColorPicker(dot, gcal?.color || '#4285f4'); if (picked) { await api.put(`/google/calendars/${calId}`, { color: picked }); if (gcal) gcal.color = picked; applyCalendarColor('google', calId, picked); } } else if (source === 'homeassistant') { const calId = parseInt(dot.dataset.calId); let hacal = null; for (const acc of state.haAccounts) { hacal = acc.calendars.find(c => c.id === calId); if (hacal) break; } const picked = await openColorPicker(dot, hacal?.color || '#03a9f4'); if (picked) { await api.put(`/homeassistant/calendars/${calId}`, { color: picked }); if (hacal) hacal.color = picked; applyCalendarColor('homeassistant', calId, picked); } } }); }); // ── Rename on double-click ───────────────────────────── container.querySelectorAll('.cal-item-name').forEach(nameEl => { nameEl.addEventListener('dblclick', e => { e.stopPropagation(); const item = nameEl.closest('.cal-item'); const source = nameEl.dataset.source; const currentName = nameEl.textContent; const input = document.createElement('input'); input.type = 'text'; input.value = currentName; input.className = 'cal-rename-input'; nameEl.replaceWith(input); input.focus(); input.select(); const save = async () => { const newName = input.value.trim(); if (newName && newName !== currentName) { if (source === 'caldav') { const calId = parseInt(item.dataset.calId); await api.put(`/caldav/calendars/${calId}`, { name: newName }); for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.id === calId) cal.name = newName; } } } else if (source === 'local') { const calId = parseInt(item.dataset.calId); await api.put(`/local/calendars/${calId}`, { name: newName }); const cal = state.localCalendars.find(c => c.id === calId); if (cal) cal.name = newName; } else if (source === 'ical') { const subId = parseInt(item.dataset.subId); await api.put(`/ical/subscriptions/${subId}`, { name: newName }); const sub = state.icalSubscriptions.find(s => s.id === subId); if (sub) sub.name = newName; } } renderCalendarList(); }; input.addEventListener('keydown', e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') renderCalendarList(); }); input.addEventListener('blur', save); }); }); // ── Remove handlers ──────────────────────────────────── container.querySelectorAll('.cal-item-remove').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); const source = btn.dataset.source; let cacheCalId = null; if (source === 'caldav') { const calId = parseInt(btn.dataset.calId); await api.put(`/caldav/calendars/${calId}`, { enabled: false, sidebar_hidden: true }); for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; } } } cacheCalId = calId; } else if (source === 'local') { if (!confirm(t('confirm_delete_local_cal'))) return; const calId = parseInt(btn.dataset.calId); await api.delete(`/local/calendars/${calId}`); state.localCalendars = state.localCalendars.filter(c => c.id !== calId); cacheCalId = `local-${calId}`; } else if (source === 'ical') { if (!confirm(t('confirm_remove_ical'))) return; const subId = parseInt(btn.dataset.subId); await api.delete(`/ical/subscriptions/${subId}`); state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId); cacheCalId = `ical-${subId}`; } else if (source === 'google') { const calId = parseInt(btn.dataset.calId); await api.put(`/google/calendars/${calId}`, { enabled: false, sidebar_hidden: true }); for (const acc of state.googleAccounts) { for (const cal of acc.calendars) { if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; } } } cacheCalId = `google-${calId}`; } else if (source === 'homeassistant') { const calId = parseInt(btn.dataset.calId); await api.put(`/homeassistant/calendars/${calId}`, { enabled: false, sidebar_hidden: true }); for (const acc of state.haAccounts) { for (const cal of acc.calendars) { if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; } } } cacheCalId = `homeassistant-${calId}`; } if (cacheCalId !== null) { eventCache.events = eventCache.events.filter(ev => ev.calendar_id !== cacheCalId); state.events = eventCache.events; } renderCalendarList(); renderView(); updateTitle(); renderMiniCal(); }); }); } // ── Swipe navigation + long-press → context menu (mobile) ── function bindSwipeNavigation() { const container = document.getElementById('view-container'); if (!container) return; let startX = 0, startY = 0, startT = 0, active = false; let lpTimer = null, lpTarget = null, lpFired = false; container.addEventListener('touchstart', e => { if (e.touches.length !== 1) { active = false; return; } startX = e.touches[0].clientX; startY = e.touches[0].clientY; startT = Date.now(); active = true; lpFired = false; // Long-press → context menu (only on day cells, not on events) lpTarget = e.target.closest('.month-col, .week-day-col'); if (lpTarget && !e.target.closest('.month-span-event, .week-event')) { lpTimer = setTimeout(() => { const t = e.touches[0]; const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: t.clientX, clientY: t.clientY, }); lpTarget.dispatchEvent(ev); lpFired = true; }, 500); } }, { passive: true }); container.addEventListener('touchmove', e => { if (!active) return; const t = e.touches[0]; if (Math.abs(t.clientX - startX) > 8 || Math.abs(t.clientY - startY) > 8) { if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } } }, { passive: true }); container.addEventListener('touchend', e => { if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } if (!active) return; active = false; // Suppress the click that follows a long-press if (lpFired) { const blocker = ev => { ev.stopPropagation(); ev.preventDefault(); }; document.addEventListener('click', blocker, { capture: true, once: true }); lpFired = false; return; } const t = e.changedTouches[0]; const dx = t.clientX - startX; const dy = t.clientY - startY; const dt = Date.now() - startT; // Horizontal swipe: ≥ 60px, mostly horizontal, faster than 700ms if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && dt < 700) { navigate(dx < 0 ? 1 : -1); fetchAndRender(); } }, { passive: true }); container.addEventListener('touchcancel', () => { if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } active = false; }, { passive: true }); } // ── Navigation ──────────────────────────────────────────── function navigate(dir) { const d = state.currentDate; if (state.currentView === 'month') { // Buttons jump 4 weeks (one screenful) state.currentDate = new Date(d); state.currentDate.setDate(d.getDate() + dir * 28); } else if (state.currentView === 'week') { state.currentDate = new Date(d); state.currentDate.setDate(d.getDate() + dir * 7); } else if (state.currentView === 'day') { state.currentDate = new Date(d); state.currentDate.setDate(d.getDate() + dir); } else if (state.currentView === 'quarter') { state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir * 3, 1); } else { state.currentDate = new Date(d); state.currentDate.setDate(d.getDate() + dir * 30); } fetchAndRender(); } // ── Topbar bindings ─────────────────────────────────────── function bindTopbar() { document.getElementById('btn-today').onclick = () => { state.currentDate = new Date(); fetchAndRender(); }; document.getElementById('btn-prev').onclick = () => navigate(-1); document.getElementById('btn-next').onclick = () => navigate(1); document.querySelectorAll('.view-btn').forEach(btn => { btn.addEventListener('click', () => { state.currentView = btn.dataset.view; updateViewButtons(); fetchAndRender(); }); }); document.getElementById('btn-settings').onclick = openSettingsModal; document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.selectedDate || state.currentDate); const fab = document.getElementById('btn-create-fab'); if (fab) fab.onclick = () => openNewEventModal(state.selectedDate || state.currentDate); // Mobile view-toggle popup const viewMobileBtn = document.getElementById('btn-view-mobile'); const viewMobileDropdown = document.getElementById('view-mobile-dropdown'); if (viewMobileBtn && viewMobileDropdown) { viewMobileBtn.onclick = e => { e.stopPropagation(); viewMobileDropdown.classList.toggle('hidden'); }; document.addEventListener('click', e => { if (!viewMobileDropdown.contains(e.target) && !viewMobileBtn.contains(e.target)) { viewMobileDropdown.classList.add('hidden'); } }); viewMobileDropdown.querySelectorAll('[data-mobile-view]').forEach(btn => { btn.onclick = () => { state.currentView = btn.dataset.mobileView; updateViewButtons(); fetchAndRender(); viewMobileDropdown.classList.add('hidden'); }; }); const todayMobile = document.getElementById('btn-today-mobile'); if (todayMobile) todayMobile.onclick = () => { state.currentDate = new Date(); fetchAndRender(); viewMobileDropdown.classList.add('hidden'); }; } // Settings entry inside the user dropdown (mobile) const settingsFromUser = document.getElementById('btn-settings-from-user'); if (settingsFromUser) settingsFromUser.onclick = () => { document.getElementById('user-dropdown').classList.add('hidden'); openSettingsModal(); }; // Settings nav hamburger (only does something on mobile via CSS) const settingsNavToggle = document.getElementById('settings-nav-toggle'); const settingsCard = document.querySelector('#modal-settings .settings-page-card'); const settingsNavBackdrop = document.getElementById('settings-nav-backdrop'); if (settingsNavToggle && settingsCard) { settingsNavToggle.onclick = () => settingsCard.classList.toggle('nav-open'); } if (settingsNavBackdrop && settingsCard) { settingsNavBackdrop.onclick = () => settingsCard.classList.remove('nav-open'); } // After picking a section in the nav, close the overlay (mobile UX) document.querySelectorAll('.settings-nav-btn').forEach(btn => { btn.addEventListener('click', () => { if (settingsCard) settingsCard.classList.remove('nav-open'); }); }); // Mouse wheel / trackpad scroll navigation – only for month & quarter let _wheelLast = 0; document.getElementById('view-container').addEventListener('wheel', e => { if (state.currentView !== 'month' && state.currentView !== 'quarter') return; e.preventDefault(); const now = Date.now(); if (now - _wheelLast < 500) return; _wheelLast = now; const dir = e.deltaY > 0 ? 1 : -1; if (state.currentView === 'month') { state.currentDate = new Date(state.currentDate); state.currentDate.setDate(state.currentDate.getDate() + dir * 7); fetchAndRender(); } else { navigate(dir); } }, { passive: false }); } // ── Sidebar toggle ──────────────────────────────────────── function bindSidebar() { document.getElementById('sidebar-toggle').onclick = () => { document.getElementById('sidebar').classList.toggle('collapsed'); document.body.classList.toggle('sidebar-open'); // mobile slide-in }; const backdrop = document.getElementById('sidebar-backdrop'); if (backdrop) backdrop.onclick = () => document.body.classList.remove('sidebar-open'); // Add calendar dropdown const addBtn = document.getElementById('btn-add-cal'); const dropdown = document.getElementById('add-cal-dropdown'); addBtn.onclick = e => { e.stopPropagation(); dropdown.classList.toggle('hidden'); }; document.addEventListener('click', e => { if (!dropdown.contains(e.target) && e.target !== addBtn) { dropdown.classList.add('hidden'); } }); dropdown.querySelector('[data-action="caldav"]').onclick = () => { dropdown.classList.add('hidden'); openAccountModal(); }; dropdown.querySelector('[data-action="local"]').onclick = () => { dropdown.classList.add('hidden'); openLocalCalModal(); }; dropdown.querySelector('[data-action="ical"]').onclick = () => { dropdown.classList.add('hidden'); openICalSubModal(); }; dropdown.querySelector('[data-action="homeassistant"]').onclick = () => { dropdown.classList.add('hidden'); openHAAccountModal(); }; dropdown.querySelector('[data-action="google"]').onclick = async () => { dropdown.classList.add('hidden'); try { const { configured } = await api.get('/google/configured'); if (!configured) { showToast(t('google_not_configured'), true); return; } const { url } = await api.get('/google/auth-url'); window.location.href = url; } catch (e) { showToast(t('error_prefix') + e.message, true); } }; } // ── Day Context Menu (month view) ──────────────────────── // ── Delete logic ────────────────────────────────────────── async function deleteEventByScope(ev, scope) { if (scope === 'all' || !(ev.rrule || ev.recurring)) { // Delete the entire event (or non-recurring) if (ev.source === 'google') { const accId = ev.calendar_id.replace('google-', ''); await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`); } else if (ev.source === 'local') { await api.delete(`/local/events/${encodeURIComponent(ev.id)}`); } else if (ev.source === 'ical') { const subId = ev.calendar_id.replace('ical-', ''); await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`); } else if (ev.source === 'homeassistant') { const haCalId = ev.calendar_id.replace('homeassistant-', ''); await api.delete(`/homeassistant/events/${haCalId}/${encodeURIComponent(ev.id)}`); } else { await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`); } } else { // Delete single occurrence: add EXDATE to exclude this date const exdate = ev.start.slice(0, 10).replace(/-/g, ''); if (ev.source === 'local') { // For local events: update rrule with EXDATE via a special field const currentRrule = ev.rrule || ''; await api.put(`/local/events/${encodeURIComponent(ev.id)}`, { exdate: exdate, }); } else { // For CalDAV: pass exdate to update await api.put( `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`, { exdate: exdate } ); } } } // ── Delete Confirm Dialog ───────────────────────────────── function showDeleteConfirm(ev) { return new Promise(resolve => { const modal = document.getElementById('modal-delete-confirm'); const isRecurring = !!(ev.rrule || ev.recurring); document.getElementById('delete-confirm-title').textContent = t('confirm_delete_title'); document.getElementById('delete-confirm-text').textContent = t('confirm_delete_event', { title: ev.title }); document.getElementById('delete-series-options').classList.toggle('hidden', !isRecurring); // Reset radio const radios = modal.querySelectorAll('input[name="delete-scope"]'); radios[0].checked = true; // Labels const labels = modal.querySelectorAll('#delete-series-options label'); if (labels[0]) labels[0].lastChild.textContent = ' ' + t('delete_single'); if (labels[1]) labels[1].lastChild.textContent = ' ' + t('delete_all_series'); openModal('modal-delete-confirm'); const okBtn = document.getElementById('delete-confirm-ok'); const cleanup = () => { okBtn.onclick = null; modal.querySelectorAll('[data-modal="modal-delete-confirm"]').forEach(b => b.onclick = null); }; okBtn.onclick = () => { const scope = isRecurring ? modal.querySelector('input[name="delete-scope"]:checked')?.value || 'single' : 'single'; cleanup(); closeModal('modal-delete-confirm'); resolve(scope); }; modal.querySelectorAll('[data-modal="modal-delete-confirm"]').forEach(b => { b.onclick = () => { cleanup(); closeModal('modal-delete-confirm'); resolve(null); }; }); }); } function showDayContextMenu(date, mouseEvent) { document.querySelectorAll('.cal-context-menu').forEach(m => m.remove()); const menu = document.createElement('div'); menu.className = 'cal-context-menu'; menu.innerHTML = `
${t('ctx_create_event')}
`; menu.style.left = mouseEvent.clientX + 'px'; menu.style.top = mouseEvent.clientY + 'px'; document.body.appendChild(menu); menu.querySelector('[data-action="create"]').onclick = () => { menu.remove(); openNewEventModal(date); }; const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } }; setTimeout(() => document.addEventListener('click', close), 0); } // ── Event Popup ─────────────────────────────────────────── function showEventPopup(ev, anchor) { const popup = document.getElementById('popup-event'); document.getElementById('popup-copy-menu').classList.add('hidden'); popup.classList.remove('hidden'); const color = ev.color || ev.calendarColor || '#4285f4'; document.getElementById('popup-color-dot').style.background = color; document.getElementById('popup-title').textContent = ev.title; // Time if (ev.allDay) { document.getElementById('popup-time').textContent = t('allday_cap'); } else { const s = new Date(ev.start); const e = new Date(ev.end); const sameDay = s.getFullYear() === e.getFullYear() && s.getMonth() === e.getMonth() && s.getDate() === e.getDate(); document.getElementById('popup-time').textContent = sameDay ? `${fmtDatetime(s)} – ${fmtTime(e)}` : `${fmtDatetime(s)} – ${fmtDatetime(e)}`; } document.getElementById('popup-location').textContent = ev.location || ''; document.getElementById('popup-location').style.display = ev.location ? '' : 'none'; document.getElementById('popup-description').textContent = ev.description || ''; document.getElementById('popup-description').style.display = ev.description ? '' : 'none'; document.getElementById('popup-calendar').textContent = ev.calendar_name || ''; // Position near anchor const rect = anchor.getBoundingClientRect(); const pw = 300, ph = 200; let left = rect.right + 8; let top = rect.top; if (left + pw > window.innerWidth) left = rect.left - pw - 8; if (top + ph > window.innerHeight) top = window.innerHeight - ph - 16; popup.style.left = Math.max(8, left) + 'px'; popup.style.top = Math.max(8, top) + 'px'; // Hide edit/delete for read-only iCal subscription events const isReadOnly = (ev.source === 'ical'); document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : ''; document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : ''; document.getElementById('popup-edit').onclick = () => { popup.classList.add('hidden'); openEditEventModal(ev); }; // Copy to calendar document.getElementById('popup-copy').onclick = e => { e.stopPropagation(); const menu = document.getElementById('popup-copy-menu'); if (!menu.classList.contains('hidden')) { menu.classList.add('hidden'); return; } const targets = buildWritableCalendars(ev); if (!targets.length) { showToast('Keine Zielkalender verfügbar', true); return; } menu.innerHTML = `` + `` + targets.map(c => `` ).join(''); menu.classList.remove('hidden'); // Stop clicks on the checkbox label from closing the menu menu.querySelector('.popup-copy-edit-toggle').addEventListener('click', e2 => e2.stopPropagation()); menu.querySelectorAll('.popup-copy-item').forEach(el => { el.addEventListener('click', async ev2 => { ev2.stopPropagation(); const editFirst = document.getElementById('popup-copy-edit-cb').checked; menu.classList.add('hidden'); popup.classList.add('hidden'); const cal = targets[parseInt(el.dataset.calIdx)]; if (editFirst) { openCopyEditModal(ev, cal); } else { await copyEventToCalendar(ev, cal); } }); }); }; document.getElementById('popup-delete').onclick = async () => { popup.classList.add('hidden'); const scope = await showDeleteConfirm(ev); if (!scope) return; try { await deleteEventByScope(ev, scope); showToast(t('event_deleted')); fetchAndRender(true); } catch (e) { showToast(e.message, true); } }; document.getElementById('popup-close').onclick = () => popup.classList.add('hidden'); } // Close popup on outside click document.addEventListener('click', e => { const popup = document.getElementById('popup-event'); if (!popup.classList.contains('hidden') && !popup.contains(e.target)) { popup.classList.add('hidden'); } }); // ── Event Modal ─────────────────────────────────────────── function populateCalendarSelect(selectedId) { const sel = document.getElementById('ev-calendar'); sel.innerHTML = ''; // CalDAV calendars (show all that aren't removed from sidebar, even if unchecked) state.accounts.forEach(acc => { acc.calendars.filter(c => !c.sidebar_hidden).forEach(cal => { const opt = document.createElement('option'); opt.value = cal.id; opt.textContent = `${acc.name} / ${cal.name}`; if (cal.id === selectedId) opt.selected = true; sel.appendChild(opt); }); }); // Local calendars state.localCalendars.filter(c => !c.sidebar_hidden).forEach(cal => { const opt = document.createElement('option'); opt.value = `local-${cal.id}`; opt.textContent = cal.name; if (`local-${cal.id}` === selectedId) opt.selected = true; sel.appendChild(opt); }); // iCal subscriptions are read-only, not shown here // Google calendars (read/write) state.googleAccounts.forEach(acc => { acc.calendars.filter(c => !c.sidebar_hidden).forEach(cal => { const opt = document.createElement('option'); opt.value = `google-${cal.id}`; opt.textContent = `${acc.email} / ${cal.name}`; if (`google-${cal.id}` === selectedId) opt.selected = true; sel.appendChild(opt); }); }); // Home Assistant calendars state.haAccounts.forEach(acc => { acc.calendars.filter(c => !c.sidebar_hidden).forEach(cal => { const opt = document.createElement('option'); opt.value = `homeassistant-${cal.id}`; opt.textContent = `${acc.name} / ${cal.name}`; if (`homeassistant-${cal.id}` === selectedId) opt.selected = true; sel.appendChild(opt); }); }); } // ── Date field helpers ──────────────────────────────────── // All-day events use exclusive end-dates (iCal RFC 5545 convention): // DTEND points to the day AFTER the last visible day. The user picker // uses inclusive end-dates ("ends on 18.08" means 18.08 is the last // day). These helpers convert between the two. function shiftDate(isoDate, deltaDays) { if (!isoDate) return isoDate; const [y, m, d] = isoDate.slice(0, 10).split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + deltaDays); return dt.toISOString().slice(0, 10); } const allDayEndToInclusive = iso => shiftDate(iso, -1); // storage → picker const allDayEndToExclusive = iso => shiftDate(iso, +1); // picker → storage function setDtValue(id, isoStr, mode) { const input = document.getElementById(id); if (input) input.value = isoStr || ''; const display = document.getElementById(id + '-display'); if (display) { display.querySelector('.dt-display-text').textContent = formatDtDisplay(isoStr, mode, getLang()); } } function openNewEventModal(date) { state.editingEvent = null; state.selectedEventColor = ''; document.getElementById('modal-event-title-label').textContent = 'Termin erstellen'; document.getElementById('ev-title').value = ''; document.getElementById('ev-location').value = ''; document.getElementById('ev-description').value = ''; document.getElementById('ev-allday').checked = false; const start = new Date(date); const end = new Date(date); end.setHours(end.getHours() + 1); setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime'); setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime'); setDtValue('ev-start-date', toDateInput(start), 'date'); setDtValue('ev-end-date', toDateInput(start), 'date'); toggleAlldayFields(false); populateCalendarSelect(null); resetColorPicker(''); resetRecurrenceUI(); document.getElementById('ev-delete').classList.add('hidden'); openModal('modal-event'); } // Open the create-event modal pre-filled with an existing event's data, so // the user can edit before copying it into the target calendar. function openCopyEditModal(ev, targetCal) { state.editingEvent = null; state.selectedEventColor = ev.color || ''; document.getElementById('modal-event-title-label').textContent = 'Termin erstellen'; document.getElementById('ev-title').value = ev.title || ''; document.getElementById('ev-location').value = ev.location || ''; document.getElementById('ev-description').value = ev.description || ''; document.getElementById('ev-allday').checked = !!ev.allDay; if (ev.allDay) { setDtValue('ev-start-date', (ev.start || '').slice(0, 10), 'date'); setDtValue('ev-end-date', allDayEndToInclusive((ev.end || '').slice(0, 10)), 'date'); } else { const s = new Date(ev.start); const e = new Date(ev.end); setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime'); setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime'); } toggleAlldayFields(!!ev.allDay); // Map target calendar to the dropdown's option value let selectedId; if (targetCal.type === 'caldav') selectedId = targetCal.id; else selectedId = `${targetCal.type}-${targetCal.id}`; populateCalendarSelect(selectedId); resetColorPicker(ev.color || ''); resetRecurrenceUI(); if (ev.rrule) parseRruleIntoUI(ev.rrule); document.getElementById('ev-delete').classList.add('hidden'); openModal('modal-event'); } function openEditEventModal(ev) { if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; } state.editingEvent = ev; state.selectedEventColor = ev.color || ''; document.getElementById('modal-event-title-label').textContent = 'Termin bearbeiten'; document.getElementById('ev-title').value = ev.title; document.getElementById('ev-location').value = ev.location || ''; document.getElementById('ev-description').value = ev.description || ''; document.getElementById('ev-allday').checked = ev.allDay; if (ev.allDay) { setDtValue('ev-start-date', ev.start.slice(0, 10), 'date'); setDtValue('ev-end-date', allDayEndToInclusive(ev.end.slice(0, 10)), 'date'); toggleAlldayFields(true); } else { const s = new Date(ev.start); const e = new Date(ev.end); setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime'); setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime'); toggleAlldayFields(false); } populateCalendarSelect(ev.calendar_id); resetColorPicker(ev.color || ''); // Recurrence const rrule = ev.rrule || ''; const recSel = document.getElementById('ev-recurrence'); const customPanel = document.getElementById('ev-recurrence-custom'); if (!rrule) { recSel.value = ''; customPanel.classList.add('hidden'); } else if (['FREQ=DAILY', 'FREQ=WEEKLY', 'FREQ=MONTHLY', 'FREQ=YEARLY'].includes(rrule)) { recSel.value = rrule; customPanel.classList.add('hidden'); } else { recSel.value = 'custom'; customPanel.classList.remove('hidden'); parseRruleIntoUI(rrule); } document.getElementById('ev-delete').classList.remove('hidden'); openModal('modal-event'); } function toggleAlldayFields(allDay) { document.getElementById('ev-time-row').style.display = allDay ? 'none' : ''; document.getElementById('ev-date-row').style.display = allDay ? '' : 'none'; } function resetColorPicker(color) { state.selectedEventColor = color; const hex = document.getElementById('ev-color-hex'); const preview = document.getElementById('ev-color-preview'); hex.value = color ? color.toUpperCase() : ''; preview.style.background = color || 'var(--primary)'; } function buildRruleFromUI() { const sel = document.getElementById('ev-recurrence').value; if (!sel) return null; if (sel !== 'custom') return sel; const interval = parseInt(document.getElementById('ev-rec-interval').value) || 1; const freq = document.getElementById('ev-rec-freq').value; let rule = `FREQ=${freq}`; if (interval > 1) rule += `;INTERVAL=${interval}`; if (freq === 'WEEKLY') { const days = [...document.querySelectorAll('.rec-day-btn.active')].map(b => b.dataset.day); if (days.length) rule += `;BYDAY=${days.join(',')}`; } const endType = document.getElementById('ev-rec-end-type').value; if (endType === 'count') { rule += `;COUNT=${parseInt(document.getElementById('ev-rec-count').value) || 10}`; } else if (endType === 'until') { const until = document.getElementById('ev-rec-until').value; if (until) rule += `;UNTIL=${until.replace(/-/g, '')}T235959Z`; } return rule; } function parseRruleIntoUI(rruleStr) { const parts = {}; rruleStr.split(';').forEach(p => { const [k, v] = p.split('=', 2); if (k && v) parts[k] = v; }); document.getElementById('ev-rec-interval').value = parts.INTERVAL || '1'; document.getElementById('ev-rec-freq').value = parts.FREQ || 'DAILY'; document.getElementById('ev-rec-weekdays').classList.toggle('hidden', parts.FREQ !== 'WEEKLY'); // Reset all weekday buttons document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active')); if (parts.BYDAY) { parts.BYDAY.split(',').forEach(day => { const btn = document.querySelector(`.rec-day-btn[data-day="${day.trim()}"]`); if (btn) btn.classList.add('active'); }); } if (parts.COUNT) { document.getElementById('ev-rec-end-type').value = 'count'; document.getElementById('ev-rec-count').value = parts.COUNT; document.getElementById('ev-rec-end-count').classList.remove('hidden'); document.getElementById('ev-rec-end-until').classList.add('hidden'); } else if (parts.UNTIL) { document.getElementById('ev-rec-end-type').value = 'until'; // Parse UNTIL: 20260501T235959Z → 2026-05-01 const u = parts.UNTIL.replace('Z', ''); const formatted = u.length >= 8 ? `${u.slice(0,4)}-${u.slice(4,6)}-${u.slice(6,8)}` : ''; if (formatted) setDtValue('ev-rec-until', formatted, 'date'); document.getElementById('ev-rec-end-count').classList.add('hidden'); document.getElementById('ev-rec-end-until').classList.remove('hidden'); } else { document.getElementById('ev-rec-end-type').value = 'never'; document.getElementById('ev-rec-end-count').classList.add('hidden'); document.getElementById('ev-rec-end-until').classList.add('hidden'); } } function resetRecurrenceUI() { document.getElementById('ev-recurrence').value = ''; document.getElementById('ev-recurrence-custom').classList.add('hidden'); document.getElementById('ev-rec-interval').value = '1'; document.getElementById('ev-rec-freq').value = 'DAILY'; document.getElementById('ev-rec-weekdays').classList.add('hidden'); document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active')); document.getElementById('ev-rec-end-type').value = 'never'; document.getElementById('ev-rec-end-count').classList.add('hidden'); document.getElementById('ev-rec-end-until').classList.add('hidden'); } function bindEventModal() { document.getElementById('ev-allday').addEventListener('change', e => { toggleAlldayFields(e.target.checked); }); // Date/time pickers with auto-adjustment logic [ { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' }, { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime', role: 'end' }, { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date', role: 'start' }, { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date', role: 'end' }, ].forEach(({ displayId, inputId, mode, role }) => { const disp = document.getElementById(displayId); if (!disp) return; const open = async () => { const current = document.getElementById(inputId)?.value || ''; const oldStart = mode === 'datetime' ? document.getElementById('ev-start').value : document.getElementById('ev-start-date').value; const oldEnd = mode === 'datetime' ? document.getElementById('ev-end').value : document.getElementById('ev-end-date').value; const result = await openDatePicker(disp, current, mode); if (result === null) return; setDtValue(inputId, result, mode); if (role === 'start') { // Adjust end to maintain duration if (mode === 'datetime') { const os = oldStart ? new Date(oldStart) : null; const oe = oldEnd ? new Date(oldEnd) : null; const ns = new Date(result); const duration = (os && oe && oe > os) ? (oe - os) : 3600000; const ne = new Date(ns.getTime() + duration); setDtValue('ev-end', toLocalDatetimeInput(ne), 'datetime'); } else { const endVal = document.getElementById('ev-end-date').value; if (!endVal || endVal < result) { setDtValue('ev-end-date', result, 'date'); } } } else { // Validate end is not before start if (mode === 'datetime') { const startVal = document.getElementById('ev-start').value; if (startVal && new Date(result) <= new Date(startVal)) { const corrected = new Date(new Date(startVal).getTime() + 3600000); setDtValue('ev-end', toLocalDatetimeInput(corrected), 'datetime'); showToast(t('error_end_before_start'), true); } } else { const startVal = document.getElementById('ev-start-date').value; if (startVal && result < startVal) { setDtValue('ev-end-date', startVal, 'date'); showToast(t('error_end_before_start'), true); } } } }; disp.addEventListener('click', open); disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); }); }); // Color picker: click preview to open gradient picker const evColorPreview = document.getElementById('ev-color-preview'); const evColorHex = document.getElementById('ev-color-hex'); evColorPreview.addEventListener('click', async () => { const current = state.selectedEventColor || '#4285f4'; const picked = await openColorPicker(evColorPreview, current); if (picked) resetColorPicker(picked); }); evColorHex.addEventListener('change', () => { let val = evColorHex.value.trim(); if (!val.startsWith('#')) val = '#' + val; if (/^#[0-9a-fA-F]{6}$/.test(val)) { resetColorPicker(val); } }); // ── Recurrence UI ────────────────────────────────────── const recSel = document.getElementById('ev-recurrence'); const customPanel = document.getElementById('ev-recurrence-custom'); const recFreq = document.getElementById('ev-rec-freq'); const weekdaysDiv = document.getElementById('ev-rec-weekdays'); const endTypeSel = document.getElementById('ev-rec-end-type'); recSel.addEventListener('change', () => { customPanel.classList.toggle('hidden', recSel.value !== 'custom'); }); recFreq.addEventListener('change', () => { weekdaysDiv.classList.toggle('hidden', recFreq.value !== 'WEEKLY'); }); document.querySelectorAll('.rec-day-btn').forEach(btn => { btn.addEventListener('click', () => btn.classList.toggle('active')); }); endTypeSel.addEventListener('change', () => { document.getElementById('ev-rec-end-count').classList.toggle('hidden', endTypeSel.value !== 'count'); document.getElementById('ev-rec-end-until').classList.toggle('hidden', endTypeSel.value !== 'until'); }); const untilDisp = document.getElementById('ev-rec-until-display'); if (untilDisp) { const openUntil = async () => { const current = document.getElementById('ev-rec-until').value || ''; const result = await openDatePicker(untilDisp, current, 'date'); if (result !== null) setDtValue('ev-rec-until', result, 'date'); }; untilDisp.addEventListener('click', openUntil); untilDisp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openUntil(); }); } document.getElementById('ev-save').onclick = async () => { const title = document.getElementById('ev-title').value.trim(); if (!title) { showToast(t('error_enter_title'), true); return; } const allDay = document.getElementById('ev-allday').checked; const calVal = document.getElementById('ev-calendar').value; const isLocal = calVal.startsWith('local-'); const isGoogle = calVal.startsWith('google-'); const isHA = calVal.startsWith('homeassistant-'); const loc = document.getElementById('ev-location').value.trim(); const desc = document.getElementById('ev-description').value.trim(); const color = state.selectedEventColor; const rrule = buildRruleFromUI(); let start, end; if (allDay) { start = document.getElementById('ev-start-date').value; end = document.getElementById('ev-end-date').value; if (!start) { showToast(t('error_enter_date'), true); return; } if (!end || end < start) end = start; // User picker uses inclusive end; storage uses exclusive (iCal convention) end = allDayEndToExclusive(end); } else { const sv = document.getElementById('ev-start').value; const ev2 = document.getElementById('ev-end').value; if (!sv) { showToast(t('error_enter_start'), true); return; } start = new Date(sv).toISOString(); end = ev2 ? new Date(ev2).toISOString() : new Date(new Date(sv).getTime() + 3600000).toISOString(); } try { if (state.editingEvent) { const ev = state.editingEvent; if (ev.source === 'google') { const accId = ev.calendar_id.replace('google-', ''); await api.put(`/google/events/${accId}/${encodeURIComponent(ev.id)}`, { title, start, end, allDay, location: loc, description: desc } ); } else if (ev.source === 'local') { await api.put(`/local/events/${encodeURIComponent(ev.id)}`, { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' } ); } else if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; } else if (ev.source === 'homeassistant') { const haCalId = ev.calendar_id.replace('homeassistant-', ''); await api.put(`/homeassistant/events/${haCalId}/${encodeURIComponent(ev.id)}`, { title, start, end, allDay, location: loc, description: desc } ); } else { await api.put( `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`, { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' } ); } // Patch the cached event in-place so the UI reflects changes immediately applyEventPatch(ev.id, ev.url, { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || null, }); showToast(t('event_updated')); } else if (isGoogle) { const calDbId = parseInt(calVal.replace('google-', '')); await api.post('/google/events', { calendar_db_id: calDbId, title, start, end, allDay, location: loc, description: desc, }); showToast(t('event_created')); } else if (isLocal) { const calId = parseInt(calVal.replace('local-', '')); await api.post('/local/events', { calendar_id: calId, title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || null, }); showToast(t('event_created')); } else if (isHA) { const haCalId = parseInt(calVal.replace('homeassistant-', '')); await api.post('/homeassistant/events', { calendar_id: haCalId, title, start, end, allDay, location: loc, description: desc, }); showToast(t('event_created')); } else { const calId = parseInt(calVal); await api.post('/caldav/events', { calendar_id: calId, title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || null, }); showToast(t('event_created')); } closeModal('modal-event'); fetchAndRender(true); } catch (e) { showToast(e.message, true); } }; document.getElementById('ev-delete').onclick = async () => { const ev = state.editingEvent; if (!ev) return; const scope = await showDeleteConfirm(ev); if (!scope) return; try { await deleteEventByScope(ev, scope); showToast(t('event_deleted')); closeModal('modal-event'); fetchAndRender(true); } catch (e) { showToast(e.message, true); } }; } // ── Account Modal ───────────────────────────────────────── function openAccountModal() { document.getElementById('acc-name').value = ''; document.getElementById('acc-url').value = ''; document.getElementById('acc-username').value = ''; document.getElementById('acc-password').value = ''; document.getElementById('acc-color-hex').value = '#4285f4'; document.getElementById('acc-color-preview').style.background = '#4285f4'; document.getElementById('acc-error').classList.add('hidden'); openModal('modal-account'); } function bindAccountModal() { const accPreview = document.getElementById('acc-color-preview'); const accHex = document.getElementById('acc-color-hex'); accPreview.addEventListener('click', async () => { const picked = await openColorPicker(accPreview, accHex.value || '#4285f4'); if (picked) { accHex.value = picked.toUpperCase(); accPreview.style.background = picked; } }); accHex.addEventListener('change', () => { let val = accHex.value.trim(); if (!val.startsWith('#')) val = '#' + val; if (/^#[0-9a-fA-F]{6}$/.test(val)) { accHex.value = val.toUpperCase(); accPreview.style.background = val; } }); document.getElementById('acc-save').onclick = async () => { const name = document.getElementById('acc-name').value.trim(); const url = document.getElementById('acc-url').value.trim(); const username = document.getElementById('acc-username').value.trim(); const password = document.getElementById('acc-password').value; const color = document.getElementById('acc-color-hex').value; const errEl = document.getElementById('acc-error'); if (!name || !url || !username || !password) { errEl.textContent = 'Bitte alle Felder ausfüllen'; errEl.classList.remove('hidden'); return; } errEl.classList.add('hidden'); document.getElementById('acc-save').disabled = true; document.getElementById('acc-save').textContent = 'Verbinde…'; try { const acc = await api.post('/caldav/accounts', { name, url, username, password, color }); state.accounts.push(acc); renderCalendarList(); closeModal('modal-account'); showToast(t("account_added", {name})); fetchAndRender(true); } catch (e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); } finally { document.getElementById('acc-save').disabled = false; document.getElementById('acc-save').textContent = 'Verbinden'; } }; } // ── Local Calendar Modal ────────────────────────────────── function openLocalCalModal() { document.getElementById('local-cal-name').value = ''; document.getElementById('local-cal-color-hex').value = '#34a853'; document.getElementById('local-cal-color-preview').style.background = '#34a853'; openModal('modal-local-cal'); } function bindLocalCalModal() { const preview = document.getElementById('local-cal-color-preview'); const hex = document.getElementById('local-cal-color-hex'); preview.addEventListener('click', async () => { const picked = await openColorPicker(preview, hex.value || '#34a853'); if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; } }); hex.addEventListener('change', () => { let val = hex.value.trim(); if (!val.startsWith('#')) val = '#' + val; if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; } }); document.getElementById('local-cal-save').onclick = async () => { const name = document.getElementById('local-cal-name').value.trim(); if (!name) { showToast(t('error_enter_name'), true); return; } const color = hex.value; try { const cal = await api.post('/local/calendars', { name, color }); state.localCalendars.push(cal); renderCalendarList(); closeModal('modal-local-cal'); showToast(t("calendar_created", {name})); } catch (e) { showToast(e.message, true); } }; } // ── Home Assistant Account Modal ───────────────────────── function openHAAccountModal() { document.getElementById('ha-account-name').value = ''; document.getElementById('ha-account-url').value = ''; document.getElementById('ha-account-token').value = ''; document.getElementById('ha-account-error').classList.add('hidden'); // Reset to OAuth method document.getElementById('ha-auth-oauth').checked = true; document.getElementById('ha-oauth-info').classList.remove('hidden'); document.getElementById('ha-token-group').classList.add('hidden'); document.getElementById('ha-account-save').textContent = 'Mit Home Assistant anmelden'; openModal('modal-ha-account'); } function bindHAAccountModal() { // Toggle auth method fields + save button label document.querySelectorAll('[name="ha-auth-method"]').forEach(r => { r.addEventListener('change', () => { const isOAuth = document.getElementById('ha-auth-oauth').checked; document.getElementById('ha-oauth-info').classList.toggle('hidden', !isOAuth); document.getElementById('ha-token-group').classList.toggle('hidden', isOAuth); document.getElementById('ha-account-save').textContent = isOAuth ? 'Mit Home Assistant anmelden' : 'Verbinden'; }); }); document.getElementById('ha-account-save').onclick = async () => { const name = document.getElementById('ha-account-name').value.trim(); const url = document.getElementById('ha-account-url').value.trim(); const isOAuth = document.getElementById('ha-auth-oauth').checked; const errEl = document.getElementById('ha-account-error'); if (!name || !url) { errEl.textContent = 'Bitte Name und URL ausfüllen'; errEl.classList.remove('hidden'); return; } errEl.classList.add('hidden'); const saveBtn = document.getElementById('ha-account-save'); saveBtn.disabled = true; if (isOAuth) { saveBtn.textContent = 'Weiterleiten…'; try { const base = window.location.origin; const resp = await api.post('/homeassistant/auth-url', { name, url, client_id: base + '/', redirect_uri: base + '/api/homeassistant/callback', }); if (!resp) return; window.location.href = resp.url; } catch (e) { errEl.textContent = e.message || 'Fehler beim Starten der Anmeldung'; errEl.classList.remove('hidden'); saveBtn.disabled = false; saveBtn.textContent = 'Mit Home Assistant anmelden'; } return; } // Long-Lived Token flow const token = document.getElementById('ha-account-token').value.trim(); if (!token) { errEl.textContent = 'Bitte Access Token ausfüllen'; errEl.classList.remove('hidden'); saveBtn.disabled = false; return; } saveBtn.textContent = 'Verbinde…'; try { const account = await api.post('/homeassistant/accounts', { name, url, token }); if (!account) return; state.haAccounts.push(account); renderCalendarList(); closeModal('modal-ha-account'); showToast(`Home Assistant "${name}" verbunden`); fetchAndRender(true); } catch (e) { errEl.textContent = e.message || 'Home Assistant nicht erreichbar'; errEl.classList.remove('hidden'); } finally { saveBtn.disabled = false; saveBtn.textContent = 'Verbinden'; } }; } // ── iCal Subscription Modal ────────────────────────────── function openICalSubModal() { document.getElementById('ical-sub-name').value = ''; document.getElementById('ical-sub-url').value = ''; document.getElementById('ical-sub-color-hex').value = '#46bdc6'; document.getElementById('ical-sub-color-preview').style.background = '#46bdc6'; document.getElementById('ical-sub-refresh').value = '60'; document.getElementById('ical-sub-error').classList.add('hidden'); openModal('modal-ical-sub'); } function bindICalSubModal() { const preview = document.getElementById('ical-sub-color-preview'); const hex = document.getElementById('ical-sub-color-hex'); preview.addEventListener('click', async () => { const picked = await openColorPicker(preview, hex.value || '#46bdc6'); if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; } }); hex.addEventListener('change', () => { let val = hex.value.trim(); if (!val.startsWith('#')) val = '#' + val; if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; } }); document.getElementById('ical-sub-save').onclick = async () => { const name = document.getElementById('ical-sub-name').value.trim(); const url = document.getElementById('ical-sub-url').value.trim(); const errEl = document.getElementById('ical-sub-error'); if (!name || !url) { errEl.textContent = 'Bitte Name und URL eingeben'; errEl.classList.remove('hidden'); return; } errEl.classList.add('hidden'); const color = hex.value; const refresh_minutes = parseInt(document.getElementById('ical-sub-refresh').value); document.getElementById('ical-sub-save').disabled = true; document.getElementById('ical-sub-save').textContent = 'Lade…'; try { const sub = await api.post('/ical/subscriptions', { name, url, color, refresh_minutes }); state.icalSubscriptions.push(sub); renderCalendarList(); closeModal('modal-ical-sub'); showToast(t("ical_subscribed", {name})); fetchAndRender(true); } catch (e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); } finally { document.getElementById('ical-sub-save').disabled = false; document.getElementById('ical-sub-save').textContent = 'Abonnieren'; } }; } // ── Settings Modal ──────────────────────────────────────── function openSettingsModal() { const s = state.settings; document.getElementById('cfg-default-view').value = s.default_view || 'month'; document.getElementById('cfg-week-start').value = s.week_start_day || 'monday'; const colors = [ { id: 'cfg-primary', val: s.primary_color || '#4285f4' }, { id: 'cfg-accent', val: s.accent_color || '#ea4335' }, { id: 'cfg-today', val: s.today_color || '#4285f4' }, { id: 'cfg-month-divider', val: s.month_divider_color || '#7090c0' }, { id: 'cfg-month-label', val: s.month_label_color || '#7090c0' }, ]; colors.forEach(({ id, val }) => { document.getElementById(id + '-hex').value = val.toUpperCase(); document.getElementById(id + '-preview').style.background = val; }); document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; document.getElementById('cfg-language').value = getLang(); // Set active contrast/hour-height buttons [ { id: 'cfg-text-contrast', val: s.text_contrast || 3 }, { id: 'cfg-line-contrast', val: s.line_contrast || 3 }, { id: 'cfg-hour-height', val: s.hour_height || 60 }, ].forEach(({ id, val }) => { const sel = document.getElementById(id); if (!sel) return; sel.querySelectorAll('.contrast-btn').forEach(btn => { btn.classList.toggle('active', String(btn.dataset.val) === String(val)); }); }); // Show users nav button only for admins const user = JSON.parse(localStorage.getItem('user') || '{}'); const usersNavBtn = document.getElementById('settings-nav-users'); if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin); if (user.is_admin) loadUsers(); // Activate first panel const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)'); if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel); // Render all accounts and hidden calendars renderAllAccounts(); renderHiddenCalendars(); openModal('modal-settings'); } function activateSettingsPanel(panel) { document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.toggle('active', b.dataset.panel === panel)); document.querySelectorAll('.settings-panel').forEach(p => p.classList.toggle('active', p.id === 'settings-panel-' + panel)); } function renderGoogleAccounts() { const list = document.getElementById('google-accounts-list'); if (!list) return; if (!state.googleAccounts.length) { list.innerHTML = `${t('settings_no_google')}`; return; } list.innerHTML = state.googleAccounts.map(acc => `
${escHtml(acc.email)}
` ).join(''); list.querySelectorAll('[data-sync-acc]').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; btn.textContent = '…'; try { const updated = await api.post(`/google/accounts/${btn.dataset.syncAcc}/sync`); const idx = state.googleAccounts.findIndex(a => a.id === updated.id); if (idx !== -1) state.googleAccounts[idx] = updated; renderGoogleAccounts(); renderCalendarList(); fetchAndRender(true); showToast(t('google_synced')); } catch (e) { showToast(e.message, true); } }); }); list.querySelectorAll('[data-disconnect-acc]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm(t('confirm_google_disconnect'))) return; try { await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`); state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc)); renderGoogleAccounts(); renderCalendarList(); fetchAndRender(true); showToast(t('google_disconnected')); } catch (e) { showToast(e.message, true); } }); }); } function renderAllAccounts() { // CalDAV section const caldavList = document.getElementById('accounts-caldav-list'); if (caldavList) { if (!state.accounts.length) { caldavList.innerHTML = `${t('settings_no_caldav_accounts')}`; } else { caldavList.innerHTML = state.accounts.map(acc => `
${escHtml(acc.name)} ${escHtml(acc.url || '')}
` ).join(''); caldavList.querySelectorAll('[data-caldav-sync]').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; btn.textContent = '…'; try { await api.post(`/caldav/accounts/${btn.dataset.caldavSync}/sync`); renderCalendarList(); fetchAndRender(true); showToast(t('google_synced')); } catch (e) { showToast(e.message, true); } finally { btn.disabled = false; btn.textContent = t('sync'); } }); }); caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm(t('confirm_caldav_disconnect'))) return; try { await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`); state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect)); renderAllAccounts(); renderCalendarList(); fetchAndRender(true); showToast(t('caldav_disconnected')); } catch (e) { showToast(e.message, true); } }); }); } } // Local calendars section const localList = document.getElementById('accounts-local-list'); if (localList) { if (!state.localCalendars.length) { localList.innerHTML = `${t('settings_no_local_cals')}`; } else { localList.innerHTML = state.localCalendars.map(cal => `
${escHtml(cal.name)}
` ).join(''); } } // iCal subscriptions section const icalList = document.getElementById('accounts-ical-list'); if (icalList) { if (!state.icalSubscriptions.length) { icalList.innerHTML = `${t('settings_no_ical_subs')}`; } else { icalList.innerHTML = state.icalSubscriptions.map(sub => `
${escHtml(sub.name)} ${escHtml(sub.url || '')}
` ).join(''); icalList.querySelectorAll('[data-ical-delete]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm(t('confirm_remove_ical'))) return; try { await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`); state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete)); renderAllAccounts(); renderCalendarList(); fetchAndRender(true); } catch (e) { showToast(e.message, true); } }); }); } } // Google accounts section — delegate to existing function renderGoogleAccounts(); // Home Assistant accounts section const haList = document.getElementById('accounts-ha-list'); if (haList) { if (!state.haAccounts.length) { haList.innerHTML = 'Keine HA-Konten'; } else { haList.innerHTML = state.haAccounts.map(acc => `
${escHtml(acc.name)} ${escHtml(acc.url || '')}
` ).join(''); haList.querySelectorAll('[data-ha-sync]').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; btn.textContent = '…'; try { const updated = await api.post(`/homeassistant/accounts/${btn.dataset.haSync}/sync`); const idx = state.haAccounts.findIndex(a => a.id === parseInt(btn.dataset.haSync)); if (idx !== -1) state.haAccounts[idx] = updated; renderAllAccounts(); renderCalendarList(); fetchAndRender(true); showToast('Home Assistant synchronisiert'); } catch (e) { showToast(e.message, true); } finally { btn.disabled = false; btn.textContent = t('sync'); } }); }); haList.querySelectorAll('[data-ha-disconnect]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm('Home Assistant Konto wirklich trennen?')) return; try { await api.delete(`/homeassistant/accounts/${btn.dataset.haDisconnect}`); state.haAccounts = state.haAccounts.filter(a => a.id !== parseInt(btn.dataset.haDisconnect)); renderAllAccounts(); renderCalendarList(); fetchAndRender(true); showToast('Home Assistant getrennt'); } catch (e) { showToast(e.message, true); } }); }); } } } function renderHiddenCalendars() { const list = document.getElementById('hidden-cals-list'); const hidden = []; for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name, source: 'caldav' }); } } for (const acc of state.googleAccounts) { for (const cal of acc.calendars) { if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.email, source: 'google' }); } } for (const acc of state.haAccounts) { for (const cal of acc.calendars) { if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name, source: 'homeassistant' }); } } if (!hidden.length) { list.innerHTML = `${t('settings_no_hidden_cals')}`; return; } list.innerHTML = hidden.map(c => `
${escHtml(c.acc)} / ${escHtml(c.name)}
` ).join(''); list.querySelectorAll('[data-restore-cal]').forEach(btn => { btn.addEventListener('click', async () => { const calId = parseInt(btn.dataset.restoreCal); const source = btn.dataset.restoreSource; if (source === 'google') { await api.put(`/google/calendars/${calId}`, { enabled: true, sidebar_hidden: false }); for (const acc of state.googleAccounts) { for (const cal of acc.calendars) { if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; } } } } else if (source === 'homeassistant') { await api.put(`/homeassistant/calendars/${calId}`, { enabled: true, sidebar_hidden: false }); for (const acc of state.haAccounts) { for (const cal of acc.calendars) { if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; } } } } else { await api.put(`/caldav/calendars/${calId}`, { enabled: true, sidebar_hidden: false }); for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; } } } } renderHiddenCalendars(); renderCalendarList(); fetchAndRender(); }); }); } async function loadUsers() { try { const users = await api.get('/users/'); const list = document.getElementById('users-list'); list.innerHTML = users.map(u => `
${escHtml(u.username)}
${u.email ? `
${escHtml(u.email)}
` : ''}
${u.is_admin ? 'Admin' : ''} ${u.id !== JSON.parse(localStorage.getItem('user')||'{}').id ? `` : ''}
` ).join(''); list.querySelectorAll('[data-del-user]').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm(t('confirm_delete_user'))) return; try { await api.delete(`/users/${btn.dataset.delUser}`); loadUsers(); } catch (e) { showToast(e.message, true); } }); }); } catch (e) { /* not admin */ } } function bindSettingsModal() { ['cfg-primary','cfg-accent','cfg-today','cfg-month-divider','cfg-month-label'].forEach(prefix => { const preview = document.getElementById(prefix + '-preview'); const hex = document.getElementById(prefix + '-hex'); preview.addEventListener('click', async () => { const picked = await openColorPicker(preview, hex.value || '#4285f4'); if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; } }); hex.addEventListener('change', () => { let val = hex.value.trim(); if (!val.startsWith('#')) val = '#' + val; if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; } }); }); // Panel navigation document.querySelectorAll('.settings-nav-btn').forEach(btn => { btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel)); }); // Contrast / hour-height selectors document.querySelectorAll('.contrast-selector').forEach(sel => { sel.addEventListener('click', e => { const btn = e.target.closest('.contrast-btn'); if (!btn) return; sel.querySelectorAll('.contrast-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); document.getElementById('btn-add-user').onclick = () => { document.getElementById('add-user-form').classList.toggle('hidden'); }; document.getElementById('new-user-save').onclick = async () => { const username = document.getElementById('new-username').value.trim(); const password = document.getElementById('new-password').value; const is_admin = document.getElementById('new-is-admin').checked; if (!username || !password) { showToast(t('error_username_password'), true); return; } try { await api.post('/users/', { username, password, is_admin }); showToast(t("user_created", {name: username})); document.getElementById('add-user-form').classList.add('hidden'); document.getElementById('new-username').value = ''; document.getElementById('new-password').value = ''; loadUsers(); } catch (e) { showToast(e.message, true); } }; document.getElementById('settings-save').onclick = async () => { const getActive = (id) => { const btn = document.querySelector(`#${id} .contrast-btn.active`); return btn ? Number(btn.dataset.val) : null; }; const settings = { default_view: document.getElementById('cfg-default-view').value, week_start_day: document.getElementById('cfg-week-start').value, primary_color: document.getElementById('cfg-primary-hex').value, accent_color: document.getElementById('cfg-accent-hex').value, 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, 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, }; try { await api.put('/settings/', settings); state.settings = { ...state.settings, ...settings }; state.dimPast = settings.dim_past_events; weekStartDay = settings.week_start_day; setLang(settings.language); applyTheme(state.settings); showToast(t('settings_saved')); closeModal('modal-settings'); renderMiniCal(); fetchAndRender(); } catch (e) { showToast(e.message, true); } }; } // ── Profile Modal ───────────────────────────────────────── export function openProfileModal() { const user = JSON.parse(localStorage.getItem('user') || '{}'); // Username & email document.getElementById('profile-username').value = user.username || ''; document.getElementById('profile-display-name').textContent = user.username || ''; // Load fresh profile data api.get('/profile/').then(profile => { document.getElementById('profile-email').value = profile.email || ''; // Avatar const letter = document.getElementById('profile-avatar-letter'); const img = document.getElementById('profile-avatar-img'); const removeBtn = document.getElementById('profile-avatar-remove'); if (profile.has_avatar) { fetchAvatarBlob().then(blobUrl => { img.src = blobUrl; img.classList.remove('hidden'); letter.classList.add('hidden'); }).catch(() => { img.classList.add('hidden'); letter.classList.remove('hidden'); letter.textContent = (user.username || '?')[0].toUpperCase(); }); removeBtn.classList.remove('hidden'); } else { img.classList.add('hidden'); letter.classList.remove('hidden'); letter.textContent = (user.username || '?')[0].toUpperCase(); removeBtn.classList.add('hidden'); } // 2FA status document.getElementById('2fa-disabled-section').classList.toggle('hidden', profile.totp_enabled); document.getElementById('2fa-setup-section').classList.add('hidden'); document.getElementById('2fa-enabled-section').classList.toggle('hidden', !profile.totp_enabled); }).catch(() => {}); // Clear password fields document.getElementById('profile-pw-current').value = ''; document.getElementById('profile-pw-new').value = ''; document.getElementById('profile-pw-confirm').value = ''; document.getElementById('2fa-disable-pw').value = ''; document.getElementById('2fa-verify-code').value = ''; // Load calendars renderProfileCalendars(); openModal('modal-profile'); } function renderProfileCalendars() { const container = document.getElementById('profile-calendars'); if (!state.accounts.length) { container.innerHTML = `

${t('no_caldav')}

`; return; } const html = state.accounts.map(acc => acc.calendars.map(cal => `
${escHtml(cal.name)}
` ).join('') ).join(''); container.innerHTML = html; } function bindProfileModal() { // Save profile info (email) document.getElementById('profile-save-info').onclick = async () => { const email = document.getElementById('profile-email').value.trim(); try { await api.put('/profile/', { email: email || null }); showToast(t('profile_saved')); } catch (e) { showToast(e.message, true); } }; // Avatar upload document.getElementById('profile-avatar-upload').onclick = () => { document.getElementById('profile-avatar-input').click(); }; document.getElementById('profile-avatar-input').onchange = (e) => { const file = e.target.files[0]; if (!file) return; e.target.value = ''; openCropModal(file); }; document.getElementById('profile-avatar-remove').onclick = async () => { try { await api.delete('/profile/avatar'); showToast(t('avatar_removed')); document.getElementById('profile-avatar-img').classList.add('hidden'); document.getElementById('profile-avatar-letter').classList.remove('hidden'); document.getElementById('profile-avatar-remove').classList.add('hidden'); updateTopbarAvatar(false); } catch (e) { showToast(e.message, true); } }; // Password change document.getElementById('profile-pw-save').onclick = async () => { const current = document.getElementById('profile-pw-current').value; const newPw = document.getElementById('profile-pw-new').value; const confirm = document.getElementById('profile-pw-confirm').value; if (!current || !newPw) { showToast(t('error_fill_all'), true); return; } if (newPw !== confirm) { showToast(t('error_pw_mismatch'), true); return; } if (newPw.length < 6) { showToast(t('error_pw_length'), true); return; } try { await api.post('/profile/password', { current_password: current, new_password: newPw }); showToast(t('password_changed')); document.getElementById('profile-pw-current').value = ''; document.getElementById('profile-pw-new').value = ''; document.getElementById('profile-pw-confirm').value = ''; } catch (e) { showToast(e.message, true); } }; // 2FA Setup document.getElementById('2fa-setup-btn').onclick = async () => { try { const res = await api.post('/profile/2fa/setup', {}); document.getElementById('2fa-qr-img').src = res.qr_code; document.getElementById('2fa-secret-code').textContent = res.secret; document.getElementById('2fa-disabled-section').classList.add('hidden'); document.getElementById('2fa-setup-section').classList.remove('hidden'); } catch (e) { showToast(e.message, true); } }; document.getElementById('2fa-copy-secret').onclick = () => { const secret = document.getElementById('2fa-secret-code').textContent; navigator.clipboard.writeText(secret).then(() => showToast(t('key_copied'))); }; document.getElementById('2fa-enable-btn').onclick = async () => { const code = document.getElementById('2fa-verify-code').value.trim(); if (!code || code.length !== 6) { showToast(t('error_enter_6digit'), true); return; } try { await api.post('/profile/2fa/enable', { code }); showToast(t('totp_enabled')); document.getElementById('2fa-setup-section').classList.add('hidden'); document.getElementById('2fa-enabled-section').classList.remove('hidden'); } catch (e) { showToast(e.message, true); } }; document.getElementById('2fa-cancel-btn').onclick = () => { document.getElementById('2fa-setup-section').classList.add('hidden'); document.getElementById('2fa-disabled-section').classList.remove('hidden'); }; document.getElementById('2fa-disable-btn').onclick = async () => { const pw = document.getElementById('2fa-disable-pw').value; if (!pw) { showToast(t('error_enter_password'), true); return; } try { await api.post('/profile/2fa/disable', { password: pw }); showToast(t('totp_disabled')); document.getElementById('2fa-enabled-section').classList.add('hidden'); document.getElementById('2fa-disabled-section').classList.remove('hidden'); document.getElementById('2fa-disable-pw').value = ''; } catch (e) { showToast(e.message, true); } }; } function updateTopbarAvatar(hasAvatar) { const avatar = document.getElementById('user-avatar'); if (hasAvatar) { fetchAvatarBlob().then(blobUrl => { const img = new Image(); img.onload = () => { avatar.innerHTML = ''; img.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0'; avatar.appendChild(img); }; img.src = blobUrl; }).catch(() => { avatar.innerHTML = ''; const u = JSON.parse(localStorage.getItem('user')||'{}'); avatar.textContent = (u.username||'?')[0].toUpperCase(); }); // Update localStorage so avatar persists across reloads const u = JSON.parse(localStorage.getItem('user')||'{}'); u.has_avatar = true; localStorage.setItem('user', JSON.stringify(u)); } else { avatar.innerHTML = ''; const user = JSON.parse(localStorage.getItem('user') || '{}'); avatar.textContent = (user.username || '?')[0].toUpperCase(); user.has_avatar = false; localStorage.setItem('user', JSON.stringify(user)); } } // ── Calendar Color Picker ───────────────────────────────── async function openCalColorPicker(anchor, calId) { // Find current color of the calendar let currentColor = '#4285f4'; for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.id === calId && cal.color) currentColor = cal.color; } } const picked = await openColorPicker(anchor, currentColor); if (!picked) return; await api.put(`/caldav/calendars/${calId}`, { color: picked }); for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.id === calId) cal.color = picked; } } applyCalendarColor('caldav', calId, picked); } // ── Avatar Crop ────────────────────────────────────────── let activeCropper = null; function openCropModal(file) { const reader = new FileReader(); reader.onload = (e) => { const cropImg = document.getElementById('crop-image'); // Destroy previous cropper if any if (activeCropper) { activeCropper.destroy(); activeCropper = null; } // Reset image src first to force reload cropImg.removeAttribute('src'); openModal('modal-crop'); // Use requestAnimationFrame to ensure modal is visible before initializing cropper requestAnimationFrame(() => { cropImg.onload = () => { // Small delay to ensure the image is fully rendered in the DOM setTimeout(() => { if (activeCropper) { activeCropper.destroy(); activeCropper = null; } activeCropper = new Cropper(cropImg, { aspectRatio: 1, viewMode: 1, dragMode: 'move', autoCropArea: 1, cropBoxResizable: true, cropBoxMovable: true, background: false, }); }, 100); }; cropImg.src = e.target.result; }); }; reader.readAsDataURL(file); } document.getElementById('crop-save').onclick = async () => { if (!activeCropper) return; const canvas = activeCropper.getCroppedCanvas({ width: 512, height: 512, imageSmoothingQuality: 'high', }); canvas.toBlob(async (blob) => { const form = new FormData(); form.append('file', blob, 'avatar.jpg'); try { await api.upload('/profile/avatar', form); showToast(t('avatar_uploaded')); // Update profile modal avatar const img = document.getElementById('profile-avatar-img'); fetchAvatarBlob().then(blobUrl => { img.src = blobUrl; img.classList.remove('hidden'); document.getElementById('profile-avatar-letter').classList.add('hidden'); }); document.getElementById('profile-avatar-remove').classList.remove('hidden'); // Update topbar avatar updateTopbarAvatar(true); closeModal('modal-crop'); } catch (err) { showToast(err.message, true); } }, 'image/jpeg', 0.9); }; // Clean up cropper when modal closes document.getElementById('modal-crop').addEventListener('click', (e) => { if (e.target.matches('[data-modal="modal-crop"]') || e.target === document.getElementById('modal-crop')) { if (activeCropper) { activeCropper.destroy(); activeCropper = null; } } }); // ── Modal helpers ───────────────────────────────────────── function openModal(id) { document.getElementById(id).classList.remove('hidden'); } function closeModal(id) { document.getElementById(id).classList.add('hidden'); } // Close button bindings (added once) document.querySelectorAll('.modal-close, [data-modal]').forEach(el => { el.addEventListener('click', () => { const target = el.dataset.modal || el.closest('.modal-overlay')?.id; if (target) closeModal(target); }); }); document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(overlay.id); }); }); // ── Toast ───────────────────────────────────────────────── let toastTimer = null; export function showToast(msg, isError = false) { const el = document.getElementById('toast'); el.textContent = msg; el.className = 'toast' + (isError ? ' error' : ''); el.classList.remove('hidden'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.add('hidden'), 3500); } // ── Helpers ─────────────────────────────────────────────── function fmtTime(d) { return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); } function fmtDatetime(d) { return d.toLocaleString('de', { weekday:'short', day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' }); } function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } function buildWritableCalendars(excludeEv) { // Determine the event's current calendar to exclude it from copy targets let excludeType = null; let excludeId = null; if (excludeEv) { const cid = String(excludeEv.calendar_id); if (excludeEv.source === 'local') { excludeType = 'local'; excludeId = parseInt(cid.replace('local-', '')); } else if (excludeEv.source === 'google') { excludeType = 'google'; excludeId = parseInt(cid.replace('google-', '')); } else if (excludeEv.source === 'homeassistant') { excludeType = 'homeassistant'; excludeId = parseInt(cid.replace('homeassistant-', '')); } else if (excludeEv.source === 'caldav' || !excludeEv.source) { excludeType = 'caldav'; excludeId = parseInt(cid); } } const skip = (type, id) => excludeType === type && excludeId === id; const list = []; let idx = 0; for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.sidebar_hidden || skip('caldav', cal.id)) continue; list.push({ _idx: idx++, id: cal.id, name: `${acc.name} / ${cal.name}`, color: cal.color || '#4285f4', type: 'caldav' }); } } for (const cal of state.localCalendars) { if (cal.sidebar_hidden || skip('local', cal.id)) continue; list.push({ _idx: idx++, id: cal.id, name: cal.name, color: cal.color || '#34a853', type: 'local' }); } for (const acc of state.googleAccounts) { for (const cal of acc.calendars) { if (cal.sidebar_hidden || skip('google', cal.id)) continue; list.push({ _idx: idx++, id: cal.id, name: `${acc.email} / ${cal.name}`, color: cal.color || '#4285f4', type: 'google' }); } } for (const acc of state.haAccounts) { for (const cal of acc.calendars) { if (cal.sidebar_hidden || skip('homeassistant', cal.id)) continue; list.push({ _idx: idx++, id: cal.id, name: `${acc.name} / ${cal.name}`, color: cal.color || '#03a9f4', type: 'homeassistant' }); } } return list; } async function copyEventToCalendar(ev, cal) { const { title, allDay, location, description, color } = ev; // Normalize to UTC ISO string so the backend doesn't misinterpret bare local times const toISO = s => (s && s.length > 10) ? new Date(s).toISOString() : s; const start = allDay ? ev.start : toISO(ev.start); const end = allDay ? ev.end : toISO(ev.end); try { if (cal.type === 'google') { await api.post('/google/events', { calendar_db_id: cal.id, title, start, end, allDay, location: location || '', description: description || '', }); } else if (cal.type === 'local') { await api.post('/local/events', { calendar_id: cal.id, title, start, end, allDay, location: location || '', description: description || '', color: color || null, }); } else if (cal.type === 'homeassistant') { await api.post('/homeassistant/events', { calendar_id: cal.id, title, start, end, allDay, location: location || '', description: description || '', }); } else { await api.post('/caldav/events', { calendar_id: cal.id, title, start, end, allDay, location: location || '', description: description || '', color: color || null, }); } showToast(t('event_copied')); fetchAndRender(true); } catch (e) { showToast(e.message, true); } }