From d1c5b662303b47f8b0f8ec0ac844204981a2489d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 15 Jun 2026 20:36:09 +0200 Subject: [PATCH] feat: web settings restructure + popup drag + calendar management table (v52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "Ansicht" tab; move Kalenderansicht to top of Darstellung - Reorder Darstellung: Kalenderansicht → Sprache → Stundenhöhe → Farben - Move Standard-Termindauer to Profil tab as contrast-button selector - Replace per-source account sections in Kalender tab with unified calendar management table (Name/Herkunft/Ausgeblendet/Benachrichtigungen/ Export·Import/Sync/Delete) covering all calendar sources - Add "Konto hinzufügen" buttons (Lokal/CalDAV/iCal/HA/Google) to Kalender tab - Popup drag-to-move via pointer capture on .popup-header Co-Authored-By: Claude Sonnet 4.6 --- frontend/css/app.css | 26 ++++ frontend/index.html | 134 +++++++---------- frontend/js/calendar.js | 310 ++++++++++++++++++++++++++++++++++++++-- frontend/js/version.js | 2 +- 4 files changed, 380 insertions(+), 92 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index d073533..431df1a 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1137,6 +1137,7 @@ a { color: var(--primary); text-decoration: none; } background: linear-gradient(180deg, color-mix(in srgb, var(--ev-color, var(--primary)) 13%, transparent), transparent); } +.ep-dragging { user-select: none; } /* Slim accent strip in the event's colour. */ .popup-header::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; @@ -1430,6 +1431,31 @@ a { color: var(--primary); text-decoration: none; } width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block; } +/* ── Calendar Management Table ──────────────────────────── */ +.accounts-add-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } +.cal-manage-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.cal-manage-table th { + text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: .4px; color: var(--text-3); padding: 0 8px 8px 0; + border-bottom: 1px solid var(--border-light); +} +.cal-manage-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border-light); vertical-align: middle; } +.cal-manage-table tbody tr:last-child td { border-bottom: none; } +.ct-acc-row td { padding-top: 14px; background: none; } +.ct-acc-row td:first-child { font-size: 13px; } +.ct-cal-row .ct-indent { padding-left: 16px; } +.ct-src { font-size: 11px; color: var(--text-3); white-space: nowrap; } +.ct-badge { + display: inline-block; font-size: 10px; font-weight: 600; padding: 1px 5px; + border-radius: 3px; background: var(--surface-2); color: var(--text-3); + margin-left: 6px; vertical-align: middle; +} +.ct-badge-g { background: #e8f0fe; color: #1a73e8; } +.ct-badge-ha { background: #e8f5e9; color: #2e7d32; } +.ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; } +.ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; } +.ct-toggle { cursor: pointer; } + /* ── Month View (spanning bars) ─────────────────────────── */ .month-body { display: flex; flex-direction: column; flex: 1; overflow: hidden; diff --git a/frontend/index.html b/frontend/index.html index 508b35b..e87ba2b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -692,7 +692,6 @@ @@ -727,6 +726,16 @@ +

Standard-Termindauer

+
+ + + + + + +
+

Geteilter Kalender

Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist

@@ -735,10 +744,35 @@
- +
-

Sprache

+

Kalenderansicht

+
+ + +
+
+ + +
+
+ +
+ +

Sprache

+

Stundenhöhe (Wochen- & Tagesansicht)

+

Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt

+
+ + + + +
+

Farben

@@ -807,88 +850,19 @@
- -

Stundenhöhe (Wochen- & Tagesansicht)

-

Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt

-
- - - - -
- -
-

Kalenderansicht

-
- - -
-
- - -
-
- -
-
- - -
- -

Ausgeblendete Kalender

-
Keine ausgeblendeten Kalender
-
- - +

Kalender

- -
-
CalDAV-Konten
-
Keine CalDAV-Konten
-
- -
-
Lokale Kalender
-
Keine lokalen Kalender
-
- -
-
iCal-Abonnements
-
Keine Abonnements
-
- -
-
Google-Konten
-
Keine Google-Konten
-
- -
-
Home Assistant
-
Keine HA-Konten
+
+ + + + +
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index b50f181..fd27cf2 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -1425,6 +1425,33 @@ function showEventPopup(ev, anchor) { popup.style.left = Math.max(8, left) + 'px'; popup.style.top = Math.max(8, top) + 'px'; + // Drag-to-move: grab the header and drag the popup anywhere on screen + const dragHandle = popup.querySelector('.popup-header'); + let dragOffX = 0, dragOffY = 0; + dragHandle.style.cursor = 'grab'; + const onPointerMove = e => { + if (!popup.classList.contains('ep-dragging')) return; + const vw = window.innerWidth, vh = window.innerHeight; + const x = Math.max(0, Math.min(vw - popup.offsetWidth, e.clientX - dragOffX)); + const y = Math.max(0, Math.min(vh - popup.offsetHeight, e.clientY - dragOffY)); + popup.style.left = x + 'px'; + popup.style.top = y + 'px'; + }; + const onPointerUp = () => { + popup.classList.remove('ep-dragging'); + dragHandle.style.cursor = 'grab'; + }; + dragHandle.onpointerdown = e => { + if (e.button !== 0) return; + dragOffX = e.clientX - popup.getBoundingClientRect().left; + dragOffY = e.clientY - popup.getBoundingClientRect().top; + dragHandle.setPointerCapture(e.pointerId); + popup.classList.add('ep-dragging'); + dragHandle.style.cursor = 'grabbing'; + }; + dragHandle.onpointermove = onPointerMove; + dragHandle.onpointerup = onPointerUp; + // Hide edit/delete for read-only iCal subscription events const isReadOnly = (ev.source === 'ical'); document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : ''; @@ -3004,7 +3031,6 @@ function openSettingsModal() { prev.style.background = val || fallback; }); document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; - document.getElementById('cfg-default-duration').value = String(s.default_event_duration_minutes || 60); document.getElementById('cfg-language').value = getLang(); document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy'; renderGroupVisibleList(s.group_visible_calendar_id); @@ -3017,11 +3043,12 @@ function openSettingsModal() { document.getElementById('cfg-email').value = p.email || ''; }).catch(() => {}); - // Set active contrast/hour-height buttons + // Set active contrast/hour-height/duration buttons [ - { id: 'cfg-text-contrast', val: s.text_contrast || 3 }, - { id: 'cfg-line-contrast', val: s.line_contrast || 3 }, - { id: 'cfg-hour-height', val: s.hour_height || 60 }, + { id: 'cfg-text-contrast', val: s.text_contrast || 3 }, + { id: 'cfg-line-contrast', val: s.line_contrast || 3 }, + { id: 'cfg-hour-height', val: s.hour_height || 60 }, + { id: 'cfg-event-duration', val: s.default_event_duration_minutes || 60 }, ].forEach(({ id, val }) => { const sel = document.getElementById(id); if (!sel) return; @@ -3040,9 +3067,8 @@ function openSettingsModal() { const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)'); if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel); - // Render all accounts and hidden calendars + // Render unified calendar table renderAllAccounts(); - renderHiddenCalendars(); openModal('modal-settings'); } @@ -3215,9 +3241,6 @@ function renderAllAccounts() { } } - // Google accounts section — delegate to existing function - renderGoogleAccounts(); - // Home Assistant accounts section const haList = document.getElementById('accounts-ha-list'); if (haList) { @@ -3262,10 +3285,13 @@ function renderAllAccounts() { }); } } + + renderCalendarTable(); } function renderHiddenCalendars() { const list = document.getElementById('hidden-cals-list'); + if (!list) return; const hidden = []; for (const acc of state.accounts) { for (const cal of acc.calendars) { @@ -3325,6 +3351,252 @@ function renderHiddenCalendars() { }); } +function renderCalendarTable() { + const container = document.getElementById('cal-settings-table'); + if (!container) return; + + const TRASH = ``; + let rowCount = 0; + + const hid = (src, id, checked) => + ``; + const rem = (src, id, checked) => + ``; + const dot = (color, fb) => + ``; + + let rows = ''; + + // Local calendars + for (const cal of state.localCalendars) { + if (cal.group) continue; + const owned = cal.owned !== false; + const canWrite = owned || cal.permission === 'read_write'; + rows += ` + ${dot(cal.color, '#34a853')}${escHtml(cal.name)} + Lokal + — + ${owned ? rem('local', cal.id, cal.reminders_enabled !== false) : '—'} + + + ${canWrite ? `` : ''} + + — + ${owned ? `` : ''} + `; + rowCount++; + } + + // iCal subscriptions + for (const sub of state.icalSubscriptions) { + rows += ` + ${dot(sub.color, '#aa66cc')}${escHtml(sub.name)} + iCal + ${hid('ical', sub.id, !sub.sidebar_hidden)} + ${rem('ical', sub.id, sub.reminders_enabled !== false)} + — + — + + `; + rowCount++; + } + + // CalDAV accounts + their calendars + for (const acc of state.accounts) { + rows += ` + ${escHtml(acc.name)}CalDAV + ——— + + + `; + rowCount++; + for (const cal of (acc.calendars || [])) { + rows += ` + ${dot(cal.color, '#4285f4')}${escHtml(cal.name)} + ${escHtml(acc.name)} + ${hid('caldav', cal.id, !cal.sidebar_hidden)} + ${rem('caldav', cal.id, cal.reminders_enabled !== false)} + ——— + `; + rowCount++; + } + } + + // Google accounts + their calendars + for (const acc of state.googleAccounts) { + rows += ` + ${escHtml(acc.email)}Google + ——— + + + `; + rowCount++; + for (const cal of (acc.calendars || [])) { + rows += ` + ${dot(cal.color, '#4285f4')}${escHtml(cal.name)} + ${escHtml(acc.email)} + ${hid('google', cal.id, !cal.sidebar_hidden)} + ${rem('google', cal.id, cal.reminders_enabled !== false)} + ——— + `; + rowCount++; + } + } + + // Home Assistant accounts + their calendars + for (const acc of state.haAccounts) { + rows += ` + ${escHtml(acc.name)}Home Assistant + ——— + + + `; + rowCount++; + for (const cal of (acc.calendars || [])) { + rows += ` + ${dot(cal.color, '#03a9f4')}${escHtml(cal.name)} + ${escHtml(acc.name)} + ${hid('ha', cal.id, !cal.sidebar_hidden)} + ${rem('ha', cal.id, cal.reminders_enabled !== false)} + ——— + `; + rowCount++; + } + } + + if (!rowCount) { + rows = `Keine Kalender vorhanden`; + } + + container.innerHTML = ` + + + + ${rows} +
NameHerkunftAusgeblendetBenachrichtigungenExport / ImportSync
`; + + // Hidden toggle + container.querySelectorAll('.ct-toggle[data-ct-hid]').forEach(cb => { + cb.addEventListener('change', async () => { + const src = cb.dataset.ctHid, id = parseInt(cb.dataset.ctId), hidden = !cb.checked; + try { + if (src === 'ical') { + await api.put(`/ical/subscriptions/${id}`, { sidebar_hidden: hidden }); + const s = state.icalSubscriptions.find(s => s.id === id); + if (s) s.sidebar_hidden = hidden; + } else if (src === 'caldav') { + await api.put(`/caldav/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden }); + for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } } + } else if (src === 'google') { + await api.put(`/google/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden }); + for (const acc of state.googleAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } } + } else if (src === 'ha') { + await api.put(`/homeassistant/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden }); + for (const acc of state.haAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } } + } + renderCalendarList(); + } catch (e) { showToast(e.message, true); cb.checked = !cb.checked; } + }); + }); + + // Reminders toggle + container.querySelectorAll('.ct-toggle[data-ct-rem]').forEach(cb => { + cb.addEventListener('change', async () => { + const src = cb.dataset.ctRem, id = parseInt(cb.dataset.ctId), enabled = cb.checked; + const path = src === 'ical' ? `/ical/subscriptions/${id}` : `/${src === 'ha' ? 'homeassistant' : src}/calendars/${id}`; + try { + await api.put(path, { reminders_enabled: enabled }); + if (src === 'local') { const c = state.localCalendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } + else if (src === 'ical') { const s = state.icalSubscriptions.find(s => s.id === id); if (s) s.reminders_enabled = enabled; } + else if (src === 'caldav') { for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } } + else if (src === 'google') { for (const acc of state.googleAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } } + else if (src === 'ha') { for (const acc of state.haAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } } + renderCalendarList(); + } catch (e) { showToast(e.message, true); cb.checked = !cb.checked; } + }); + }); + + // Sync buttons + container.querySelectorAll('[data-ct-sync]').forEach(btn => { + btn.addEventListener('click', async () => { + const src = btn.dataset.ctSync, id = parseInt(btn.dataset.ctId); + const label = btn.textContent; + btn.disabled = true; btn.textContent = '…'; + try { + if (src === 'caldav') { + await api.post(`/caldav/accounts/${id}/sync`); + } else if (src === 'google') { + const updated = await api.post(`/google/accounts/${id}/sync`); + const idx = state.googleAccounts.findIndex(a => a.id === updated.id); + if (idx !== -1) state.googleAccounts[idx] = updated; + } else if (src === 'ha') { + const updated = await api.post(`/homeassistant/accounts/${id}/sync`); + const idx = state.haAccounts.findIndex(a => a.id === updated.id); + if (idx !== -1) state.haAccounts[idx] = updated; + } + renderCalendarTable(); renderCalendarList(); fetchAndRender(true); + showToast(t('google_synced')); + } catch (e) { showToast(e.message, true); } + finally { btn.disabled = false; btn.textContent = label; } + }); + }); + + // Disconnect buttons + container.querySelectorAll('[data-ct-disc]').forEach(btn => { + btn.addEventListener('click', async () => { + const src = btn.dataset.ctDisc, id = parseInt(btn.dataset.ctId); + const msg = src === 'caldav' ? t('confirm_caldav_disconnect') + : src === 'google' ? t('confirm_google_disconnect') + : 'Konto wirklich trennen?'; + if (!confirm(msg)) return; + try { + if (src === 'caldav') { + await api.delete(`/caldav/accounts/${id}`); + state.accounts = state.accounts.filter(a => a.id !== id); + } else if (src === 'google') { + await api.delete(`/google/accounts/${id}`); + state.googleAccounts = state.googleAccounts.filter(a => a.id !== id); + } else if (src === 'ha') { + await api.delete(`/homeassistant/accounts/${id}`); + state.haAccounts = state.haAccounts.filter(a => a.id !== id); + } + renderCalendarTable(); renderCalendarList(); fetchAndRender(true); + showToast(t('caldav_disconnected')); + } catch (e) { showToast(e.message, true); } + }); + }); + + // Export / Import + container.querySelectorAll('[data-ct-export]').forEach(btn => { + btn.addEventListener('click', async () => { + try { await api.download(`/local/calendars/${btn.dataset.ctId}/export`, `${btn.dataset.ctName || 'calendar'}.ics`); } + catch (e) { showToast(e.message, true); } + }); + }); + container.querySelectorAll('[data-ct-import]').forEach(btn => { + btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId))); + }); + + // Delete + container.querySelectorAll('[data-ct-del]').forEach(btn => { + btn.addEventListener('click', async () => { + const src = btn.dataset.ctDel, id = parseInt(btn.dataset.ctId); + const msg = src === 'local' ? t('confirm_delete_local_cal') : t('confirm_remove_ical'); + if (!confirm(msg)) return; + try { + if (src === 'local') { + await api.delete(`/local/calendars/${id}`); + state.localCalendars = state.localCalendars.filter(c => c.id !== id); + } else { + await api.delete(`/ical/subscriptions/${id}`); + state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== id); + } + renderCalendarTable(); renderCalendarList(); fetchAndRender(true); + } catch (e) { showToast(e.message, true); } + }); + }); +} + async function loadUsers() { try { const users = await api.get('/users/'); @@ -3515,7 +3787,7 @@ function bindSettingsModal() { line_color: colourOrNull('cfg-line-color-hex'), bg_color: colourOrNull('cfg-bg-color-hex'), dim_past_events: document.getElementById('cfg-dim-past').checked, - default_event_duration_minutes: parseInt(document.getElementById('cfg-default-duration').value, 10) || 60, + default_event_duration_minutes: getActive('cfg-event-duration') || 60, hour_height: getActive('cfg-hour-height') || 44, language: document.getElementById('cfg-language').value, private_event_visibility: document.getElementById('cfg-private-visibility').value, @@ -3535,6 +3807,22 @@ function bindSettingsModal() { fetchAndRender(); } catch (e) { showToast(e.message, true); } }; + + // Add-account buttons in calendar panel + const addBtnMap = { + 'settings-btn-add-local': openLocalCalModal, + 'settings-btn-add-caldav': openAccountModal, + 'settings-btn-add-ical': openICalSubModal, + 'settings-btn-add-ha': openHAAccountModal, + 'settings-btn-add-google': async () => { + const url = await api.get('/google/auth/url'); + if (url && url.auth_url) window.open(url.auth_url, '_blank'); + }, + }; + Object.entries(addBtnMap).forEach(([id, fn]) => { + const btn = document.getElementById(id); + if (btn) btn.addEventListener('click', () => { closeModal('modal-settings'); fn(); }); + }); } // ── Profile Modal ───────────────────────────────────────── diff --git a/frontend/js/version.js b/frontend/js/version.js index 8a83bf5..53d928b 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v51'; +export const APP_VERSION = 'v52';