From 8d2f48760786d2db309cf0c4f3b66deaf9faed09 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 17:23:28 +0200 Subject: [PATCH] feat: flache sortierbare Kalenderliste (Drag&Drop) + Fixes - Sidebar: eine flache Kalenderliste statt Quellen-Gruppen; Quelle/Konto klein-grau inline rechts neben dem Namen; per Drag&Drop sortierbar (Reihenfolge pro Geraet in localStorage). - Gruppenkalender serverseitig auch beim Besitzer als group:true markiert -> erscheint nicht mehr in der "Fuer Gruppen sichtbar"-Auswahl und nicht in der normalen Kalenderliste (nur unter Gruppen). - Settings-URL-State: uiSettingsOpen wird beim Init aus der URL gesetzt, bevor das erste writeUrlState() es ueberschreibt -> Reload bleibt jetzt wirklich in den Einstellungen. - Auswahl-Markierungen (Mitglieder/Gruppen-Sichtbar) in Akzentfarbe, CSS-gezeichnet statt blauer Emoji. Version v28. Co-Authored-By: Claude Opus 4.8 --- backend/routers/local_router.py | 52 +++++--- frontend/css/app.css | 33 ++++- frontend/js/calendar.js | 222 ++++++++++++++++---------------- frontend/js/i18n.js | 2 + frontend/js/version.js | 2 +- 5 files changed, 181 insertions(+), 130 deletions(-) diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index d4e16c8..7060f18 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -91,13 +91,34 @@ def list_calendars( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): + # Map calendar_id -> group name for every group the user belongs to, so we + # can flag group calendars as such even when the user owns them (the creator + # owns the group calendar — it must still be marked group:true). + group_cal_map = { + cal_id: name + for cal_id, name in ( + db.query(models.GroupCalendar.calendar_id, models.Group.name) + .join(models.Group, models.Group.id == models.GroupCalendar.group_id) + .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id) + .filter(models.GroupMember.user_id == current_user.id) + .all() + ) + } + # Own calendars own = ( db.query(models.LocalCalendar) .filter(models.LocalCalendar.user_id == current_user.id) .all() ) - result = [_cal_dict(c, owned=True) for c in own] + result = [] + for c in own: + d = _cal_dict(c, owned=True) + if c.id in group_cal_map: + d["group"] = True + result.append(d) + + seen_ids = {c.id for c in own} # Calendars shared with this user shares = ( @@ -105,33 +126,30 @@ def list_calendars( .filter(models.CalendarShare.user_id == current_user.id) .all() ) - seen_ids = {c.id for c in own} for share in shares: cal = share.calendar if cal is None or cal.id in seen_ids: continue seen_ids.add(cal.id) owner = db.query(models.User).filter(models.User.id == cal.user_id).first() - result.append(_cal_dict( + d = _cal_dict( cal, owned=False, shared_by=owner.username if owner else None, permission=share.permission, - )) + ) + if cal.id in group_cal_map: + d["group"] = True + result.append(d) - # Group calendars the user can reach via membership (read_write), so members - # can select the group calendar in the editor and see it in their list. - group_cals = ( - db.query(models.LocalCalendar, models.Group.name) - .join(models.GroupCalendar, models.GroupCalendar.calendar_id == models.LocalCalendar.id) - .join(models.Group, models.Group.id == models.GroupCalendar.group_id) - .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id) - .filter(models.GroupMember.user_id == current_user.id) - .all() - ) - for cal, group_name in group_cals: - if cal.id in seen_ids: + # Group calendars reached via membership (read_write) that aren't already + # listed, so members can select/see the group calendar. + for cal_id, group_name in group_cal_map.items(): + if cal_id in seen_ids: continue - seen_ids.add(cal.id) + cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == cal_id).first() + if not cal: + continue + seen_ids.add(cal_id) d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write") d["group"] = True result.append(d) diff --git a/frontend/css/app.css b/frontend/css/app.css index c20b8e9..20d61de 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1887,8 +1887,37 @@ a { color: var(--primary); text-decoration: none; } } .pick-row:last-child { border-bottom: none; } .pick-row:hover { background: var(--bg-surface); } -.pick-row-sel { background: rgba(66, 133, 244, 0.12); } -.pick-mark { flex: 0 0 auto; width: 18px; text-align: center; color: var(--primary); } +.pick-row-sel { box-shadow: inset 3px 0 0 var(--accent); } +.pick-mark { + flex: 0 0 auto; + width: 18px; height: 18px; + border: 2px solid var(--text-3); + display: inline-flex; align-items: center; justify-content: center; + font-size: 12px; line-height: 1; color: #fff; +} +.pick-check { border-radius: 4px; } +.pick-radio { border-radius: 50%; } +.pick-mark.on { background: var(--accent); border-color: var(--accent); } .pick-dot { flex: 0 0 auto; width: 12px; height: 12px; border-radius: 50%; } .pick-dot-empty { background: transparent; } .pick-name { flex: 1 1 auto; text-align: left; } + +/* Flat calendar list: inline source label + drag handle. */ +.cal-source { + margin-left: auto; + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 8px; +} +.cal-drag-handle { + flex: 0 0 auto; + cursor: grab; + color: var(--text-3); + font-size: 14px; + user-select: none; +} +.cal-item.cal-dragging { opacity: .5; } diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index cede4af..2cec07c 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -115,6 +115,9 @@ export async function initCalendar() { const urlState = readUrlState(); if (urlState.date) state.currentDate = urlState.date; if (urlState.view) state.currentView = urlState.view; + // Preserve the settings flag through the first writeUrlState() (fired by the + // initial fetchAndRender) so a reload reopens settings instead of stripping it. + uiSettingsOpen = urlState.settings === true; setLang(settings.language || 'de'); applyTheme(settings); @@ -137,7 +140,7 @@ export async function initCalendar() { loadGroups(); // Reopen the settings modal after a reload if the URL says we were in it. - if (readUrlState().settings) openSettingsModal(); + if (urlState.settings) openSettingsModal(); // Browser-Back/Forward: URL-Hash → State synchronisieren window.addEventListener('hashchange', () => { @@ -562,122 +565,121 @@ function renderMiniCal() { } // ── Calendar List ───────────────────────────────────────── +const CAL_ORDER_KEY = 'cal_order'; +function loadCalOrder() { + try { return JSON.parse(localStorage.getItem(CAL_ORDER_KEY) || '[]'); } + catch (e) { return []; } +} +function saveCalOrder(keys) { + localStorage.setItem(CAL_ORDER_KEY, JSON.stringify(keys)); +} + +// Drag & drop reordering of the flat calendar list (persisted per device). +function bindCalDragReorder(container) { + let dragKey = null; + container.querySelectorAll('.cal-item').forEach(item => { + item.addEventListener('dragstart', e => { + // Don't start a drag from interactive children (checkbox, color dot, buttons). + if (e.target.closest('input, button, .cal-item-dot')) { e.preventDefault(); return; } + dragKey = item.dataset.key; + e.dataTransfer.effectAllowed = 'move'; + item.classList.add('cal-dragging'); + }); + item.addEventListener('dragend', () => { + dragKey = null; + item.classList.remove('cal-dragging'); + }); + item.addEventListener('dragover', e => { e.preventDefault(); }); + item.addEventListener('drop', e => { + e.preventDefault(); + const targetKey = item.dataset.key; + if (!dragKey || dragKey === targetKey) return; + const keys = [...container.querySelectorAll('.cal-item')].map(el => el.dataset.key); + const from = keys.indexOf(dragKey); + const to = keys.indexOf(targetKey); + if (from === -1 || to === -1) return; + keys.splice(to, 0, keys.splice(from, 1)[0]); + saveCalOrder(keys); + renderCalendarList(); + }); + }); +} + function renderCalendarList() { const container = document.getElementById('cal-list-items'); - let html = ''; + // Eye-off (hide external calendar) and trash (delete local/ical) icons. + const EYE_OFF = ``; + const TRASH = ``; - // ── CalDAV accounts ──────────────────────────────────── - if (state.accounts.length) { - html += state.accounts.map(acc => { - const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden); - if (!visibleCals.length) return ''; - return `` + - visibleCals.map(cal => - `
- -
- ${escHtml(cal.name)} - -
` - ).join(''); - }).join(''); - } + // Build a single flat list of all calendars. The source/account is shown + // inline (small, grey) next to the name and section headers are gone, so the + // whole list can be freely reordered via drag & drop. + const entries = []; + state.accounts.forEach(acc => { + (acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => { + entries.push({ key: `caldav:${cal.id}`, source: 'caldav', dataId: `data-cal-id="${cal.id}"`, + name: cal.name, color: cal.color || '#4285f4', enabled: cal.enabled, + sourceLabel: acc.name, remove: { icon: EYE_OFF, title: t('hide_cal') } }); + }); + }); + state.localCalendars.filter(c => c.owned !== false && !c.group).forEach(cal => { + entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`, + name: cal.name, color: cal.color, enabled: cal.enabled, + sourceLabel: t('cal_local'), remove: { icon: TRASH, title: t('remove_cal') } }); + }); + state.localCalendars.filter(c => c.owned === false && !c.group).forEach(cal => { + entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`, + name: cal.name, color: cal.color, enabled: cal.enabled, + sourceLabel: `${t('shared_with_me')} · ${cal.shared_by || ''}`, remove: null }); + }); + state.icalSubscriptions.forEach(sub => { + entries.push({ key: `ical:${sub.id}`, source: 'ical', dataId: `data-sub-id="${sub.id}"`, + name: sub.name, color: sub.color, enabled: sub.enabled, + sourceLabel: t('cal_ical'), remove: { icon: TRASH, title: t('remove_ical_sub') } }); + }); + state.googleAccounts.forEach(acc => { + (acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => { + entries.push({ key: `google:${cal.id}`, source: 'google', dataId: `data-cal-id="${cal.id}"`, + name: cal.name, color: cal.color || '#4285f4', enabled: cal.enabled, + sourceLabel: acc.email, remove: { icon: EYE_OFF, title: t('hide_cal') } }); + }); + }); + state.haAccounts.forEach(acc => { + (acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => { + entries.push({ key: `homeassistant:${cal.id}`, source: 'homeassistant', dataId: `data-cal-id="${cal.id}"`, + name: cal.name, color: cal.color || '#03a9f4', enabled: cal.enabled, + sourceLabel: acc.name, remove: { icon: EYE_OFF, title: t('hide_cal') } }); + }); + }); - // ── Local calendars: own ones, then a separate "shared with me" group ── - const ownLocal = state.localCalendars.filter(c => c.owned !== false); - const sharedLocal = state.localCalendars.filter(c => c.owned === false && !c.group); + // Apply the saved manual order (per device); unknown calendars append at end. + const order = loadCalOrder(); + entries.sort((a, b) => { + const ia = order.indexOf(a.key), ib = order.indexOf(b.key); + if (ia === -1 && ib === -1) return 0; + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + saveCalOrder(entries.map(e => e.key)); - const renderLocalItem = (cal, withRemove) => { - const removeBtn = withRemove - ? `` - : ''; - return `
- -
- ${escHtml(cal.name)} - ${removeBtn} -
`; - }; - - if (ownLocal.length) { - html += ``; - html += ownLocal.map(c => renderLocalItem(c, true)).join(''); - } - if (sharedLocal.length) { - html += ``; - html += sharedLocal.map(cal => - `
- -
- ${escHtml(cal.name)} - ${escHtml(cal.shared_by || '')} -
` - ).join(''); - } - - // ── iCal subscriptions ───────────────────────────────── - if (state.icalSubscriptions.length) { - html += ``; - html += state.icalSubscriptions.map(sub => - `
- -
- ${escHtml(sub.name)} - -
` - ).join(''); - } - - // ── Google accounts ─────────────────────────────────── - if (state.googleAccounts.length) { - html += state.googleAccounts.map(acc => { - const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden); - if (!visibleCals.length) return ``; - return `` + - visibleCals.map(cal => - `
- -
- ${escHtml(cal.name)} - -
` - ).join(''); - }).join(''); - } - - // ── Home Assistant accounts ─────────────────────────── - if (state.haAccounts.length) { - html += state.haAccounts.map(acc => { - const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden); - if (!visibleCals.length) return ``; - return `` + - visibleCals.map(cal => - `
- -
- ${escHtml(cal.name)} - -
` - ).join(''); - }).join(''); - } - - if (!html) { + if (!entries.length) { container.innerHTML = `
${t('error_no_calendars')}
`; return; } - container.innerHTML = html; + container.innerHTML = entries.map(e => + `
+ + +
+ ${escHtml(e.name)} + ${escHtml(e.sourceLabel)} + ${e.remove ? `` : ''} +
` + ).join(''); + + bindCalDragReorder(container); // ── Checkbox handlers ────────────────────────────────── container.querySelectorAll('input[type=checkbox]').forEach(cb => { @@ -2304,7 +2306,7 @@ function renderGroupMemberPicker() { ? dir.map(u => { const on = picked.has(u.id); return ``; }).join('') @@ -2381,7 +2383,7 @@ function renderGroupVisibleList(selectedId) { ? `` : ``; return ``; diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 5823dee..f8df184 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -127,6 +127,7 @@ const translations = { settings_group_visible: 'Für Gruppen sichtbarer Kalender', settings_group_visible_desc: 'Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist', group_visible_none: 'Keiner', + drag_reorder: 'Zum Sortieren ziehen', settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)', settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt', hour_compact: 'Kompakt', hour_normal: 'Normal', @@ -385,6 +386,7 @@ const translations = { settings_group_visible: 'Calendar visible to groups', settings_group_visible_desc: 'Choose which of your calendars your group members can see', group_visible_none: 'None', + drag_reorder: 'Drag to reorder', settings_hour_height: 'Hour height (week & day view)', settings_hour_height_desc: 'How much space one hour takes in the time grid', hour_compact: 'Compact', hour_normal: 'Normal', diff --git a/frontend/js/version.js b/frontend/js/version.js index 3a64095..3238676 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 = 'v27'; +export const APP_VERSION = 'v28';