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