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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 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));