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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-31 17:23:28 +02:00
parent c7185a128e
commit 8d2f487607
5 changed files with 181 additions and 130 deletions

View File

@@ -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,
))
# 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:
if cal.id in group_cal_map:
d["group"] = True
result.append(d)
# 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)

View File

@@ -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; }

View File

@@ -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 = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>`;
const TRASH = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
// ── 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 `<div class="cal-account-name">${escHtml(acc.name)}</div>` +
visibleCals.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}" data-source="caldav">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="caldav" />
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="caldav" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="caldav">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="caldav" title="${t('hide_cal')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
</button>
</div>`
).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
? `<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="local" title="${t('remove_cal')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>`
: '';
return `<div class="cal-item" data-cal-id="${cal.id}" data-source="local">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="local" />
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="local" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="local">${escHtml(cal.name)}</span>
${removeBtn}
</div>`;
};
if (ownLocal.length) {
html += `<div class="cal-account-name">${t('cal_local')}</div>`;
html += ownLocal.map(c => renderLocalItem(c, true)).join('');
}
if (sharedLocal.length) {
html += `<div class="cal-account-name">${t('shared_with_me')}</div>`;
html += sharedLocal.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}" data-source="local">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="local" />
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="local" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="local">${escHtml(cal.name)}</span>
<span class="cal-badge cal-badge-shared" title="${t('shared_by', { name: cal.shared_by || '' })}">${escHtml(cal.shared_by || '')}</span>
</div>`
).join('');
}
// ── iCal subscriptions ─────────────────────────────────
if (state.icalSubscriptions.length) {
html += `<div class="cal-account-name">${t('cal_ical')}</div>`;
html += state.icalSubscriptions.map(sub =>
`<div class="cal-item" data-sub-id="${sub.id}" data-source="ical">
<input type="checkbox" ${sub.enabled ? 'checked' : ''} data-sub-id="${sub.id}" data-source="ical" />
<div class="cal-item-dot" style="background:${sub.color}" data-sub-id="${sub.id}" data-source="ical" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="ical">${escHtml(sub.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-sub-id="${sub.id}" data-source="ical" title="${t('remove_ical_sub')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>`
).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 `<div class="cal-account-name">${escHtml(acc.email)}</div>`;
return `<div class="cal-account-name">${escHtml(acc.email)}</div>` +
visibleCals.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}" data-source="google">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="google" />
<div class="cal-item-dot" style="background:${cal.color || '#4285f4'}" data-cal-id="${cal.id}" data-source="google" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="google">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="google" title="${t('hide_cal')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
</button>
</div>`
).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 `<div class="cal-account-name">${escHtml(acc.name)}</div>`;
return `<div class="cal-account-name">${escHtml(acc.name)}</div>` +
visibleCals.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}" data-source="homeassistant">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="homeassistant" />
<div class="cal-item-dot" style="background:${cal.color || '#03a9f4'}" data-cal-id="${cal.id}" data-source="homeassistant" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="homeassistant">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="homeassistant" title="${t('hide_cal')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
</button>
</div>`
).join('');
}).join('');
}
if (!html) {
if (!entries.length) {
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">${t('error_no_calendars')}</div>`;
return;
}
container.innerHTML = html;
container.innerHTML = entries.map(e =>
`<div class="cal-item" draggable="true" data-key="${e.key}" data-source="${e.source}" ${e.dataId}>
<span class="cal-drag-handle" title="${t('drag_reorder')}">⠿</span>
<input type="checkbox" ${e.enabled ? 'checked' : ''} data-source="${e.source}" ${e.dataId} />
<div class="cal-item-dot" style="background:${e.color}" data-source="${e.source}" ${e.dataId} title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="${e.source}">${escHtml(e.name)}</span>
<span class="cal-source">${escHtml(e.sourceLabel)}</span>
${e.remove ? `<button class="icon-btn mini-btn cal-item-remove" data-source="${e.source}" ${e.dataId} title="${e.remove.title}">${e.remove.icon}</button>` : ''}
</div>`
).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 `<div class="pick-row ${on ? 'pick-row-sel' : ''}" data-member-id="${u.id}" role="checkbox" aria-checked="${on}">
<span class="pick-mark">${on ? '' : ''}</span>
<span class="pick-mark pick-check ${on ? 'on' : ''}">${on ? '' : ''}</span>
<span class="pick-name">${escHtml(u.display_name || '')}</span>
</div>`;
}).join('')
@@ -2381,7 +2383,7 @@ function renderGroupVisibleList(selectedId) {
? `<span class="pick-dot" style="background:${color}"></span>`
: `<span class="pick-dot pick-dot-empty"></span>`;
return `<div class="pick-row ${sel ? 'pick-row-sel' : ''}" data-pick="${val}" role="radio" aria-checked="${sel}">
<span class="pick-mark">${sel ? '' : ''}</span>
<span class="pick-mark pick-radio ${sel ? 'on' : ''}"></span>
${dot}
<span class="pick-name">${escHtml(name)}</span>
</div>`;

View File

@@ -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',

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v27';
export const APP_VERSION = 'v28';