feat: Web-Frontend – Sharing, iCal Import/Export, Ersteller & Privat-Flag

- Ersteller-Zeile im Event-Popup (nur wenn Ersteller != aktueller User).
- Privat-Toggle im Event-Editor (nur lokale Kalender) + Sichtbarkeits-
  Auswahl (hidden|busy) in den Einstellungen.
- Lokale Kalender in Settings & Sidebar: Teilen/Importieren/Exportieren-
  Aktionen (nur eigene; geteilte mit "geteilt von"-Badge, kein Loeschen).
- Share-Modal: Benutzerverzeichnis mit Suche, read/read_write, Freigaben
  entfernen.
- api.js: download()-Helper fuer iCal-Export (Blob).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-31 16:30:47 +02:00
parent 32268a18b2
commit 8d2a697f8b
5 changed files with 336 additions and 14 deletions

View File

@@ -1764,3 +1764,40 @@ a { color: var(--primary); text-decoration: none; }
}
}
/* ── Collaboration: sharing badges & user picker ───────────── */
.cal-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg-surface);
color: var(--text-2);
border: 1px solid var(--border);
white-space: nowrap;
}
.cal-badge-shared {
background: rgba(66, 133, 244, 0.15);
color: var(--primary);
border-color: transparent;
}
.share-user-picker {
margin-top: 8px;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 10px;
}
.share-user-item {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.share-user-item:last-child { border-bottom: none; }
.share-user-item:hover { background: var(--bg-surface); }
.popup-creator {
margin-top: 6px;
font-size: 12px;
color: var(--text-2);
font-style: italic;
}

View File

@@ -319,6 +319,11 @@
<label>Kalender</label>
<select id="ev-calendar"></select>
</div>
<div class="form-row" id="ev-private-row" style="display:none">
<label class="toggle-label">
<input type="checkbox" id="ev-private" /> <span data-i18n="event_private">Privat</span>
</label>
</div>
<div class="form-group">
<label>Ort</label>
<input type="text" id="ev-location" placeholder="Ort hinzufügen" />
@@ -344,6 +349,34 @@
</div>
</div>
<!-- Share Calendar Modal -->
<div id="modal-share" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px">
<div class="modal-header">
<h3 data-i18n="share_title">Kalender teilen</h3>
<button class="icon-btn modal-close" data-modal="modal-share">&times;</button>
</div>
<div class="modal-body">
<h4 class="panel-title" data-i18n="share_current">Aktuelle Freigaben</h4>
<div id="share-current-list" class="accounts-list"></div>
<h4 class="panel-title" style="margin-top:20px" data-i18n="share_add">Benutzer hinzufügen</h4>
<div class="form-row" style="gap:8px;align-items:center">
<input type="text" id="share-user-search" data-i18n-placeholder="share_search" placeholder="Benutzer suchen…" style="flex:1" />
<select id="share-permission">
<option value="read" data-i18n="perm_read">Nur lesen</option>
<option value="read_write" data-i18n="perm_read_write">Lesen &amp; schreiben</option>
</select>
</div>
<div id="share-user-picker" class="share-user-picker"></div>
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button class="btn btn-primary" data-modal="modal-share" data-i18n="done">Fertig</button>
</div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div id="modal-delete-confirm" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
@@ -395,6 +428,7 @@
<div class="popup-location" id="popup-location"></div>
<div class="popup-description" id="popup-description"></div>
<div class="popup-calendar" id="popup-calendar"></div>
<div class="popup-creator" id="popup-creator" style="display:none"></div>
</div>
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
</div>
@@ -672,6 +706,16 @@
</label>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4>
<p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p>
<div class="form-group">
<label data-i18n="settings_private_visibility">Private Termine für Gruppenmitglieder</label>
<select id="cfg-private-visibility">
<option value="busy" data-i18n="private_visibility_busy">Als „Beschäftigt“ anzeigen</option>
<option value="hidden" data-i18n="private_visibility_hidden">Ausblenden</option>
</select>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4>
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">

View File

@@ -57,12 +57,46 @@ async function uploadRequest(path, formData) {
return res.json();
}
async function downloadRequest(path, fallbackName) {
const token = localStorage.getItem('token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method: 'GET', headers });
if (res.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return null;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
// Derive filename from Content-Disposition if present.
let filename = fallbackName || 'calendar.ics';
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="?([^"]+)"?/);
if (m) filename = m[1];
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path),
upload: (path, form) => uploadRequest(path, form),
download: (path, name) => downloadRequest(path, name),
login: (username, password, totp_code = null, remember_me = false) =>
request('POST', '/auth/login', { username, password, totp_code, remember_me }),

View File

@@ -538,19 +538,28 @@ function renderCalendarList() {
}).join('');
}
// ── Local calendars ────────────────────────────────────
// ── Local calendars (own + shared with me) ─────────────
if (state.localCalendars.length) {
html += `<div class="cal-account-name">${t('cal_local')}</div>`;
html += state.localCalendars.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}" data-source="local">
html += state.localCalendars.map(cal => {
const owned = cal.owned !== false;
// Shared calendars get an owner badge and no delete button (owner-only).
const sharedBadge = !owned
? `<span class="cal-badge cal-badge-shared" title="${t('shared_by', { name: cal.shared_by || '' })}">${escHtml(cal.shared_by || '')}</span>`
: '';
const removeBtn = owned
? `<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>
<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>
</div>`
).join('');
${sharedBadge}
${removeBtn}
</div>`;
}).join('');
}
// ── iCal subscriptions ─────────────────────────────────
@@ -1203,6 +1212,16 @@ function showEventPopup(ev, anchor) {
document.getElementById('popup-description').style.display = ev.description ? '' : 'none';
document.getElementById('popup-calendar').textContent = ev.calendar_name || '';
// Creator — only shown when it isn't the current user.
const creatorEl = document.getElementById('popup-creator');
const me = JSON.parse(localStorage.getItem('user') || '{}');
if (ev.creator && ev.creator.display_name && ev.creator.id !== me.id) {
creatorEl.textContent = t('created_by', { name: ev.creator.display_name });
creatorEl.style.display = '';
} else {
creatorEl.style.display = 'none';
}
// Position near anchor
const rect = anchor.getBoundingClientRect();
const pw = 300, ph = 200;
@@ -1375,6 +1394,7 @@ function openNewEventModal(date) {
toggleAlldayFields(false);
populateCalendarSelect(null);
updatePrivateRow(false);
resetColorPicker('');
resetRecurrenceUI();
document.getElementById('ev-delete').classList.add('hidden');
@@ -1410,6 +1430,7 @@ function openCopyEditModal(ev, targetCal) {
if (targetCal.type === 'caldav') selectedId = targetCal.id;
else selectedId = `${targetCal.type}-${targetCal.id}`;
populateCalendarSelect(selectedId);
updatePrivateRow(ev.private);
resetColorPicker(ev.color || '');
resetRecurrenceUI();
@@ -1442,6 +1463,7 @@ function openEditEventModal(ev) {
}
populateCalendarSelect(ev.calendar_id);
updatePrivateRow(ev.private);
resetColorPicker(ev.color || '');
// Recurrence
@@ -1469,6 +1491,18 @@ function toggleAlldayFields(allDay) {
document.getElementById('ev-date-row').style.display = allDay ? '' : 'none';
}
// The "Privat" toggle only applies to local calendars; hide it otherwise.
function updatePrivateRow(isPrivate) {
const calVal = document.getElementById('ev-calendar').value || '';
const isLocal = calVal.startsWith('local-');
const row = document.getElementById('ev-private-row');
row.style.display = isLocal ? '' : 'none';
if (isPrivate !== undefined) {
document.getElementById('ev-private').checked = !!isPrivate;
}
if (!isLocal) document.getElementById('ev-private').checked = false;
}
function resetColorPicker(color) {
state.selectedEventColor = color;
const hex = document.getElementById('ev-color-hex');
@@ -1559,6 +1593,9 @@ function bindEventModal() {
toggleAlldayFields(e.target.checked);
});
// The "Privat" toggle is only relevant for local calendars.
document.getElementById('ev-calendar').addEventListener('change', () => updatePrivateRow());
// Date/time pickers with auto-adjustment logic
[
{ displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' },
@@ -1684,6 +1721,7 @@ function bindEventModal() {
const desc = document.getElementById('ev-description').value.trim();
const color = state.selectedEventColor;
const rrule = buildRruleFromUI();
const isPrivate = isLocal && document.getElementById('ev-private').checked;
let start, end;
if (allDay) {
@@ -1711,7 +1749,7 @@ function bindEventModal() {
);
} else if (ev.source === 'local') {
await api.put(`/local/events/${encodeURIComponent(ev.id)}`,
{ title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' }
{ title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '', private: isPrivate }
);
} else if (ev.source === 'ical') {
showToast(t('event_readonly'), true);
@@ -1733,6 +1771,7 @@ function bindEventModal() {
location: loc, description: desc,
color: color || null,
rrule: rrule || null,
private: ev.source === 'local' ? isPrivate : ev.private,
});
showToast(t('event_updated'));
} else if (isGoogle) {
@@ -1747,7 +1786,7 @@ function bindEventModal() {
await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
rrule: rrule || null,
rrule: rrule || null, private: isPrivate,
});
showToast(t('event_created'));
} else if (isHA) {
@@ -2025,6 +2064,100 @@ function bindICalSubModal() {
};
}
// ── iCal Import ───────────────────────────────────────────
// Open a file picker and import the chosen .ics into the given local calendar.
function triggerIcsImport(calendarId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.ics,text/calendar';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', async () => {
const file = input.files && input.files[0];
input.remove();
if (!file) return;
const form = new FormData();
form.append('file', file);
try {
showToast(t('importing'));
const res = await api.upload(`/local/calendars/${calendarId}/import`, form);
showToast(t('import_result', { imported: res.imported, skipped: res.skipped }));
fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
});
input.click();
}
// ── Sharing ───────────────────────────────────────────────
async function openShareModal(calendarId) {
const modal = document.getElementById('modal-share');
modal.dataset.calId = String(calendarId);
const search = document.getElementById('share-user-search');
search.value = '';
search.oninput = renderShareUserPicker;
document.getElementById('share-permission').value = 'read';
openModal('modal-share');
await refreshShareModal(calendarId);
}
async function refreshShareModal(calendarId) {
// Load current shares + the user directory (for the picker).
let shares = [], users = [];
try { shares = await api.get(`/local/calendars/${calendarId}/shares`); } catch (e) { showToast(e.message, true); }
try { users = await api.get('/users/directory'); } catch (e) { /* ignore */ }
const sharedIds = new Set(shares.map(s => s.user_id));
const listEl = document.getElementById('share-current-list');
listEl.innerHTML = shares.length
? shares.map(s =>
`<div class="accounts-row">
<span class="accounts-row-name">${escHtml(s.display_name || '')}</span>
<div class="accounts-row-actions">
<span class="cal-badge">${s.permission === 'read_write' ? t('perm_read_write') : t('perm_read')}</span>
<button class="btn btn-ghost btn-sm" data-share-remove="${s.user_id}">${t('remove')}</button>
</div>
</div>`
).join('')
: `<span class="accounts-section-empty">${t('share_none')}</span>`;
listEl.querySelectorAll('[data-share-remove]').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await api.delete(`/local/calendars/${calendarId}/shares/${btn.dataset.shareRemove}`);
await refreshShareModal(calendarId);
} catch (e) { showToast(e.message, true); }
});
});
// Store the directory (minus already-shared users) for the picker.
document.getElementById('modal-share').__users = users.filter(u => !sharedIds.has(u.id));
renderShareUserPicker();
}
function renderShareUserPicker() {
const modal = document.getElementById('modal-share');
const users = modal.__users || [];
const q = (document.getElementById('share-user-search').value || '').toLowerCase();
const filtered = users.filter(u => (u.display_name || '').toLowerCase().includes(q));
const picker = document.getElementById('share-user-picker');
picker.innerHTML = filtered.length
? filtered.map(u =>
`<div class="share-user-item" data-user-id="${u.id}">${escHtml(u.display_name || '')}</div>`
).join('')
: `<span class="accounts-section-empty">${t('share_no_users')}</span>`;
picker.querySelectorAll('.share-user-item').forEach(el => {
el.addEventListener('click', async () => {
const calId = parseInt(modal.dataset.calId);
const permission = document.getElementById('share-permission').value;
try {
await api.post(`/local/calendars/${calId}/shares`,
{ user_id: parseInt(el.dataset.userId), permission });
await refreshShareModal(calId);
} catch (e) { showToast(e.message, true); }
});
});
}
// ── Settings Modal ────────────────────────────────────────
function openSettingsModal() {
const s = state.settings;
@@ -2057,6 +2190,7 @@ function openSettingsModal() {
});
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-language').value = getLang();
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
// Set active contrast/hour-height buttons
[
@@ -2189,14 +2323,40 @@ function renderAllAccounts() {
if (!state.localCalendars.length) {
localList.innerHTML = `<span class="accounts-section-empty">${t('settings_no_local_cals')}</span>`;
} else {
localList.innerHTML = state.localCalendars.map(cal =>
`<div class="accounts-row">
localList.innerHTML = state.localCalendars.map(cal => {
const owned = cal.owned !== false;
const sharedBadge = !owned
? `<span class="cal-badge cal-badge-shared">${t('shared_by', { name: cal.shared_by || '' })}</span>`
: '';
const canWrite = owned || cal.permission === 'read_write';
const actions = [];
if (owned) actions.push(`<button class="btn btn-ghost btn-sm" data-local-share="${cal.id}">${t('share')}</button>`);
if (canWrite) actions.push(`<button class="btn btn-ghost btn-sm" data-local-import="${cal.id}">${t('import')}</button>`);
actions.push(`<button class="btn btn-ghost btn-sm" data-local-export="${cal.id}" data-local-name="${escHtml(cal.name)}">${t('export')}</button>`);
return `<div class="accounts-row">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span class="accounts-local-dot" style="background:${cal.color || '#34a853'}"></span>
<span class="accounts-row-name">${escHtml(cal.name)}</span>
${sharedBadge}
</div>
</div>`
).join('');
<div class="accounts-row-actions">${actions.join('')}</div>
</div>`;
}).join('');
localList.querySelectorAll('[data-local-export]').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await api.download(`/local/calendars/${btn.dataset.localExport}/export`,
`${btn.dataset.localName || 'calendar'}.ics`);
} catch (e) { showToast(e.message, true); }
});
});
localList.querySelectorAll('[data-local-import]').forEach(btn => {
btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.localImport)));
});
localList.querySelectorAll('[data-local-share]').forEach(btn => {
btn.addEventListener('click', () => openShareModal(parseInt(btn.dataset.localShare)));
});
}
}
@@ -2509,6 +2669,7 @@ function bindSettingsModal() {
dim_past_events: document.getElementById('cfg-dim-past').checked,
hour_height: getActive('cfg-hour-height') || 44,
language: document.getElementById('cfg-language').value,
private_event_visibility: document.getElementById('cfg-private-visibility').value,
};
try {
await api.put('/settings/', settings);

View File

@@ -84,6 +84,29 @@ const translations = {
settings_week_start: 'Erster Wochentag',
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
settings_dim_past: 'Vergangene Termine ausgrauen',
settings_privacy: 'Privatsphäre',
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
private_visibility_busy: 'Als „Beschäftigt“ anzeigen',
private_visibility_hidden: 'Ausblenden',
created_by: 'Erstellt von: {name}',
event_private: 'Privat',
share: 'Teilen',
import: 'Importieren',
export: 'Exportieren',
importing: 'Importiere…',
import_result: '{imported} importiert, {skipped} übersprungen',
shared_by: 'geteilt von {name}',
share_title: 'Kalender teilen',
share_current: 'Aktuelle Freigaben',
share_add: 'Benutzer hinzufügen',
share_search: 'Benutzer suchen…',
share_none: 'Noch nicht geteilt',
share_no_users: 'Keine Benutzer gefunden',
perm_read: 'Nur lesen',
perm_read_write: 'Lesen & schreiben',
remove: 'Entfernen',
done: 'Fertig',
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',
@@ -299,6 +322,29 @@ const translations = {
settings_week_start: 'First day of week',
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
settings_dim_past: 'Dim past events',
settings_privacy: 'Privacy',
settings_private_visibility: 'Private events for group members',
settings_private_visibility_desc: 'How your private events appear to other group members',
private_visibility_busy: 'Show as "Busy"',
private_visibility_hidden: 'Hide completely',
created_by: 'Created by: {name}',
event_private: 'Private',
share: 'Share',
import: 'Import',
export: 'Export',
importing: 'Importing…',
import_result: '{imported} imported, {skipped} skipped',
shared_by: 'shared by {name}',
share_title: 'Share calendar',
share_current: 'Current shares',
share_add: 'Add user',
share_search: 'Search users…',
share_none: 'Not shared yet',
share_no_users: 'No users found',
perm_read: 'Read only',
perm_read_write: 'Read & write',
remove: 'Remove',
done: 'Done',
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',