calendar

A HTML/PHP project for a self-hosted (family)-calendar.
Log | Files | Refs | README

app.js (35409B)


      1 'use strict';
      2 
      3 // ── State ─────────────────────────────────────────────────────────────────────
      4 const state = {
      5   username: '',
      6   email: '',
      7   calendars: [],
      8   events: {},        // keyed "YYYY-MM-DD" → array of event objects
      9   upcomingEvents: [],
     10   year: new Date().getFullYear(),
     11   month: new Date().getMonth(),  // 0-based
     12   selectedDay: null,
     13 };
     14 
     15 const COLORS = [
     16   '#88C0D0', '#8FBCBB', '#81A1C1', '#5E81AC',
     17   '#A3BE8C', '#EBCB8B', '#D08770', '#BF616A', '#B48EAD',
     18 ];
     19 
     20 function monthName(idx) {
     21   return getLang() === 'de'
     22     ? ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'][idx]
     23     : ['January','February','March','April','May','June','July','August','September','October','November','December'][idx];
     24 }
     25 
     26 const HOUR_PX = 60;  // pixels per hour in the daily view
     27 
     28 // ── API ───────────────────────────────────────────────────────────────────────
     29 async function api(method, path, body) {
     30   const opts = { method, headers: { 'Content-Type': 'application/json' } };
     31   if (body !== undefined) opts.body = JSON.stringify(body);
     32   const res = await fetch(path, opts);
     33   if (!res.ok) {
     34     const err = await res.json().catch(() => ({ detail: res.statusText }));
     35     throw new Error(err.detail || res.statusText);
     36   }
     37   if (res.status === 204) return null;
     38   return res.json();
     39 }
     40 
     41 // ── Init ──────────────────────────────────────────────────────────────────────
     42 async function init() {
     43   const me = await api('GET', '/api/me');
     44   state.username = me.username;
     45   state.email = me.email;
     46   document.getElementById('username-display').textContent = me.display_name || me.username;
     47 
     48   await loadCalendars();
     49   await Promise.all([fetchEvents(), fetchUpcomingEvents()]);
     50   renderMonthly();
     51 
     52   // Collapse sidebar on mobile by default
     53   if (window.innerWidth <= 700) {
     54     document.querySelector('.sidebar').classList.add('collapsed');
     55   }
     56 }
     57 
     58 // ── Data loading ──────────────────────────────────────────────────────────────
     59 async function loadCalendars() {
     60   state.calendars = await api('GET', '/api/calendars');
     61   renderSidebar();
     62 }
     63 
     64 async function fetchEvents() {
     65   const evs = await api('GET', `/api/events?year=${state.year}&month=${state.month + 1}`);
     66   indexEvents(evs);
     67 }
     68 
     69 async function fetchUpcomingEvents() {
     70   state.upcomingEvents = await api('GET', '/api/events/upcoming');
     71   renderUpcomingEvents();
     72 }
     73 
     74 function indexEvents(arr) {
     75   state.events = {};
     76   arr.forEach(ev => {
     77     const start = new Date(ev.start_dt);
     78     const end   = new Date(ev.end_dt);
     79     const d = new Date(start); d.setHours(0, 0, 0, 0);
     80     const last = new Date(end); last.setHours(0, 0, 0, 0);
     81     while (d <= last) {
     82       const k = dateKey(d);
     83       (state.events[k] = state.events[k] || []).push(ev);
     84       d.setDate(d.getDate() + 1);
     85     }
     86   });
     87 }
     88 
     89 function dateKey(d) {
     90   return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
     91 }
     92 
     93 // ── Sidebar toggle ────────────────────────────────────────────────────────────
     94 document.getElementById('btn-sidebar-toggle').addEventListener('click', () => {
     95   document.querySelector('.sidebar').classList.toggle('collapsed');
     96 });
     97 
     98 // ── Sidebar: Upcoming events ──────────────────────────────────────────────────
     99 function renderUpcomingEvents() {
    100   const section = document.getElementById('upcoming-section');
    101   section.innerHTML = '';
    102 
    103   const now = new Date();
    104   const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
    105   const in7Days = new Date(todayStart);
    106   in7Days.setDate(in7Days.getDate() + 7);
    107 
    108   const visibleEvs = state.upcomingEvents.filter(ev => {
    109     const cal = state.calendars.find(c => c.id === ev.calendar_id);
    110     return cal && cal.is_visible;
    111   });
    112 
    113   const near  = visibleEvs.filter(ev => new Date(ev.start_dt) < in7Days);
    114   const later = visibleEvs.filter(ev => new Date(ev.start_dt) >= in7Days);
    115 
    116   section.appendChild(makeUpcomingSectionHeader(t('upcoming-7days')));
    117   if (near.length === 0) {
    118     section.appendChild(makeUpcomingEmpty());
    119   } else {
    120     near.forEach(ev => section.appendChild(makeUpcomingItem(ev)));
    121   }
    122 
    123   const divider = document.createElement('div');
    124   divider.className = 'sidebar-divider';
    125   section.appendChild(divider);
    126 
    127   section.appendChild(makeUpcomingSectionHeader(t('upcoming-later')));
    128   if (later.length === 0) {
    129     section.appendChild(makeUpcomingEmpty());
    130   } else {
    131     later.forEach(ev => section.appendChild(makeUpcomingItem(ev)));
    132   }
    133 }
    134 
    135 function makeUpcomingSectionHeader(label) {
    136   const el = document.createElement('div');
    137   el.className = 'upcoming-header';
    138   el.textContent = label;
    139   return el;
    140 }
    141 
    142 function makeUpcomingEmpty() {
    143   const el = document.createElement('div');
    144   el.className = 'upcoming-empty';
    145   el.textContent = t('upcoming-empty');
    146   return el;
    147 }
    148 
    149 function makeUpcomingItem(ev) {
    150   const cal   = state.calendars.find(c => c.id === ev.calendar_id);
    151   const color = cal ? cal.color : '#88C0D0';
    152 
    153   const item = document.createElement('div');
    154   item.className = 'upcoming-item';
    155   item.style.borderLeftColor = color;
    156 
    157   const title = document.createElement('div');
    158   title.className = 'upcoming-title';
    159   title.textContent = ev.title;
    160 
    161   const meta = document.createElement('div');
    162   meta.className = 'upcoming-meta';
    163   meta.textContent = formatUpcomingDate(new Date(ev.start_dt), ev.all_day);
    164 
    165   item.append(title, meta);
    166   item.addEventListener('click', () => navigateToEvent(ev));
    167   return item;
    168 }
    169 
    170 function formatUpcomingDate(date, allDay) {
    171   const now      = new Date();
    172   const today    = new Date(now.getFullYear(), now.getMonth(), now.getDate());
    173   const tomorrow = new Date(today);
    174   tomorrow.setDate(tomorrow.getDate() + 1);
    175   const eventDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
    176 
    177   let dayLabel;
    178   if (eventDay.getTime() === today.getTime()) {
    179     dayLabel = t('upcoming-today');
    180   } else if (eventDay.getTime() === tomorrow.getTime()) {
    181     dayLabel = t('upcoming-tomorrow');
    182   } else {
    183     dayLabel = date.toLocaleDateString(getLang() === 'de' ? 'de-DE' : 'en-GB', {
    184       weekday: 'short', day: 'numeric', month: 'short',
    185     });
    186   }
    187 
    188   if (allDay) return dayLabel;
    189   return `${dayLabel} · ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
    190 }
    191 
    192 async function navigateToEvent(ev) {
    193   const start = new Date(ev.start_dt);
    194   const newYear  = start.getFullYear();
    195   const newMonth = start.getMonth();
    196   const newDay   = start.getDate();
    197   state.selectedDay = newDay;
    198   if (newYear !== state.year || newMonth !== state.month) {
    199     state.year  = newYear;
    200     state.month = newMonth;
    201     await fetchEvents();
    202   }
    203   renderMonthly();
    204   openDailyView(newDay);
    205 }
    206 
    207 // ── Sidebar: Calendars ────────────────────────────────────────────────────────
    208 function renderSidebar() {
    209   const list = document.getElementById('calendar-list');
    210   list.innerHTML = '';
    211   state.calendars.forEach(cal => list.appendChild(makeCalendarItem(cal)));
    212 }
    213 
    214 function makeCalendarItem(cal) {
    215   const item = document.createElement('div');
    216   item.className = 'calendar-item';
    217   item.dataset.id = cal.id;
    218 
    219   const dot = document.createElement('span');
    220   dot.className = 'cal-dot';
    221   dot.style.background = cal.color;
    222 
    223   const name = document.createElement('span');
    224   name.className = 'cal-name';
    225   name.textContent = cal.name;
    226 
    227   const toggle = document.createElement('button');
    228   toggle.className = 'cal-toggle' + (cal.is_visible ? ' visible' : '');
    229   toggle.title = cal.is_visible ? t('hide') : t('show');
    230   toggle.addEventListener('click', e => { e.stopPropagation(); toggleCalVisibility(cal); });
    231 
    232   item.append(dot, name, toggle);
    233   item.addEventListener('contextmenu', e => { e.preventDefault(); showContextMenu(e, cal); });
    234   return item;
    235 }
    236 
    237 async function toggleCalVisibility(cal) {
    238   await api('PATCH', `/api/calendars/${cal.id}/visibility`, { is_visible: !cal.is_visible });
    239   cal.is_visible = !cal.is_visible;
    240   renderSidebar();
    241   renderUpcomingEvents();
    242   renderMonthly();
    243   if (state.selectedDay) renderDailyEvents();
    244 }
    245 
    246 // ── Context menu ──────────────────────────────────────────────────────────────
    247 const ctxMenu = document.getElementById('ctx-menu');
    248 
    249 function showContextMenu(e, cal) {
    250   ctxMenu.innerHTML = '';
    251 
    252   if (cal.is_owner && !cal.is_personal) {
    253     addCtxItem(t('ctx-edit'),   () => openCalModal('edit', cal));
    254     addCtxItem(t('ctx-invite'), () => openMembersModal(cal));
    255     addCtxItem(t('ctx-delete'), () => deleteCalendar(cal), true);
    256   } else if (!cal.is_personal) {
    257     addCtxItem(t('ctx-leave-cal'), () => leaveCalendar(cal), true);
    258   } else {
    259     addCtxItem(t('ctx-edit-name-color'), () => openCalModal('edit', cal));
    260   }
    261 
    262   if (!ctxMenu.children.length) return;
    263   const x = Math.min(e.clientX, window.innerWidth - 180);
    264   const y = Math.min(e.clientY, window.innerHeight - ctxMenu.scrollHeight - 10);
    265   ctxMenu.style.left = x + 'px';
    266   ctxMenu.style.top  = y + 'px';
    267   ctxMenu.classList.add('open');
    268 }
    269 
    270 function addCtxItem(label, action, danger = false) {
    271   const el = document.createElement('div');
    272   el.className = 'ctx-item' + (danger ? ' danger' : '');
    273   el.textContent = label;
    274   el.addEventListener('click', () => { closeCtxMenu(); action(); });
    275   ctxMenu.appendChild(el);
    276 }
    277 
    278 function closeCtxMenu() { ctxMenu.classList.remove('open'); }
    279 
    280 document.addEventListener('click', closeCtxMenu);
    281 document.addEventListener('keydown', e => {
    282   if (e.key === 'Escape') { closeCtxMenu(); closeCalModal(); closeEventModal(); closeDailyView(); }
    283 });
    284 
    285 // ── Monthly view ──────────────────────────────────────────────────────────────
    286 function renderMonthly() {
    287   document.getElementById('month-title').textContent = `${monthName(state.month)} ${state.year}`;
    288 
    289   const grid = document.getElementById('day-grid');
    290   grid.innerHTML = '';
    291 
    292   const firstDay = new Date(state.year, state.month, 1);
    293   let startDow = firstDay.getDay();
    294   startDow = startDow === 0 ? 6 : startDow - 1;  // Mon=0 … Sun=6
    295 
    296   const daysInMonth = new Date(state.year, state.month + 1, 0).getDate();
    297   const daysInPrev  = new Date(state.year, state.month, 0).getDate();
    298   const totalCells  = Math.ceil((startDow + daysInMonth) / 7) * 7;
    299   const today       = new Date();
    300 
    301   for (let i = 0; i < totalCells; i++) {
    302     let day, isCurrent = true, cellMonth = state.month, cellYear = state.year;
    303     if (i < startDow) {
    304       day = daysInPrev - startDow + 1 + i; isCurrent = false;
    305       cellMonth = state.month === 0 ? 11 : state.month - 1;
    306       cellYear  = state.month === 0 ? state.year - 1 : state.year;
    307     } else if (i >= startDow + daysInMonth) {
    308       day = i - startDow - daysInMonth + 1; isCurrent = false;
    309       cellMonth = state.month === 11 ? 0 : state.month + 1;
    310       cellYear  = state.month === 11 ? state.year + 1 : state.year;
    311     } else {
    312       day = i - startDow + 1;
    313     }
    314 
    315     const col = i % 7;  // 0=Mon … 6=Sun
    316     const isWeekend = col >= 5;
    317 
    318     const cell = document.createElement('div');
    319     cell.className = 'day-cell';
    320     if (!isCurrent)  cell.classList.add('other-month');
    321     if (isWeekend)   cell.classList.add('weekend');
    322 
    323     const isToday = day === today.getDate() &&
    324       cellMonth === today.getMonth() && cellYear === today.getFullYear();
    325     if (isToday)                                cell.classList.add('today');
    326     if (isCurrent && state.selectedDay === day) cell.classList.add('selected');
    327 
    328     const num = document.createElement('div');
    329     num.className = 'day-number';
    330     num.textContent = day;
    331     cell.appendChild(num);
    332 
    333     // Event dots for current-month days
    334     if (isCurrent) {
    335       const k = `${state.year}-${String(state.month+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
    336       const dayEvs = (state.events[k] || []).filter(ev => {
    337         const cal = state.calendars.find(c => c.id === ev.calendar_id);
    338         return cal && cal.is_visible;
    339       });
    340       if (dayEvs.length) {
    341         const dots = document.createElement('div');
    342         dots.className = 'event-dots';
    343         dayEvs.slice(0, 3).forEach(ev => {
    344           const cal = state.calendars.find(c => c.id === ev.calendar_id);
    345           const d = document.createElement('span');
    346           d.className = 'event-dot';
    347           d.style.background = cal ? cal.color : '#88C0D0';
    348           dots.appendChild(d);
    349         });
    350         if (dayEvs.length > 3) {
    351           const more = document.createElement('span');
    352           more.className = 'event-dot-more';
    353           more.textContent = '+' + (dayEvs.length - 3);
    354           dots.appendChild(more);
    355         }
    356         cell.appendChild(dots);
    357       }
    358       cell.addEventListener('click', () => selectDay(day));
    359     }
    360 
    361     grid.appendChild(cell);
    362   }
    363 }
    364 
    365 function selectDay(day) {
    366   state.selectedDay = day;
    367   renderMonthly();
    368   openDailyView(day);
    369 }
    370 
    371 function prevMonth() {
    372   if (state.month === 0) { state.month = 11; state.year--; } else state.month--;
    373   state.selectedDay = null;
    374   fetchEvents().then(renderMonthly);
    375   closeDailyView();
    376 }
    377 
    378 function nextMonth() {
    379   if (state.month === 11) { state.month = 0; state.year++; } else state.month++;
    380   state.selectedDay = null;
    381   fetchEvents().then(renderMonthly);
    382   closeDailyView();
    383 }
    384 
    385 document.getElementById('prev-month').addEventListener('click', prevMonth);
    386 document.getElementById('next-month').addEventListener('click', nextMonth);
    387 
    388 // ── Daily view ────────────────────────────────────────────────────────────────
    389 // ── Monthly view min-width enforcement ───────────────────────────────────────
    390 const MONTHLY_MIN_W  = 300;
    391 const calendarMain   = document.querySelector('.calendar-main');
    392 const monthlyViewEl  = document.querySelector('.monthly-view');
    393 
    394 function updateMonthlyVisibility() {
    395   const open     = calendarMain.classList.contains('daily-open');
    396   const avail    = open ? calendarMain.clientWidth - 420 : Infinity;
    397   const collapse = open && avail < MONTHLY_MIN_W;
    398   monthlyViewEl.classList.toggle('monthly-hidden', collapse);
    399   calendarMain.classList.toggle('monthly-collapsed', collapse);
    400 }
    401 
    402 new ResizeObserver(updateMonthlyVisibility).observe(calendarMain);
    403 
    404 // ── Daily view open / close ───────────────────────────────────────────────────
    405 function openDailyView(day) {
    406   const date = new Date(state.year, state.month, day);
    407   document.getElementById('daily-title').textContent = date.toLocaleDateString(
    408     getLang() === 'de' ? 'de-DE' : 'en-GB',
    409     { weekday: 'long', day: 'numeric', month: 'long' }
    410   );
    411   renderTimeGrid();
    412   calendarMain.classList.add('daily-open');
    413   updateMonthlyVisibility();
    414 }
    415 
    416 function closeDailyView() {
    417   calendarMain.classList.remove('daily-open');
    418   updateMonthlyVisibility();
    419   state.selectedDay = null;
    420   renderMonthly();
    421 }
    422 
    423 document.getElementById('btn-close-daily').addEventListener('click', closeDailyView);
    424 
    425 function renderTimeGrid() {
    426   const grid = document.getElementById('time-grid');
    427   grid.innerHTML = '';
    428 
    429   const inner = document.createElement('div');
    430   inner.className = 'time-grid-inner';
    431 
    432   const hours = document.createElement('div');
    433   hours.className = 'time-hours';
    434 
    435   for (let h = 6; h <= 22; h++) {
    436     const row = document.createElement('div');
    437     row.className = 'time-row';
    438 
    439     const label = document.createElement('div');
    440     label.className = 'time-label';
    441     label.textContent = String(h).padStart(2, '0') + ':00';
    442 
    443     const slot = document.createElement('div');
    444     slot.className = 'time-slot';
    445     slot.dataset.hour = h;
    446     slot.addEventListener('click', () => {
    447       if (state.selectedDay) openEventModal(new Date(state.year, state.month, state.selectedDay, h, 0));
    448     });
    449 
    450     row.append(label, slot);
    451     hours.appendChild(row);
    452   }
    453 
    454   const overlay = document.createElement('div');
    455   overlay.className = 'events-overlay';
    456   overlay.id = 'events-overlay';
    457 
    458   inner.append(hours, overlay);
    459   grid.appendChild(inner);
    460   renderDailyEvents();
    461 }
    462 
    463 function renderDailyEvents() {
    464   const overlay = document.getElementById('events-overlay');
    465   if (!overlay || !state.selectedDay) return;
    466   overlay.innerHTML = '';
    467 
    468   const k = `${state.year}-${String(state.month+1).padStart(2,'0')}-${String(state.selectedDay).padStart(2,'0')}`;
    469   const dayEvs = (state.events[k] || []).filter(ev => {
    470     const cal = state.calendars.find(c => c.id === ev.calendar_id);
    471     return cal && cal.is_visible;
    472   });
    473 
    474   // All-day strip
    475   const alldaySection = document.getElementById('allday-section');
    476   const alldayEvents  = document.getElementById('allday-events');
    477   const alldayEvs = dayEvs.filter(ev => ev.all_day);
    478   alldayEvents.innerHTML = '';
    479   if (alldayEvs.length) {
    480     alldaySection.style.display = 'flex';
    481     alldayEvs.forEach(ev => {
    482       const cal   = state.calendars.find(c => c.id === ev.calendar_id);
    483       const color = cal ? cal.color : '#88C0D0';
    484       const chip  = document.createElement('div');
    485       chip.className = 'allday-chip';
    486       chip.textContent = ev.title;
    487       chip.style.background = color + '33';
    488       chip.style.color = color;
    489       chip.style.borderLeft = `3px solid ${color}`;
    490       chip.addEventListener('click', () => openEventModal(null, ev));
    491       alldayEvents.appendChild(chip);
    492     });
    493   } else {
    494     alldaySection.style.display = 'none';
    495   }
    496 
    497   // Timed events in the grid
    498   const dayStart = new Date(state.year, state.month, state.selectedDay, 6, 0);
    499   const dayEnd   = new Date(state.year, state.month, state.selectedDay, 22, 0);
    500 
    501   dayEvs.filter(ev => !ev.all_day).forEach(ev => {
    502     const evStart = new Date(ev.start_dt);
    503     const evEnd   = new Date(ev.end_dt);
    504     const cs = evStart < dayStart ? dayStart : evStart;
    505     const ce = evEnd   > dayEnd   ? dayEnd   : evEnd;
    506     if (cs >= ce) return;
    507 
    508     const startMin = (cs.getHours() - 6) * 60 + cs.getMinutes();
    509     const durMin   = (ce - cs) / 60000;
    510     const cal      = state.calendars.find(c => c.id === ev.calendar_id);
    511     const color    = cal ? cal.color : '#88C0D0';
    512 
    513     const block = document.createElement('div');
    514     block.className = 'event-block';
    515     block.style.top    = startMin + 'px';
    516     block.style.height = Math.max(durMin, 20) + 'px';
    517     block.style.background = color + '22';
    518     block.style.borderLeftColor = color;
    519     block.style.color = color;
    520 
    521     const titleEl = document.createElement('div');
    522     titleEl.className = 'event-block-title';
    523     titleEl.textContent = ev.title;
    524     block.appendChild(titleEl);
    525 
    526     if (ev.description) {
    527       const descEl = document.createElement('div');
    528       descEl.className = 'event-block-desc';
    529       descEl.textContent = ev.description;
    530       block.appendChild(descEl);
    531     }
    532 
    533     block.addEventListener('click', e => { e.stopPropagation(); openEventModal(null, ev); });
    534     overlay.appendChild(block);
    535   });
    536 }
    537 
    538 document.getElementById('btn-add-event').addEventListener('click', () => {
    539   if (state.selectedDay) openEventModal(new Date(state.year, state.month, state.selectedDay, 9, 0));
    540 });
    541 
    542 // ── Calendar modal (create / edit / invite) ───────────────────────────────────
    543 const calModalOverlay = document.getElementById('modal-overlay');
    544 const calModal        = document.getElementById('modal');
    545 let calModalMode   = 'create';
    546 let calModalTarget = null;
    547 let selectedColor  = COLORS[0];
    548 let inviteUsernames = [''];
    549 
    550 function openCalModal(mode, target = null) {
    551   calModalMode   = mode;
    552   calModalTarget = target;
    553   selectedColor  = target ? target.color : COLORS[0];
    554   inviteUsernames = [''];
    555   renderCalModal();
    556   calModalOverlay.classList.add('open');
    557 }
    558 
    559 function closeCalModal() { calModalOverlay.classList.remove('open'); }
    560 
    561 calModalOverlay.addEventListener('click', e => { if (e.target === calModalOverlay) closeCalModal(); });
    562 document.getElementById('modal-cancel').addEventListener('click', closeCalModal);
    563 document.getElementById('modal-submit').addEventListener('click', submitCalModal);
    564 
    565 function renderCalModal() {
    566   const titles = { create: t('new-cal-title'), edit: t('edit-cal-title') };
    567   calModal.querySelector('h2').textContent = titles[calModalMode] || '';
    568   const body = calModal.querySelector('.modal-body');
    569   body.innerHTML = '';
    570 
    571   if (calModalMode === 'create' || calModalMode === 'edit') {
    572     const g = document.createElement('div');
    573     g.className = 'form-group';
    574     g.innerHTML = `<label class="form-label">${t('form-name')}</label>
    575       <input class="form-input" id="cal-name-input" type="text" placeholder="${t('cal-name-ph')}"
    576         value="${calModalTarget ? escHtml(calModalTarget.name) : ''}">`;
    577     body.appendChild(g);
    578 
    579     const cg = document.createElement('div');
    580     cg.className = 'form-group';
    581     cg.innerHTML = `<label class="form-label">${t('form-color')}</label>`;
    582     const swatches = document.createElement('div');
    583     swatches.className = 'color-swatches';
    584     COLORS.forEach(c => {
    585       const sw = document.createElement('div');
    586       sw.className = 'color-swatch' + (c === selectedColor ? ' selected' : '');
    587       sw.style.background = c;
    588       sw.addEventListener('click', () => {
    589         selectedColor = c;
    590         swatches.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
    591         sw.classList.add('selected');
    592       });
    593       swatches.appendChild(sw);
    594     });
    595     cg.appendChild(swatches);
    596     body.appendChild(cg);
    597   }
    598 
    599   if (calModalMode === 'create') {
    600     body.appendChild(buildInviteSection());
    601   }
    602 }
    603 
    604 function buildInviteSection() {
    605   const g = document.createElement('div');
    606   g.className = 'form-group';
    607   g.innerHTML = `<label class="form-label">${t('add-by-username')}</label>`;
    608   const list = document.createElement('div');
    609   list.className = 'invite-list';
    610   list.id = 'invite-list';
    611   g.appendChild(list);
    612   renderInviteRows(list);
    613   return g;
    614 }
    615 
    616 function renderInviteRows(list) {
    617   list.innerHTML = '';
    618   inviteUsernames.forEach((uname, i) => {
    619     const row = document.createElement('div');
    620     row.className = 'invite-row';
    621     const inp = document.createElement('input');
    622     inp.type = 'text'; inp.className = 'form-input';
    623     inp.placeholder = t('username-ph'); inp.value = uname;
    624     inp.addEventListener('input', () => { inviteUsernames[i] = inp.value; });
    625 
    626     const add = document.createElement('button');
    627     add.className = 'btn-icon';
    628     add.innerHTML = svgPlus();
    629     add.addEventListener('click', () => { inviteUsernames.push(''); renderInviteRows(list); });
    630 
    631     const rem = document.createElement('button');
    632     rem.className = 'btn-icon danger';
    633     rem.innerHTML = svgMinus();
    634     rem.addEventListener('click', () => {
    635       if (inviteUsernames.length > 1) { inviteUsernames.splice(i, 1); renderInviteRows(list); }
    636     });
    637 
    638     row.append(inp, add, rem);
    639     list.appendChild(row);
    640   });
    641 }
    642 
    643 async function submitCalModal() {
    644   try {
    645     if (calModalMode === 'create') {
    646       const name = document.getElementById('cal-name-input')?.value?.trim();
    647       if (!name) return alert(t('alert-enter-name'));
    648       const usernames = inviteUsernames.map(u => u.trim()).filter(Boolean);
    649       await api('POST', '/api/calendars', { name, color: selectedColor, invite_usernames: usernames });
    650     } else if (calModalMode === 'edit') {
    651       const name = document.getElementById('cal-name-input')?.value?.trim();
    652       await api('PATCH', `/api/calendars/${calModalTarget.id}`, { name: name || undefined, color: selectedColor });
    653     }
    654     closeCalModal();
    655     await loadCalendars();
    656   } catch (err) { alert(err.message); }
    657 }
    658 
    659 async function deleteCalendar(cal) {
    660   if (!confirm(`Delete "${cal.name}"? This cannot be undone.`)) return;
    661   await api('DELETE', `/api/calendars/${cal.id}`);
    662   await loadCalendars();
    663 }
    664 
    665 async function leaveCalendar(cal) {
    666   if (!confirm(`Leave "${cal.name}"?`)) return;
    667   await api('DELETE', `/api/calendars/${cal.id}/leave`);
    668   await loadCalendars();
    669 }
    670 
    671 // ── Members modal ─────────────────────────────────────────────────────────────
    672 let _membersCalId = null;
    673 
    674 function openMembersModal(cal) {
    675   _membersCalId = cal.id;
    676   renderMembersModal();
    677   document.getElementById('modal-members-overlay').classList.add('open');
    678 }
    679 
    680 function renderMembersModal() {
    681   const cal = state.calendars.find(c => c.id === _membersCalId);
    682   if (!cal) return;
    683   document.getElementById('modal-members-title').textContent = cal.name;
    684   document.getElementById('cal-members-list').innerHTML = cal.members.map(m => `
    685     <div class="member-row">
    686       <span class="member-username">${escHtml(m.display_name || m.username)}</span>
    687       ${m.username === state.username ? `<span style="font-size:0.7rem;opacity:0.4;text-transform:uppercase;letter-spacing:0.06em">owner</span>` : ''}
    688       ${cal.is_owner && m.username !== state.username ? `
    689         <button class="btn-icon danger" title="${t('remove')}" onclick="removeMember(${cal.id},'${escHtml(m.username)}')">
    690           <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
    691         </button>` : ''}
    692     </div>`).join('');
    693   document.getElementById('cal-members-invite').style.display = cal.is_owner ? '' : 'none';
    694   document.getElementById('cal-invite-username').value = '';
    695 }
    696 
    697 window.removeMember = async (calId, username) => {
    698   if (!confirm(`${t('remove')} ${username}?`)) return;
    699   try {
    700     await api('DELETE', `/api/calendars/${calId}/members/${encodeURIComponent(username)}`);
    701     await loadCalendars();
    702     renderMembersModal();
    703   } catch (err) { alert(err.message); }
    704 };
    705 
    706 document.getElementById('btn-members-add').addEventListener('click', async () => {
    707   const username = document.getElementById('cal-invite-username').value.trim();
    708   if (!username) return;
    709   try {
    710     await api('POST', `/api/calendars/${_membersCalId}/members`, { username });
    711     await loadCalendars();
    712     renderMembersModal();
    713   } catch (err) { alert(err.message); }
    714 });
    715 
    716 document.getElementById('btn-members-close').addEventListener('click', () => {
    717   document.getElementById('modal-members-overlay').classList.remove('open');
    718 });
    719 
    720 document.getElementById('modal-members-overlay').addEventListener('click', e => {
    721   if (e.target === document.getElementById('modal-members-overlay'))
    722     document.getElementById('modal-members-overlay').classList.remove('open');
    723 });
    724 
    725 document.getElementById('btn-new-cal').addEventListener('click', () => openCalModal('create'));
    726 
    727 // ── Event modal ───────────────────────────────────────────────────────────────
    728 const evModalOverlay = document.getElementById('event-modal-overlay');
    729 let editingEvent = null;
    730 
    731 function openEventModal(prefillDate, existingEvent = null) {
    732   editingEvent = existingEvent;
    733   const modal = document.getElementById('event-modal');
    734   modal.querySelector('h2').textContent = existingEvent ? t('edit-event') : t('new-event');
    735 
    736   // Calendar select
    737   const calSel = document.getElementById('ev-calendar');
    738   calSel.innerHTML = '';
    739   state.calendars.forEach(cal => {
    740     const opt = document.createElement('option');
    741     opt.value = cal.id;
    742     opt.textContent = cal.name;
    743     if (existingEvent && existingEvent.calendar_id === cal.id) opt.selected = true;
    744     calSel.appendChild(opt);
    745   });
    746 
    747   // Dates & times
    748   const d  = existingEvent ? new Date(existingEvent.start_dt) : (prefillDate || new Date());
    749   const eD = existingEvent ? new Date(existingEvent.end_dt)
    750     : new Date(d.getTime() + 60 * 60 * 1000);
    751 
    752   document.getElementById('ev-start-date').value = localDateStr(d);
    753   document.getElementById('ev-start-time').value = localTimeStr(d);
    754   document.getElementById('ev-end-date').value   = localDateStr(eD);
    755   document.getElementById('ev-end-time').value   = localTimeStr(eD);
    756 
    757   document.getElementById('ev-title').value       = existingEvent ? existingEvent.title : '';
    758   document.getElementById('ev-description').value = existingEvent ? (existingEvent.description || '') : '';
    759 
    760   const allDay = existingEvent?.all_day ?? false;
    761   document.getElementById('ev-all-day').checked = allDay;
    762   setAllDayMode(allDay);
    763 
    764   updateEventAttendees(existingEvent);
    765   calSel.addEventListener('change', () => updateEventAttendees(existingEvent));
    766 
    767   document.getElementById('ev-delete-btn').style.display = existingEvent ? 'inline-flex' : 'none';
    768 
    769   evModalOverlay.classList.add('open');
    770 }
    771 
    772 function updateEventAttendees(existingEvent) {
    773   const calId = parseInt(document.getElementById('ev-calendar').value);
    774   const cal   = state.calendars.find(c => c.id === calId);
    775   const group = document.getElementById('ev-attendees-group');
    776   const cont  = document.getElementById('ev-attendees');
    777   cont.innerHTML = '';
    778 
    779   if (!cal || cal.members.length <= 1) { group.style.display = 'none'; return; }
    780   group.style.display = 'block';
    781 
    782   const list = document.createElement('div');
    783   list.className = 'attendee-list';
    784   cal.members.forEach(m => {
    785     const label = document.createElement('label');
    786     label.className = 'attendee-item';
    787     const cb = document.createElement('input');
    788     cb.type = 'checkbox'; cb.value = m.username;
    789     cb.checked = existingEvent ? existingEvent.attendees.includes(m.username) : true;
    790     label.append(cb, document.createTextNode(m.display_name || m.username));
    791     list.appendChild(label);
    792   });
    793   cont.appendChild(list);
    794 }
    795 
    796 function setAllDayMode(on) {
    797   document.getElementById('ev-start-time-grp').style.display = on ? 'none' : '';
    798   document.getElementById('ev-end-time-grp').style.display   = on ? 'none' : '';
    799 }
    800 document.getElementById('ev-all-day').addEventListener('change', e => setAllDayMode(e.target.checked));
    801 
    802 function closeEventModal() { evModalOverlay.classList.remove('open'); editingEvent = null; }
    803 
    804 evModalOverlay.addEventListener('click', e => { if (e.target === evModalOverlay) closeEventModal(); });
    805 document.getElementById('event-modal-cancel').addEventListener('click', closeEventModal);
    806 
    807 document.getElementById('event-modal-submit').addEventListener('click', async () => {
    808   const title   = document.getElementById('ev-title').value.trim();
    809   const calId   = parseInt(document.getElementById('ev-calendar').value);
    810   const startD  = document.getElementById('ev-start-date').value;
    811   const startT  = document.getElementById('ev-start-time').value;
    812   const endD    = document.getElementById('ev-end-date').value;
    813   const endT    = document.getElementById('ev-end-time').value;
    814   const desc    = document.getElementById('ev-description').value.trim() || null;
    815 
    816   if (!title) return alert(t('alert-enter-title'));
    817 
    818   const allDay = document.getElementById('ev-all-day').checked;
    819   let startDt, endDt;
    820   if (allDay) {
    821     if (!startD || !endD) return alert(t('alert-fill-dates'));
    822     if (endD < startD) return alert(t('alert-end-after-start'));
    823     startDt = `${startD}T00:00:00`;
    824     endDt   = `${endD}T23:59:00`;
    825   } else {
    826     if (!startD || !startT || !endD || !endT) return alert(t('alert-fill-times'));
    827     startDt = `${startD}T${startT}:00`;
    828     endDt   = `${endD}T${endT}:00`;
    829     if (endDt <= startDt) return alert(t('alert-end-after-start'));
    830   }
    831 
    832   const attendees = [...document.querySelectorAll('#ev-attendees input[type=checkbox]:checked')]
    833     .map(cb => cb.value);
    834 
    835   const payload = {
    836     calendar_id: calId,
    837     title,
    838     start_dt: startDt,
    839     end_dt:   endDt,
    840     description: desc,
    841     all_day: allDay,
    842     attendee_usernames: attendees,
    843   };
    844 
    845   try {
    846     if (editingEvent) {
    847       await api('PATCH', `/api/events/${editingEvent.id}`, payload);
    848     } else {
    849       await api('POST', '/api/events', payload);
    850     }
    851     closeEventModal();
    852     await Promise.all([fetchEvents(), fetchUpcomingEvents()]);
    853     renderMonthly();
    854     if (state.selectedDay) renderDailyEvents();
    855   } catch (err) { alert(err.message); }
    856 });
    857 
    858 document.getElementById('ev-delete-btn').addEventListener('click', async () => {
    859   if (!editingEvent || !confirm(`Delete "${editingEvent.title}"?`)) return;
    860   await api('DELETE', `/api/events/${editingEvent.id}`);
    861   closeEventModal();
    862   await Promise.all([fetchEvents(), fetchUpcomingEvents()]);
    863   renderMonthly();
    864   if (state.selectedDay) renderDailyEvents();
    865 });
    866 
    867 // ── Helpers ───────────────────────────────────────────────────────────────────
    868 function localDateStr(d) {
    869   return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
    870 }
    871 function localTimeStr(d) {
    872   return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
    873 }
    874 function escHtml(s) {
    875   return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
    876 }
    877 function svgPlus() {
    878   return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>';
    879 }
    880 function svgMinus() {
    881   return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M5 12h14" stroke-linecap="round"/></svg>';
    882 }
    883 
    884 // ── Language change ───────────────────────────────────────────────────────────
    885 document.addEventListener('langchange', () => {
    886   renderMonthly();
    887   renderSidebar();
    888   renderUpcomingEvents();
    889   if (state.selectedDay) {
    890     const date = new Date(state.year, state.month, state.selectedDay);
    891     document.getElementById('daily-title').textContent = date.toLocaleDateString(
    892       getLang() === 'de' ? 'de-DE' : 'en-GB',
    893       { weekday: 'long', day: 'numeric', month: 'long' }
    894     );
    895   }
    896 });
    897 
    898 // ── Start ─────────────────────────────────────────────────────────────────────
    899 init().catch(err => console.error('Init failed:', err));