feat: Gruppen im Web-Frontend + Gruppenkalender in /local/calendars
- Sidebar-Sektion "Gruppen": Liste, Erstellen (Name + Mitglieder-Picker),
Verwalten (Mitglieder hinzufuegen/entfernen), Loeschen.
- Gruppenansicht: laedt /api/groups/{id}/combined fuer den sichtbaren
Bereich; Event-Titel werden mit Besitzer-Initialen bzw. Gruppen-Icon
praefixt; Banner mit "Gruppenansicht verlassen".
- Server: GET /api/local/calendars liefert nun auch Gruppenkalender
(group:true, read_write) fuer Mitglieder, damit sie im Editor waehlbar
sind. Test ergaenzt (13 gruen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -105,16 +105,36 @@ def list_calendars(
|
|||||||
.filter(models.CalendarShare.user_id == current_user.id)
|
.filter(models.CalendarShare.user_id == current_user.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
seen_ids = {c.id for c in own}
|
||||||
for share in shares:
|
for share in shares:
|
||||||
cal = share.calendar
|
cal = share.calendar
|
||||||
if cal is None:
|
if cal is None or cal.id in seen_ids:
|
||||||
continue
|
continue
|
||||||
|
seen_ids.add(cal.id)
|
||||||
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||||
result.append(_cal_dict(
|
result.append(_cal_dict(
|
||||||
cal, owned=False,
|
cal, owned=False,
|
||||||
shared_by=owner.username if owner else None,
|
shared_by=owner.username if owner else None,
|
||||||
permission=share.permission,
|
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:
|
||||||
|
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)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ def test_group_members_can_write_group_calendar(client):
|
|||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_calendar_listed_for_member(client):
|
||||||
|
admin = register_admin(client)
|
||||||
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
group = client.post("/api/groups/", headers=auth(admin),
|
||||||
|
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||||
|
gcal = group["group_calendar_id"]
|
||||||
|
# Bob (member, not owner) sees the group calendar in his local list, flagged.
|
||||||
|
cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
|
||||||
|
gc = [c for c in cals if c["id"] == gcal]
|
||||||
|
assert gc and gc[0].get("group") is True
|
||||||
|
assert gc[0]["permission"] == "read_write" and gc[0]["owned"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_combined_view_marks_owner_and_group_event(client):
|
def test_combined_view_marks_owner_and_group_event(client):
|
||||||
admin = register_admin(client)
|
admin = register_admin(client)
|
||||||
b_id, b_tok = create_user(client, admin, "bob")
|
b_id, b_tok = create_user(client, admin, "bob")
|
||||||
|
|||||||
@@ -1801,3 +1801,26 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Groups ─────────────────────────────────────────────────── */
|
||||||
|
.group-view-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(66, 133, 244, 0.12);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.group-item-active {
|
||||||
|
background: rgba(66, 133, 244, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.group-item .cal-item-name { cursor: pointer; flex: 1; }
|
||||||
|
.cal-list-empty {
|
||||||
|
padding: 6px 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -198,6 +198,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="cal-list-items"></div>
|
<div id="cal-list-items"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups -->
|
||||||
|
<div class="cal-list" id="group-list">
|
||||||
|
<div class="cal-list-header">
|
||||||
|
<span data-i18n="groups_title">Gruppen</span>
|
||||||
|
<button class="icon-btn mini-btn" id="btn-add-group" data-i18n-title="group_create" title="Gruppe erstellen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v11h-2v-6H5v-2h6V5h2v11h6v2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="group-list-items"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -205,6 +216,10 @@
|
|||||||
|
|
||||||
<!-- MAIN VIEW -->
|
<!-- MAIN VIEW -->
|
||||||
<main class="main-view" id="main-view">
|
<main class="main-view" id="main-view">
|
||||||
|
<div id="group-view-banner" class="group-view-banner hidden">
|
||||||
|
<span id="group-view-label"></span>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="group-view-exit" data-i18n="group_exit">Gruppenansicht verlassen</button>
|
||||||
|
</div>
|
||||||
<div id="view-container"></div>
|
<div id="view-container"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -349,6 +364,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Group Modal -->
|
||||||
|
<div id="modal-group" class="modal-overlay hidden">
|
||||||
|
<div class="modal-card" style="max-width:480px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="group-modal-title" data-i18n="group_create">Gruppe erstellen</h3>
|
||||||
|
<button class="icon-btn modal-close" data-modal="modal-group">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="group_name">Name</label>
|
||||||
|
<input type="text" id="group-name" data-i18n-placeholder="group_name_ph" placeholder="Gruppenname" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="group_members">Mitglieder</label>
|
||||||
|
<div id="group-member-picker" class="share-user-picker"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-danger hidden" id="group-delete" data-i18n="group_delete">Gruppe löschen</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn btn-ghost" data-modal="modal-group" data-i18n="cancel">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" id="group-save" data-i18n="save">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Share Calendar Modal -->
|
<!-- Share Calendar Modal -->
|
||||||
<div id="modal-share" class="modal-overlay hidden">
|
<div id="modal-share" class="modal-overlay hidden">
|
||||||
<div class="modal-card" style="max-width:480px">
|
<div class="modal-card" style="max-width:480px">
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ let state = {
|
|||||||
dimPast: false,
|
dimPast: false,
|
||||||
editingEvent: null, // null = new event
|
editingEvent: null, // null = new event
|
||||||
selectedEventColor: '', // '' = use calendar color
|
selectedEventColor: '', // '' = use calendar color
|
||||||
|
groups: [],
|
||||||
|
activeGroupId: null, // when set, the calendar shows the combined group view
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── URL state ────────────────────────────────────────────
|
// ── URL state ────────────────────────────────────────────
|
||||||
@@ -123,8 +125,10 @@ export async function initCalendar() {
|
|||||||
bindHAAccountModal();
|
bindHAAccountModal();
|
||||||
bindSettingsModal();
|
bindSettingsModal();
|
||||||
bindProfileModal();
|
bindProfileModal();
|
||||||
|
bindGroupUI();
|
||||||
bindSwipeNavigation();
|
bindSwipeNavigation();
|
||||||
handleHAOAuthReturn();
|
handleHAOAuthReturn();
|
||||||
|
loadGroups();
|
||||||
|
|
||||||
// Browser-Back/Forward: URL-Hash → State synchronisieren
|
// Browser-Back/Forward: URL-Hash → State synchronisieren
|
||||||
window.addEventListener('hashchange', () => {
|
window.addEventListener('hashchange', () => {
|
||||||
@@ -249,9 +253,43 @@ function prefetchIfNeeded(viewStart, viewEnd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Data fetching ─────────────────────────────────────────
|
// ── Data fetching ─────────────────────────────────────────
|
||||||
|
function initials(name) {
|
||||||
|
if (!name) return '?';
|
||||||
|
const parts = String(name).trim().split(/\s+/);
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAndRender(force = false, silent = false) {
|
async function fetchAndRender(force = false, silent = false) {
|
||||||
const { start, end } = getViewRange();
|
const { start, end } = getViewRange();
|
||||||
|
|
||||||
|
// ── Combined group view ────────────────────────────────
|
||||||
|
// No client cache here; reload the combined events for the visible range.
|
||||||
|
if (state.activeGroupId) {
|
||||||
|
const fStart = new Date(start.getTime() - CACHE_BUF);
|
||||||
|
const fEnd = new Date(end.getTime() + CACHE_BUF);
|
||||||
|
if (!silent) showLoading();
|
||||||
|
try {
|
||||||
|
const resp = await api.get(
|
||||||
|
`/groups/${state.activeGroupId}/combined?start=${fStart.toISOString()}&end=${fEnd.toISOString()}`);
|
||||||
|
const evs = (resp.events || []).map(ev => {
|
||||||
|
// Prefix the title so every renderer shows who an event belongs to.
|
||||||
|
const ownerName = ev.owner && ev.owner.display_name;
|
||||||
|
const tag = ev.is_group_event ? '👥' : (ownerName ? `[${initials(ownerName)}]` : '');
|
||||||
|
return { ...ev, title: tag ? `${tag} ${ev.title}` : ev.title };
|
||||||
|
});
|
||||||
|
eventCache.start = null; eventCache.end = null; // invalidate normal cache
|
||||||
|
state.events = evs;
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, true);
|
||||||
|
state.events = [];
|
||||||
|
}
|
||||||
|
renderView();
|
||||||
|
updateTitle();
|
||||||
|
renderMiniCal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache hit: requested range is fully within what we already have
|
// Cache hit: requested range is fully within what we already have
|
||||||
if (!force && eventCache.start && eventCache.end &&
|
if (!force && eventCache.start && eventCache.end &&
|
||||||
start >= eventCache.start && end <= eventCache.end) {
|
start >= eventCache.start && end <= eventCache.end) {
|
||||||
@@ -2158,6 +2196,151 @@ function renderShareUserPicker() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Groups ────────────────────────────────────────────────
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
state.groups = await api.get('/groups/') || [];
|
||||||
|
} catch (e) { state.groups = []; }
|
||||||
|
renderGroupList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupList() {
|
||||||
|
const el = document.getElementById('group-list-items');
|
||||||
|
if (!el) return;
|
||||||
|
if (!state.groups.length) {
|
||||||
|
el.innerHTML = `<div class="cal-list-empty">${t('groups_none')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = state.groups.map(g =>
|
||||||
|
`<div class="cal-item group-item ${g.id === state.activeGroupId ? 'group-item-active' : ''}" data-group-id="${g.id}">
|
||||||
|
<span class="cal-item-name" data-group-open="${g.id}">${escHtml(g.name)}</span>
|
||||||
|
<button class="icon-btn mini-btn" data-group-edit="${g.id}" title="${t('group_manage')}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 8a4 4 0 100 8 4 4 0 000-8zm8.94 4a6.96 6.96 0 00-.14-1.32l2.03-1.58-2-3.46-2.39.96a7 7 0 00-2.28-1.32L15.4 2h-4l-.76 2.96a7 7 0 00-2.28 1.32l-2.39-.96-2 3.46 2.03 1.58A6.96 6.96 0 003.86 12c0 .45.05.89.14 1.32L1.97 14.9l2 3.46 2.39-.96a7 7 0 002.28 1.32L9.4 22h4l.76-2.96a7 7 0 002.28-1.32l2.39.96 2-3.46-2.03-1.58c.09-.43.14-.87.14-1.32z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
el.querySelectorAll('[data-group-open]').forEach(s => {
|
||||||
|
s.addEventListener('click', () => enterGroupView(parseInt(s.dataset.groupOpen)));
|
||||||
|
});
|
||||||
|
el.querySelectorAll('[data-group-edit]').forEach(b => {
|
||||||
|
b.addEventListener('click', e => { e.stopPropagation(); openGroupModal(parseInt(b.dataset.groupEdit)); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterGroupView(groupId) {
|
||||||
|
state.activeGroupId = groupId;
|
||||||
|
const g = state.groups.find(x => x.id === groupId);
|
||||||
|
document.getElementById('group-view-banner').classList.remove('hidden');
|
||||||
|
document.getElementById('group-view-label').textContent =
|
||||||
|
t('group_view_label', { name: g ? g.name : '' });
|
||||||
|
renderGroupList();
|
||||||
|
fetchAndRender(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitGroupView() {
|
||||||
|
state.activeGroupId = null;
|
||||||
|
document.getElementById('group-view-banner').classList.add('hidden');
|
||||||
|
renderGroupList();
|
||||||
|
fetchAndRender(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the group modal in create mode (no id) or manage mode (existing group).
|
||||||
|
async function openGroupModal(groupId) {
|
||||||
|
const modal = document.getElementById('modal-group');
|
||||||
|
modal.dataset.groupId = groupId ? String(groupId) : '';
|
||||||
|
const isEdit = !!groupId;
|
||||||
|
document.getElementById('group-modal-title').textContent =
|
||||||
|
isEdit ? t('group_manage') : t('group_create');
|
||||||
|
document.getElementById('group-delete').classList.toggle('hidden', !isEdit);
|
||||||
|
|
||||||
|
let detail = null, directory = [];
|
||||||
|
try { directory = await api.get('/users/directory') || []; } catch (e) { /* ignore */ }
|
||||||
|
if (isEdit) {
|
||||||
|
try { detail = await api.get(`/groups/${groupId}`); } catch (e) { showToast(e.message, true); }
|
||||||
|
}
|
||||||
|
const me = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
const existingMemberIds = new Set((detail ? detail.members : []).map(m => m.id));
|
||||||
|
|
||||||
|
document.getElementById('group-name').value = detail ? detail.name : '';
|
||||||
|
document.getElementById('group-name').disabled = isEdit; // rename not supported by API
|
||||||
|
|
||||||
|
// Member picker: current members are checked; the owner (me) is excluded.
|
||||||
|
modal.__directory = directory;
|
||||||
|
modal.__memberIds = new Set([...existingMemberIds].filter(id => id !== me.id));
|
||||||
|
renderGroupMemberPicker();
|
||||||
|
|
||||||
|
openModal('modal-group');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupMemberPicker() {
|
||||||
|
const modal = document.getElementById('modal-group');
|
||||||
|
const dir = modal.__directory || [];
|
||||||
|
const picked = modal.__memberIds || new Set();
|
||||||
|
const picker = document.getElementById('group-member-picker');
|
||||||
|
picker.innerHTML = dir.length
|
||||||
|
? dir.map(u =>
|
||||||
|
`<label class="share-user-item" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" data-member-id="${u.id}" ${picked.has(u.id) ? 'checked' : ''} />
|
||||||
|
<span>${escHtml(u.display_name || '')}</span>
|
||||||
|
</label>`
|
||||||
|
).join('')
|
||||||
|
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
|
||||||
|
picker.querySelectorAll('input[data-member-id]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const id = parseInt(cb.dataset.memberId);
|
||||||
|
if (cb.checked) picked.add(id); else picked.delete(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindGroupUI() {
|
||||||
|
const addBtn = document.getElementById('btn-add-group');
|
||||||
|
if (addBtn) addBtn.onclick = () => openGroupModal(null);
|
||||||
|
const exitBtn = document.getElementById('group-view-exit');
|
||||||
|
if (exitBtn) exitBtn.onclick = exitGroupView;
|
||||||
|
|
||||||
|
document.getElementById('group-save').onclick = async () => {
|
||||||
|
const modal = document.getElementById('modal-group');
|
||||||
|
const groupId = modal.dataset.groupId;
|
||||||
|
const memberIds = [...(modal.__memberIds || new Set())];
|
||||||
|
try {
|
||||||
|
if (groupId) {
|
||||||
|
// Manage mode: sync member additions/removals.
|
||||||
|
const detail = await api.get(`/groups/${groupId}`);
|
||||||
|
const me = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
const current = new Set(detail.members.map(m => m.id).filter(id => id !== me.id));
|
||||||
|
for (const id of memberIds) if (!current.has(id)) await api.post(`/groups/${groupId}/members`, { user_id: id });
|
||||||
|
for (const id of current) if (!memberIds.includes(id)) await api.delete(`/groups/${groupId}/members/${id}`);
|
||||||
|
showToast(t('group_saved'));
|
||||||
|
} else {
|
||||||
|
const name = document.getElementById('group-name').value.trim();
|
||||||
|
if (!name) { showToast(t('error_enter_title'), true); return; }
|
||||||
|
await api.post('/groups/', { name, member_ids: memberIds });
|
||||||
|
showToast(t('group_created'));
|
||||||
|
}
|
||||||
|
closeModal('modal-group');
|
||||||
|
await loadGroups();
|
||||||
|
// Refresh local calendars too (a new group creates a group calendar).
|
||||||
|
try { state.localCalendars = await api.get('/local/calendars') || state.localCalendars; } catch (e) {}
|
||||||
|
renderCalendarList();
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('group-delete').onclick = async () => {
|
||||||
|
const modal = document.getElementById('modal-group');
|
||||||
|
const groupId = modal.dataset.groupId;
|
||||||
|
if (!groupId) return;
|
||||||
|
if (!confirm(t('group_delete_confirm'))) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/groups/${groupId}`);
|
||||||
|
if (state.activeGroupId === parseInt(groupId)) exitGroupView();
|
||||||
|
closeModal('modal-group');
|
||||||
|
await loadGroups();
|
||||||
|
showToast(t('group_deleted'));
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings Modal ────────────────────────────────────────
|
// ── Settings Modal ────────────────────────────────────────
|
||||||
function openSettingsModal() {
|
function openSettingsModal() {
|
||||||
const s = state.settings;
|
const s = state.settings;
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ const translations = {
|
|||||||
perm_read_write: 'Lesen & schreiben',
|
perm_read_write: 'Lesen & schreiben',
|
||||||
remove: 'Entfernen',
|
remove: 'Entfernen',
|
||||||
done: 'Fertig',
|
done: 'Fertig',
|
||||||
|
groups_title: 'Gruppen',
|
||||||
|
groups_none: 'Noch keine Gruppen',
|
||||||
|
group_create: 'Gruppe erstellen',
|
||||||
|
group_manage: 'Gruppe verwalten',
|
||||||
|
group_name: 'Name',
|
||||||
|
group_name_ph: 'Gruppenname',
|
||||||
|
group_members: 'Mitglieder',
|
||||||
|
group_delete: 'Gruppe löschen',
|
||||||
|
group_delete_confirm: 'Diese Gruppe und ihren Gruppenkalender wirklich löschen?',
|
||||||
|
group_deleted: 'Gruppe gelöscht',
|
||||||
|
group_saved: 'Gruppe gespeichert',
|
||||||
|
group_created: 'Gruppe erstellt',
|
||||||
|
group_view_label: 'Gruppenansicht: {name}',
|
||||||
|
group_exit: 'Gruppenansicht verlassen',
|
||||||
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
|
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
|
||||||
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
|
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
|
||||||
hour_compact: 'Kompakt', hour_normal: 'Normal',
|
hour_compact: 'Kompakt', hour_normal: 'Normal',
|
||||||
@@ -345,6 +359,20 @@ const translations = {
|
|||||||
perm_read_write: 'Read & write',
|
perm_read_write: 'Read & write',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
done: 'Done',
|
done: 'Done',
|
||||||
|
groups_title: 'Groups',
|
||||||
|
groups_none: 'No groups yet',
|
||||||
|
group_create: 'Create group',
|
||||||
|
group_manage: 'Manage group',
|
||||||
|
group_name: 'Name',
|
||||||
|
group_name_ph: 'Group name',
|
||||||
|
group_members: 'Members',
|
||||||
|
group_delete: 'Delete group',
|
||||||
|
group_delete_confirm: 'Really delete this group and its group calendar?',
|
||||||
|
group_deleted: 'Group deleted',
|
||||||
|
group_saved: 'Group saved',
|
||||||
|
group_created: 'Group created',
|
||||||
|
group_view_label: 'Group view: {name}',
|
||||||
|
group_exit: 'Exit group view',
|
||||||
settings_hour_height: 'Hour height (week & day view)',
|
settings_hour_height: 'Hour height (week & day view)',
|
||||||
settings_hour_height_desc: 'How much space one hour takes in the time grid',
|
settings_hour_height_desc: 'How much space one hour takes in the time grid',
|
||||||
hour_compact: 'Compact', hour_normal: 'Normal',
|
hour_compact: 'Compact', hour_normal: 'Normal',
|
||||||
|
|||||||
Reference in New Issue
Block a user