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 @@
+
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 = `
+
+ | Name | Herkunft | Ausgeblendet | Benachrichtigungen | Export / Import | Sync | |
+
+ ${rows}
+
`;
+
+ // 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';