import { api } from './api.js'; import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey } from './utils.js'; import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; import { renderAgenda } from './views/agenda.js'; const MONTHS = ['Januar','Februar','März','April','Mai','Juni', 'Juli','August','September','Oktober','November','Dezember']; let state = { currentDate: new Date(), currentView: 'month', events: [], accounts: [], settings: {}, dimPast: false, editingEvent: null, // null = new event selectedEventColor: '', // '' = use calendar color }; // ── Public init ─────────────────────────────────────────── export async function initCalendar() { const [settings, accounts] = await Promise.all([ api.get('/settings/'), api.get('/caldav/accounts'), ]); state.settings = settings; state.accounts = accounts; state.currentView = settings.default_view || 'month'; state.dimPast = settings.dim_past_events; applyTheme(settings); updateViewButtons(); renderCalendarList(); renderMiniCal(); await fetchAndRender(); bindTopbar(); bindSidebar(); bindEventModal(); bindAccountModal(); bindSettingsModal(); } // ── Data fetching ───────────────────────────────────────── async function fetchAndRender() { const { start, end } = getViewRange(); showLoading(); try { const events = await api.get(`/caldav/events?start=${start.toISOString()}&end=${end.toISOString()}`); state.events = events; } catch (e) { showToast('Fehler beim Laden der Termine: ' + e.message, true); state.events = []; } renderView(); updateTitle(); renderMiniCal(); } function getViewRange() { const d = state.currentDate; let start, end; if (state.currentView === 'month') { start = new Date(d.getFullYear(), d.getMonth(), 1); start.setDate(start.getDate() - start.getDay() - 1); end = new Date(d.getFullYear(), d.getMonth() + 1, 0); end.setDate(end.getDate() + (6 - end.getDay()) + 1); } else if (state.currentView === 'week') { start = new Date(d); start.setDate(d.getDate() - d.getDay()); start.setHours(0, 0, 0, 0); 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 { // 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 => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }, showEventPopup ); } 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 ); } else if (state.currentView === 'day') { renderWeek(container, state.currentDate, evs, (date, switchDay) => { if (!switchDay) openNewEventModal(date); }, showEventPopup, true ); } 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 title = ''; if (state.currentView === 'month') { title = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; } else if (state.currentView === 'week') { const sun = new Date(d); sun.setDate(d.getDate() - d.getDay()); const sat = new Date(sun); sat.setDate(sun.getDate() + 6); const sameMonth = sun.getMonth() === sat.getMonth(); title = sameMonth ? `${sun.getDate()}. – ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}` : `${sun.getDate()}. ${MONTHS[sun.getMonth()]} – ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}`; } else if (state.currentView === 'day') { title = `${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; } else { title = `Ab ${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; } document.getElementById('view-title').textContent = title; } 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 = `${MONTHS[miniD.getMonth()]} ${miniD.getFullYear()}`; const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1); const lastDay = new Date(miniD.getFullYear(), miniD.getMonth() + 1, 0); const gridStart = new Date(firstDay); gridStart.setDate(gridStart.getDate() - firstDay.getDay()); // 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'); if (!state.accounts.length) { container.innerHTML = `
Kein CalDAV-Konto
`; return; } const html = state.accounts.map(acc => `
${escHtml(acc.name)}
` + acc.calendars.map(cal => `
${escHtml(cal.name)}
` ).join('') ).join(''); container.innerHTML = html; container.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', async () => { const calId = parseInt(cb.dataset.calId); await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked }); // Update local state for (const acc of state.accounts) { for (const cal of acc.calendars) { if (cal.id === calId) cal.enabled = cb.checked; } } fetchAndRender(); }); }); container.querySelectorAll('.cal-item-remove').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); if (!confirm('CalDAV-Konto wirklich entfernen?')) return; const accId = parseInt(btn.dataset.accId); await api.delete(`/caldav/accounts/${accId}`); state.accounts = state.accounts.filter(a => a.id !== accId); renderCalendarList(); fetchAndRender(); }); }); } // ── Navigation ──────────────────────────────────────────── function navigate(dir) { const d = state.currentDate; if (state.currentView === 'month') { state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1); } 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 { 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(new Date()); } // ── Sidebar toggle ──────────────────────────────────────── function bindSidebar() { document.getElementById('sidebar-toggle').onclick = () => { document.getElementById('sidebar').classList.toggle('collapsed'); }; document.getElementById('btn-add-account').onclick = openAccountModal; } // ── Event Popup ─────────────────────────────────────────── function showEventPopup(ev, anchor) { const popup = document.getElementById('popup-event'); 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 = 'Ganztägig'; } else { const s = new Date(ev.start); const e = new Date(ev.end); document.getElementById('popup-time').textContent = `${fmtDatetime(s)} – ${fmtTime(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'; document.getElementById('popup-edit').onclick = () => { popup.classList.add('hidden'); openEditEventModal(ev); }; document.getElementById('popup-delete').onclick = async () => { if (!confirm(`"${ev.title}" wirklich löschen?`)) return; popup.classList.add('hidden'); try { await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); showToast('Termin gelöscht'); fetchAndRender(); } 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 = ''; state.accounts.forEach(acc => { acc.calendars.filter(c => c.enabled).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); }); }); } 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); document.getElementById('ev-start').value = toLocalDatetimeInput(start); document.getElementById('ev-end').value = toLocalDatetimeInput(end); document.getElementById('ev-start-date').value = toDateInput(start); document.getElementById('ev-end-date').value = toDateInput(start); toggleAlldayFields(false); populateCalendarSelect(null); resetColorPicker(''); document.getElementById('ev-delete').classList.add('hidden'); openModal('modal-event'); } function openEditEventModal(ev) { 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) { document.getElementById('ev-start-date').value = ev.start.slice(0, 10); document.getElementById('ev-end-date').value = ev.end.slice(0, 10); toggleAlldayFields(true); } else { const s = new Date(ev.start); const e = new Date(ev.end); document.getElementById('ev-start').value = toLocalDatetimeInput(s); document.getElementById('ev-end').value = toLocalDatetimeInput(e); toggleAlldayFields(false); } populateCalendarSelect(ev.calendar_id); resetColorPicker(ev.color || ''); 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; document.querySelectorAll('#ev-color-picker .color-swatch').forEach(sw => { sw.classList.toggle('active', (sw.dataset.color || '') === color); }); } function bindEventModal() { document.getElementById('ev-allday').addEventListener('change', e => { toggleAlldayFields(e.target.checked); }); document.querySelectorAll('#ev-color-picker .color-swatch').forEach(sw => { sw.addEventListener('click', () => { state.selectedEventColor = sw.dataset.color || ''; resetColorPicker(state.selectedEventColor); }); }); document.getElementById('ev-save').onclick = async () => { const title = document.getElementById('ev-title').value.trim(); if (!title) { showToast('Bitte Titel eingeben', true); return; } const allDay = document.getElementById('ev-allday').checked; const calId = parseInt(document.getElementById('ev-calendar').value); const loc = document.getElementById('ev-location').value.trim(); const desc = document.getElementById('ev-description').value.trim(); const color = state.selectedEventColor; let start, end; if (allDay) { start = document.getElementById('ev-start-date').value; end = document.getElementById('ev-end-date').value; if (!start) { showToast('Bitte Datum eingeben', true); return; } if (!end || end < start) end = start; } else { const sv = document.getElementById('ev-start').value; const ev2 = document.getElementById('ev-end').value; if (!sv) { showToast('Bitte Start-Zeit eingeben', 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) { await api.put( `/caldav/events/${encodeURIComponent(state.editingEvent.id)}?event_url=${encodeURIComponent(state.editingEvent.url)}`, { title, start, end, allDay, location: loc, description: desc, color: color || null } ); showToast('Termin aktualisiert'); } else { await api.post('/caldav/events', { calendar_id: calId, title, start, end, allDay, location: loc, description: desc, color: color || null, }); showToast('Termin erstellt'); } closeModal('modal-event'); fetchAndRender(); } catch (e) { showToast(e.message, true); } }; document.getElementById('ev-delete').onclick = async () => { const ev = state.editingEvent; if (!ev) return; if (!confirm(`"${ev.title}" wirklich löschen?`)) return; try { await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); showToast('Termin gelöscht'); closeModal('modal-event'); fetchAndRender(); } 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').value = '#4285f4'; document.getElementById('acc-error').classList.add('hidden'); openModal('modal-account'); } function bindAccountModal() { 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').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(`Konto "${name}" hinzugefügt`); fetchAndRender(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); } finally { document.getElementById('acc-save').disabled = false; document.getElementById('acc-save').textContent = 'Verbinden'; } }; } // ── Settings Modal ──────────────────────────────────────── function openSettingsModal() { const s = state.settings; document.getElementById('cfg-default-view').value = s.default_view || 'month'; document.getElementById('cfg-primary-color').value = s.primary_color || '#4285f4'; document.getElementById('cfg-accent-color').value = s.accent_color || '#ea4335'; document.getElementById('cfg-today-color').value = s.today_color || '#4285f4'; document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; document.getElementById('cfg-primary-label').textContent = s.primary_color || '#4285f4'; document.getElementById('cfg-accent-label').textContent = s.accent_color || '#ea4335'; document.getElementById('cfg-today-label').textContent = s.today_color || '#4285f4'; // Show users section only for admins const user = JSON.parse(localStorage.getItem('user') || '{}'); const usersSection = document.getElementById('settings-users-section'); if (user.is_admin) { usersSection.classList.remove('hidden'); loadUsers(); } else { usersSection.classList.add('hidden'); } openModal('modal-settings'); } 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('Benutzer löschen?')) return; try { await api.delete(`/users/${btn.dataset.delUser}`); loadUsers(); } catch (e) { showToast(e.message, true); } }); }); } catch (e) { /* not admin */ } } function bindSettingsModal() { ['cfg-primary-color','cfg-accent-color','cfg-today-color'].forEach(id => { document.getElementById(id).addEventListener('input', e => { const labelId = id.replace('color', 'label'); document.getElementById(labelId).textContent = e.target.value; }); }); 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('Benutzername und Passwort erforderlich', true); return; } try { await api.post('/users/', { username, password, is_admin }); showToast(`Benutzer "${username}" erstellt`); 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 settings = { default_view: document.getElementById('cfg-default-view').value, primary_color: document.getElementById('cfg-primary-color').value, accent_color: document.getElementById('cfg-accent-color').value, today_color: document.getElementById('cfg-today-color').value, dim_past_events: document.getElementById('cfg-dim-past').checked, }; try { await api.put('/settings/', settings); state.settings = { ...state.settings, ...settings }; state.dimPast = settings.dim_past_events; applyTheme(settings); showToast('Einstellungen gespeichert'); closeModal('modal-settings'); renderView(); } catch (e) { showToast(e.message, true); } }; } // ── 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,'>'); }