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 `