diff --git a/frontend/css/app.css b/frontend/css/app.css index d3a38f0..e519df7 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -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; +} diff --git a/frontend/index.html b/frontend/index.html index 5b030f8..df32297 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -319,6 +319,11 @@ +
@@ -344,6 +349,34 @@
+ + + @@ -672,6 +706,16 @@ +

Privatsphäre

+

Wie private Termine für andere Gruppenmitglieder erscheinen

+
+ + +
+

Stundenhöhe (Wochen- & Tagesansicht)

Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt

diff --git a/frontend/js/api.js b/frontend/js/api.js index b715277..cc12b4a 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -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 }), diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 8c4449e..2d2f748 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -538,19 +538,28 @@ function renderCalendarList() { }).join(''); } - // ── Local calendars ──────────────────────────────────── + // ── Local calendars (own + shared with me) ───────────── if (state.localCalendars.length) { html += `
${t('cal_local')}
`; - html += state.localCalendars.map(cal => - `
+ 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 + ? `${escHtml(cal.shared_by || '')}` + : ''; + const removeBtn = owned + ? `` + : ''; + return `
${escHtml(cal.name)} - -
` - ).join(''); + ${sharedBadge} + ${removeBtn} +
`; + }).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 => + `
+ ${escHtml(s.display_name || '')} +
+ ${s.permission === 'read_write' ? t('perm_read_write') : t('perm_read')} + +
+
` + ).join('') + : `${t('share_none')}`; + + 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 => + `
${escHtml(u.display_name || '')}
` + ).join('') + : `${t('share_no_users')}`; + 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 = `${t('settings_no_local_cals')}`; } else { - localList.innerHTML = state.localCalendars.map(cal => - `
+ localList.innerHTML = state.localCalendars.map(cal => { + const owned = cal.owned !== false; + const sharedBadge = !owned + ? `${t('shared_by', { name: cal.shared_by || '' })}` + : ''; + const canWrite = owned || cal.permission === 'read_write'; + const actions = []; + if (owned) actions.push(``); + if (canWrite) actions.push(``); + actions.push(``); + return `
${escHtml(cal.name)} + ${sharedBadge}
-
` - ).join(''); +
${actions.join('')}
+
`; + }).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); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index c443647..55e9071 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -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',