above stays the value holder; this
// renders a styled list from the same state so group icons can show.
function groupIconForLocalCal(calId) {
const g = (state.groups || []).find(x => x.group_calendar_id === calId);
return (g && g.icon) ? g.icon : 'people';
}
function calSelectData() {
const sections = [];
const byValue = {};
const push = (sec, value, name, color, isGroup, iconKey) => {
sec.items.push({ value, name, color, isGroup, iconKey });
byValue[value] = { name, color, isGroup, iconKey };
};
state.accounts.forEach(acc => {
const cals = (acc.calendars || []).filter(c => !c.sidebar_hidden);
if (!cals.length) return;
const sec = { label: acc.name, items: [] };
cals.forEach(cal => push(sec, String(cal.id), cal.name, cal.color || acc.color || '#4285f4', false, null));
sections.push(sec);
});
const locals = state.localCalendars.filter(c => !c.sidebar_hidden && !c.group);
if (locals.length) {
const sec = { label: t('cal_local'), items: [] };
locals.forEach(cal => push(sec, `local-${cal.id}`, cal.name, cal.color, false, null));
sections.push(sec);
}
const groups = state.localCalendars.filter(c => !c.sidebar_hidden && c.group);
if (groups.length) {
const sec = { label: t('groups_title'), items: [] };
groups.forEach(cal => push(sec, `local-${cal.id}`, cal.name, cal.color, true, groupIconForLocalCal(cal.id)));
sections.push(sec);
}
state.googleAccounts.forEach(acc => {
const cals = (acc.calendars || []).filter(c => !c.sidebar_hidden);
if (!cals.length) return;
const sec = { label: acc.email, items: [] };
cals.forEach(cal => push(sec, `google-${cal.id}`, cal.name, cal.color || '#4285f4', false, null));
sections.push(sec);
});
state.haAccounts.forEach(acc => {
const cals = (acc.calendars || []).filter(c => !c.sidebar_hidden);
if (!cals.length) return;
const sec = { label: acc.name, items: [] };
cals.forEach(cal => push(sec, `homeassistant-${cal.id}`, cal.name, cal.color || '#03a9f4', false, null));
sections.push(sec);
});
return { sections, byValue };
}
function calOptionInner(info) {
const dot = ` `;
const icon = info.isGroup ? `${groupIconSvg(info.iconKey || 'people', 16)} ` : '';
return `${dot}${icon}${escHtml(info.name)} `;
}
function renderCalSelectTrigger() {
const cur = document.getElementById('ev-cal-current');
if (!cur) return;
const val = document.getElementById('ev-calendar').value;
const { byValue } = calSelectData();
const info = byValue[val];
cur.innerHTML = info ? calOptionInner(info) : `— `;
}
function renderCalSelectMenu() {
const menu = document.getElementById('ev-cal-menu');
const val = document.getElementById('ev-calendar').value;
const { sections } = calSelectData();
menu.innerHTML = sections.map(sec =>
`${escHtml(sec.label)}
` +
sec.items.map(it =>
`${calOptionInner(it)}
`
).join('')
).join('');
menu.querySelectorAll('.cal-select-option').forEach(el => {
el.addEventListener('click', () => {
const sel = document.getElementById('ev-calendar');
sel.value = el.dataset.value;
sel.dispatchEvent(new Event('change'));
renderCalSelectTrigger();
closeCalSelectMenu();
});
});
}
function openCalSelectMenu() {
renderCalSelectMenu();
document.getElementById('ev-cal-menu').classList.remove('hidden');
document.getElementById('ev-cal-select').dataset.open = '1';
document.getElementById('ev-cal-trigger').setAttribute('aria-expanded', 'true');
setTimeout(() => document.addEventListener('click', calSelectOutside), 0);
}
function closeCalSelectMenu() {
const menu = document.getElementById('ev-cal-menu');
if (menu) menu.classList.add('hidden');
const wrap = document.getElementById('ev-cal-select');
if (wrap) delete wrap.dataset.open;
const trig = document.getElementById('ev-cal-trigger');
if (trig) trig.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', calSelectOutside);
}
function calSelectOutside(e) {
if (!e.target.closest('#ev-cal-select')) closeCalSelectMenu();
}
// ── Date field helpers ────────────────────────────────────
// All-day events use exclusive end-dates (iCal RFC 5545 convention):
// DTEND points to the day AFTER the last visible day. The user picker
// uses inclusive end-dates ("ends on 18.08" means 18.08 is the last
// day). These helpers convert between the two.
function shiftDate(isoDate, deltaDays) {
if (!isoDate) return isoDate;
const [y, m, d] = isoDate.slice(0, 10).split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + deltaDays);
return dt.toISOString().slice(0, 10);
}
const allDayEndToInclusive = iso => shiftDate(iso, -1); // storage → picker
const allDayEndToExclusive = iso => shiftDate(iso, +1); // picker → storage
function setDtValue(id, isoStr, mode) {
const input = document.getElementById(id);
if (input) input.value = isoStr || '';
const display = document.getElementById(id + '-display');
if (display) {
display.querySelector('.dt-display-text').textContent =
formatDtDisplay(isoStr, mode);
}
}
function openNewEventModal(date) {
state.editingEvent = null;
state.selectedEventColor = '';
document.getElementById('modal-event-title-label').textContent = 'Termin erstellen';
document.getElementById('ev-title').value = '';
document.getElementById('ev-location').value = '';
document.getElementById('ev-description').value = '';
document.getElementById('ev-allday').checked = false;
const start = new Date(date);
const durMin = (state.settings && state.settings.default_event_duration_minutes) || 60;
const end = new Date(start.getTime() + durMin * 60000);
setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
setDtValue('ev-start-date', toDateInput(start), 'date');
setDtValue('ev-end-date', toDateInput(start), 'date');
toggleAlldayFields(false);
populateCalendarSelect(null);
updatePrivateRow(false);
// New local event: prefill with the global default reminder if one is set.
const defMin = state.settings && state.settings.default_reminder_minutes;
setEventReminders(defMin != null ? [defMin] : []);
updateRemindersRow();
resetAttachmentState();
updateAttachmentsRow();
resetColorPicker('');
resetRecurrenceUI();
document.getElementById('ev-delete').classList.add('hidden');
openModal('modal-event');
}
// Open the create-event modal pre-filled with an existing event's data, so
// the user can edit before copying it into the target calendar.
function openCopyEditModal(ev, targetCal) {
state.editingEvent = null;
state.selectedEventColor = ev.color || '';
document.getElementById('modal-event-title-label').textContent = 'Termin erstellen';
document.getElementById('ev-title').value = ev.title || '';
document.getElementById('ev-location').value = ev.location || '';
document.getElementById('ev-description').value = ev.description || '';
document.getElementById('ev-allday').checked = !!ev.allDay;
if (ev.allDay) {
setDtValue('ev-start-date', (ev.start || '').slice(0, 10), 'date');
setDtValue('ev-end-date', allDayEndToInclusive((ev.end || '').slice(0, 10)), 'date');
} else {
const s = new Date(ev.start);
const e = new Date(ev.end);
setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime');
setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime');
}
toggleAlldayFields(!!ev.allDay);
// Map target calendar to the dropdown's option value
let selectedId;
if (targetCal.type === 'caldav') selectedId = targetCal.id;
else selectedId = `${targetCal.type}-${targetCal.id}`;
populateCalendarSelect(selectedId);
updatePrivateRow(ev.private);
setEventReminders(ev.reminders || []);
updateRemindersRow();
resetAttachmentState();
updateAttachmentsRow();
resetColorPicker(ev.color || '');
resetRecurrenceUI();
if (ev.rrule) parseRruleIntoUI(ev.rrule);
document.getElementById('ev-delete').classList.add('hidden');
openModal('modal-event');
}
function openEditEventModal(ev) {
if (ev.source === 'ical' || ev.read_only === true) { showToast(t('event_readonly'), true); return; }
state.editingEvent = ev;
state.selectedEventColor = ev.color || '';
document.getElementById('modal-event-title-label').textContent = 'Termin bearbeiten';
document.getElementById('ev-title').value = ev.title;
document.getElementById('ev-location').value = ev.location || '';
document.getElementById('ev-description').value = ev.description || '';
document.getElementById('ev-allday').checked = ev.allDay;
if (ev.allDay) {
setDtValue('ev-start-date', ev.start.slice(0, 10), 'date');
setDtValue('ev-end-date', allDayEndToInclusive(ev.end.slice(0, 10)), 'date');
toggleAlldayFields(true);
} else {
const s = new Date(ev.start);
const e = new Date(ev.end);
setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime');
setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime');
toggleAlldayFields(false);
}
populateCalendarSelect(ev.calendar_id);
updatePrivateRow(ev.private);
setEventReminders(ev.reminders || []);
updateRemindersRow();
loadEventAttachments(ev);
updateAttachmentsRow();
resetColorPicker(ev.color || '');
// Recurrence
const rrule = ev.rrule || '';
const recSel = document.getElementById('ev-recurrence');
const customPanel = document.getElementById('ev-recurrence-custom');
if (!rrule) {
recSel.value = '';
customPanel.classList.add('hidden');
} else if (['FREQ=DAILY', 'FREQ=WEEKLY', 'FREQ=MONTHLY', 'FREQ=YEARLY'].includes(rrule)) {
recSel.value = rrule;
customPanel.classList.add('hidden');
} else {
recSel.value = 'custom';
customPanel.classList.remove('hidden');
parseRruleIntoUI(rrule);
}
document.getElementById('ev-delete').classList.remove('hidden');
openModal('modal-event');
}
function toggleAlldayFields(allDay) {
document.getElementById('ev-time-row').style.display = allDay ? 'none' : '';
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');
const preview = document.getElementById('ev-color-preview');
hex.value = color ? color.toUpperCase() : '';
preview.style.background = color || 'var(--primary)';
}
// ── Reminders (local events only) ─────────────────────────
// A few quick presets; anything else is entered via the "custom" mode
// (number + unit). Reminders are stored as minutes-before-start integers.
const REMINDER_PRESETS = [0, 30, 1440]; // at start, 30 min, 1 day
const REMINDER_UNITS = [
{ unit: 'minutes', mult: 1 },
{ unit: 'hours', mult: 60 },
{ unit: 'days', mult: 1440 },
{ unit: 'weeks', mult: 10080 },
];
function reminderLabel(min) {
if (min <= 0) return t('reminder_at_start');
if (min < 60) return t('reminder_minutes', { n: min });
if (min < 1440) { const h = min / 60; return h === 1 ? t('reminder_hour_one') : t('reminder_hours', { n: h }); }
if (min < 10080) { const d = min / 1440; return d === 1 ? t('reminder_day_one') : t('reminder_days', { n: d }); }
const w = min / 10080; return w === 1 ? t('reminder_week_one') : t('reminder_weeks', { n: w });
}
// Split a minutes value into the largest exact {value, unit} for the custom picker.
function splitReminder(min) {
for (let i = REMINDER_UNITS.length - 1; i >= 0; i--) {
const u = REMINDER_UNITS[i];
if (min > 0 && min % u.mult === 0) return { value: min / u.mult, unit: u.unit };
}
return { value: Math.max(1, min), unit: 'minutes' };
}
function setEventReminders(arr) {
state.eventReminders = Array.isArray(arr)
? arr.map(Number).filter(n => !isNaN(n)) : [];
renderReminderRows();
}
function renderReminderRows() {
const list = document.getElementById('ev-reminders-list');
if (!list) return;
list.innerHTML = '';
state.eventReminders.forEach((min, idx) => {
const row = document.createElement('div');
row.className = 'ev-reminder-row';
const isPreset = REMINDER_PRESETS.includes(min);
const sel = document.createElement('select');
REMINDER_PRESETS.forEach(v => {
const o = document.createElement('option');
o.value = String(v);
o.textContent = reminderLabel(v);
if (isPreset && v === min) o.selected = true;
sel.appendChild(o);
});
const customOpt = document.createElement('option');
customOpt.value = 'custom';
customOpt.textContent = t('reminder_custom');
if (!isPreset) customOpt.selected = true;
sel.appendChild(customOpt);
// Custom (number + unit) inputs, shown only in custom mode.
const customWrap = document.createElement('span');
customWrap.className = 'ev-reminder-custom';
customWrap.style.display = isPreset ? 'none' : '';
const numInput = document.createElement('input');
numInput.type = 'number';
numInput.min = '1';
const unitSel = document.createElement('select');
REMINDER_UNITS.forEach(u => {
const o = document.createElement('option');
o.value = u.unit;
o.textContent = t('reminder_unit_' + u.unit);
unitSel.appendChild(o);
});
const beforeLbl = document.createElement('span');
beforeLbl.className = 'ev-reminder-before';
beforeLbl.textContent = t('reminder_before');
const sp = splitReminder(isPreset ? 30 : min);
numInput.value = String(sp.value);
unitSel.value = sp.unit;
customWrap.appendChild(numInput);
customWrap.appendChild(unitSel);
customWrap.appendChild(beforeLbl);
function commitCustom() {
const n = Math.max(1, parseInt(numInput.value, 10) || 1);
const mult = REMINDER_UNITS.find(u => u.unit === unitSel.value).mult;
state.eventReminders[idx] = n * mult;
}
sel.addEventListener('change', () => {
if (sel.value === 'custom') {
customWrap.style.display = '';
commitCustom();
} else {
customWrap.style.display = 'none';
state.eventReminders[idx] = parseInt(sel.value, 10);
}
});
numInput.addEventListener('input', commitCustom);
unitSel.addEventListener('change', commitCustom);
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'icon-btn ev-reminder-remove';
rm.innerHTML = '×';
rm.addEventListener('click', () => {
state.eventReminders.splice(idx, 1);
renderReminderRows();
});
row.appendChild(sel);
row.appendChild(customWrap);
row.appendChild(rm);
list.appendChild(row);
});
}
function addReminderRow() {
const def = (state.settings && state.settings.default_reminder_minutes != null)
? state.settings.default_reminder_minutes : 30;
state.eventReminders.push(def >= 0 ? def : 30);
renderReminderRows();
}
// Reminders apply to local events only (the server stores them on local events).
// When the selected calendar has its reminders disabled, we keep any existing
// reminders intact (never delete them) but grey out the controls and explain
// that they won't fire — matching the apps' behaviour.
function updateRemindersRow() {
const calVal = document.getElementById('ev-calendar').value || '';
const isLocal = calVal.startsWith('local-');
const group = document.getElementById('ev-reminders-group');
group.style.display = isLocal ? '' : 'none';
if (!isLocal) return;
const calId = parseInt(calVal.slice('local-'.length), 10);
const cal = (state.localCalendars || []).find(c => c.id === calId);
const disabled = !!(cal && cal.reminders_enabled === false);
const hint = document.getElementById('ev-reminders-hint');
if (hint) hint.style.display = disabled ? '' : 'none';
group.classList.toggle('reminders-disabled', disabled);
group.querySelectorAll('select, input, button').forEach(el => { el.disabled = disabled; });
}
// ── Attachments ───────────────────────────────────────────
// Attachments exist on local events only — the other sources are fetched live
// from a remote server and have no row here to hang a file on. The editor
// stages changes and applies them on Save, matching the modal's "nothing
// happens until you save" contract; the popup fetches the list lazily, because
// the event payload carries only attachment_count.
const ATTACH_ACCEPT = 'application/pdf,image/png,image/jpeg,image/webp,image/gif,.txt,.md,.csv';
const ATTACH_MAX_BYTES = 10 * 1024 * 1024;
const ATTACH_MAX_COUNT = 10;
const SVG_NS = 'http://www.w3.org/2000/svg';
const ICON_PATH_DOC = 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm0 2l4 4h-4V4zM8 13h8v2H8v-2zm0 4h8v2H8v-2z';
const ICON_PATH_PDF = 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm0 2l4 4h-4V4zM8 12h1.5a1.5 1.5 0 010 3H9v2H8v-5zm3.5 0H13a2 2 0 012 2v1a2 2 0 01-2 2h-1.5v-5z';
function formatFileSize(bytes) {
const n = Number(bytes);
if (!isFinite(n)) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
// Map the HTTP status to a translated message. Without this the backend's
// German detail would be shown to someone running the app in another language.
function attachErrorMessage(e) {
if (e && e.status === 409) return t('attachment_limit_reached');
if (e && e.status === 413) return t('attachment_too_large');
if (e && e.status === 400) return t('attachment_type_not_allowed');
return t('attachment_upload_failed', { error: (e && e.message) || '' });
}
function attachIconEl(contentType) {
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('class', 'ev-attach-icon');
svg.setAttribute('fill', 'currentColor');
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', contentType === 'application/pdf' ? ICON_PATH_PDF : ICON_PATH_DOC);
svg.appendChild(path);
return svg;
}
// cannot carry the bearer token, so fetch the thumbnail and
// hand the element a blob URL. Revoke it as soon as the image has decoded —
// the browser keeps its own copy, and the row is rebuilt on every render.
// (fetchAvatarBlob and loadAvatarImage never revoke; don't copy that.)
async function setAttachmentThumb(imgEl, attId) {
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/local/attachments/${attId}/thumb`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return; // the type icon stays in place
const url = URL.createObjectURL(await res.blob());
imgEl.onload = () => URL.revokeObjectURL(url);
imgEl.onerror = () => URL.revokeObjectURL(url);
imgEl.src = url;
imgEl.style.display = '';
} catch (e) {
/* offline or blocked — the icon is a fine fallback */
}
}
// Filenames come from other users via calendar sharing, so they only ever go
// into textContent, never into innerHTML.
function attachmentRow({ name, size, contentType, thumbId, pending, onRemove }) {
const row = document.createElement('div');
row.className = 'ev-attach-row' + (pending ? ' is-pending' : '');
if (thumbId) {
const img = document.createElement('img');
img.className = 'ev-attach-thumb';
img.alt = '';
img.style.display = 'none';
row.appendChild(attachIconEl(contentType));
row.appendChild(img);
setAttachmentThumb(img, thumbId).then(() => {
if (img.getAttribute('src')) row.firstChild.remove();
});
} else {
row.appendChild(attachIconEl(contentType));
}
const label = document.createElement('span');
label.className = 'ev-attach-name';
label.textContent = name;
label.title = name;
row.appendChild(label);
const sizeEl = document.createElement('span');
sizeEl.className = 'ev-attach-size';
sizeEl.textContent = formatFileSize(size);
row.appendChild(sizeEl);
if (onRemove) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'icon-btn ev-attach-remove';
btn.textContent = '×';
btn.title = t('delete');
btn.addEventListener('click', onRemove);
row.appendChild(btn);
}
return row;
}
function attachmentStagedCount() {
const removed = state.removedAttachments || [];
const existing = (state.eventAttachments || []).filter(a => !removed.includes(a.id));
return existing.length + (state.pendingAttachments || []).length;
}
function resetAttachmentState() {
state.eventAttachments = [];
state.pendingAttachments = [];
state.removedAttachments = [];
}
function renderAttachmentRows() {
const list = document.getElementById('ev-attachments-list');
if (!list) return;
list.innerHTML = '';
const removed = state.removedAttachments || [];
const existing = (state.eventAttachments || []).filter(a => !removed.includes(a.id));
const pending = state.pendingAttachments || [];
// Nothing when empty — the reminders list does the same, and the caption
// under the button already says what can go here.
existing.forEach(a => list.appendChild(attachmentRow({
name: a.filename, size: a.size_bytes, contentType: a.content_type,
thumbId: a.has_thumb ? a.id : null,
onRemove: () => { state.removedAttachments.push(a.id); renderAttachmentRows(); },
})));
pending.forEach((f, i) => list.appendChild(attachmentRow({
name: f.name, size: f.size, contentType: f.type, pending: true,
onRemove: () => { state.pendingAttachments.splice(i, 1); renderAttachmentRows(); },
})));
const addBtn = document.getElementById('ev-attachment-add');
if (addBtn) addBtn.disabled = attachmentStagedCount() >= ATTACH_MAX_COUNT;
}
// Attachments apply to local calendars only — same gate as the private toggle.
function updateAttachmentsRow() {
const calVal = document.getElementById('ev-calendar').value || '';
const isLocal = calVal.startsWith('local-');
const group = document.getElementById('ev-attachments-group');
if (!group) return;
group.style.display = isLocal ? '' : 'none';
if (!isLocal && (state.pendingAttachments || []).length) {
// Switching to a remote calendar drops staged files: there would be
// nowhere to put them. Say so rather than losing them silently.
state.pendingAttachments = [];
showToast(t('attachment_save_first'), true);
}
if (isLocal) renderAttachmentRows();
}
function triggerAttachmentPicker() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = ATTACH_ACCEPT;
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', () => {
const files = Array.from(input.files || []);
input.remove();
for (const f of files) {
if (attachmentStagedCount() >= ATTACH_MAX_COUNT) {
showToast(t('attachment_limit_reached'), true);
break;
}
// The server checks both again; this is only to fail fast.
if (f.size > ATTACH_MAX_BYTES) {
showToast(t('attachment_too_large'), true);
continue;
}
state.pendingAttachments.push(f);
}
renderAttachmentRows();
});
input.click();
}
async function loadEventAttachments(ev) {
resetAttachmentState();
if (ev && ev.source === 'local' && (ev.attachment_count || 0) > 0) {
try {
state.eventAttachments = await api.get(
`/local/events/${encodeURIComponent(ev.id)}/attachments`) || [];
} catch (e) {
/* the editor still opens; the list just stays empty */
}
}
renderAttachmentRows();
}
// Applied after the event itself was saved, because a new event has no uid to
// attach to until then. If this fails the event still exists — say so plainly
// instead of reporting success.
async function flushAttachmentChanges(uid) {
const removed = state.removedAttachments || [];
const pending = state.pendingAttachments || [];
let failed = 0;
let lastError = null;
for (const id of removed) {
try {
await api.delete(`/local/attachments/${id}`);
} catch (e) { failed++; lastError = e; }
}
for (const f of pending) {
const form = new FormData();
form.append('file', f, f.name);
try {
await api.upload(`/local/events/${encodeURIComponent(uid)}/attachments`, form);
} catch (e) { failed++; lastError = e; }
}
const total = removed.length + pending.length;
resetAttachmentState();
if (failed === 1) {
showToast(attachErrorMessage(lastError), true);
} else if (failed > 1) {
showToast(t('attachment_upload_partial', { failed, total }), true);
}
}
// ── Attachments in the detail popup ───────────────────────
async function renderPopupAttachments(ev) {
const row = document.getElementById('popup-row-attachments');
const list = document.getElementById('popup-attachments');
if (!row || !list) return;
list.innerHTML = '';
if (ev.source !== 'local' || !((ev.attachment_count || 0) > 0)) {
row.style.display = 'none';
return;
}
row.style.display = '';
const placeholder = document.createElement('span');
placeholder.className = 'ev-attach-size';
placeholder.textContent = '…';
list.appendChild(placeholder);
let items = [];
try {
items = await api.get(`/local/events/${encodeURIComponent(ev.id)}/attachments`) || [];
} catch (e) {
list.innerHTML = '';
const err = document.createElement('span');
err.className = 'ev-attach-size';
err.textContent = (e && e.message) || '';
list.appendChild(err);
return;
}
// The popup may have been closed or re-rendered while we waited.
if (document.getElementById('popup-attachments') !== list) return;
list.innerHTML = '';
if (!items.length) { row.style.display = 'none'; return; }
for (const a of items) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'popup-attach-item';
item.appendChild(attachIconEl(a.content_type));
const label = document.createElement('span');
label.className = 'ev-attach-name';
label.textContent = a.filename;
label.title = a.filename;
item.appendChild(label);
const size = document.createElement('span');
size.className = 'ev-attach-size';
size.textContent = formatFileSize(a.size_bytes);
item.appendChild(size);
item.addEventListener('click', async () => {
try {
await api.download(`/local/attachments/${a.id}`, a.filename);
} catch (err) {
showToast((err && err.message) || '', true);
}
});
list.appendChild(item);
}
}
function buildRruleFromUI() {
const sel = document.getElementById('ev-recurrence').value;
if (!sel) return null;
if (sel !== 'custom') return sel;
const interval = parseInt(document.getElementById('ev-rec-interval').value) || 1;
const freq = document.getElementById('ev-rec-freq').value;
let rule = `FREQ=${freq}`;
if (interval > 1) rule += `;INTERVAL=${interval}`;
if (freq === 'WEEKLY') {
const days = [...document.querySelectorAll('.rec-day-btn.active')].map(b => b.dataset.day);
if (days.length) rule += `;BYDAY=${days.join(',')}`;
}
const endType = document.getElementById('ev-rec-end-type').value;
if (endType === 'count') {
rule += `;COUNT=${parseInt(document.getElementById('ev-rec-count').value) || 10}`;
} else if (endType === 'until') {
const until = document.getElementById('ev-rec-until').value;
if (until) rule += `;UNTIL=${until.replace(/-/g, '')}T235959Z`;
}
return rule;
}
function parseRruleIntoUI(rruleStr) {
const parts = {};
rruleStr.split(';').forEach(p => {
const [k, v] = p.split('=', 2);
if (k && v) parts[k] = v;
});
document.getElementById('ev-rec-interval').value = parts.INTERVAL || '1';
document.getElementById('ev-rec-freq').value = parts.FREQ || 'DAILY';
document.getElementById('ev-rec-weekdays').classList.toggle('hidden', parts.FREQ !== 'WEEKLY');
// Reset all weekday buttons
document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active'));
if (parts.BYDAY) {
parts.BYDAY.split(',').forEach(day => {
const btn = document.querySelector(`.rec-day-btn[data-day="${day.trim()}"]`);
if (btn) btn.classList.add('active');
});
}
if (parts.COUNT) {
document.getElementById('ev-rec-end-type').value = 'count';
document.getElementById('ev-rec-count').value = parts.COUNT;
document.getElementById('ev-rec-end-count').classList.remove('hidden');
document.getElementById('ev-rec-end-until').classList.add('hidden');
} else if (parts.UNTIL) {
document.getElementById('ev-rec-end-type').value = 'until';
// Parse UNTIL: 20260501T235959Z → 2026-05-01
const u = parts.UNTIL.replace('Z', '');
const formatted = u.length >= 8 ? `${u.slice(0,4)}-${u.slice(4,6)}-${u.slice(6,8)}` : '';
if (formatted) setDtValue('ev-rec-until', formatted, 'date');
document.getElementById('ev-rec-end-count').classList.add('hidden');
document.getElementById('ev-rec-end-until').classList.remove('hidden');
} else {
document.getElementById('ev-rec-end-type').value = 'never';
document.getElementById('ev-rec-end-count').classList.add('hidden');
document.getElementById('ev-rec-end-until').classList.add('hidden');
}
}
function resetRecurrenceUI() {
document.getElementById('ev-recurrence').value = '';
document.getElementById('ev-recurrence-custom').classList.add('hidden');
document.getElementById('ev-rec-interval').value = '1';
document.getElementById('ev-rec-freq').value = 'DAILY';
document.getElementById('ev-rec-weekdays').classList.add('hidden');
document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById('ev-rec-end-type').value = 'never';
document.getElementById('ev-rec-end-count').classList.add('hidden');
document.getElementById('ev-rec-end-until').classList.add('hidden');
}
function bindEventModal() {
document.getElementById('ev-allday').addEventListener('change', e => {
toggleAlldayFields(e.target.checked);
});
// The "Privat" toggle and reminders are only relevant for local calendars.
document.getElementById('ev-calendar').addEventListener('change', () => {
updatePrivateRow();
updateRemindersRow();
updateAttachmentsRow();
});
document.getElementById('ev-reminder-add').addEventListener('click', addReminderRow);
document.getElementById('ev-attachment-add').addEventListener('click', triggerAttachmentPicker);
// Custom calendar dropdown toggle
document.getElementById('ev-cal-trigger').addEventListener('click', e => {
e.stopPropagation();
const menu = document.getElementById('ev-cal-menu');
if (menu.classList.contains('hidden')) openCalSelectMenu(); else closeCalSelectMenu();
});
document.getElementById('ev-cal-select').addEventListener('keydown', e => {
if (e.key === 'Escape') closeCalSelectMenu();
});
// Date/time pickers with auto-adjustment logic
[
{ displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' },
{ displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime', role: 'end' },
{ displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date', role: 'start' },
{ displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date', role: 'end' },
].forEach(({ displayId, inputId, mode, role }) => {
const disp = document.getElementById(displayId);
if (!disp) return;
const open = async () => {
const current = document.getElementById(inputId)?.value || '';
const oldStart = mode === 'datetime'
? document.getElementById('ev-start').value
: document.getElementById('ev-start-date').value;
const oldEnd = mode === 'datetime'
? document.getElementById('ev-end').value
: document.getElementById('ev-end-date').value;
const result = await openDatePicker(disp, current, mode);
if (result === null) return;
setDtValue(inputId, result, mode);
if (role === 'start') {
// Adjust end to maintain duration
if (mode === 'datetime') {
const os = oldStart ? new Date(oldStart) : null;
const oe = oldEnd ? new Date(oldEnd) : null;
const ns = new Date(result);
const duration = (os && oe && oe > os) ? (oe - os) : 3600000;
const ne = new Date(ns.getTime() + duration);
setDtValue('ev-end', toLocalDatetimeInput(ne), 'datetime');
} else {
const endVal = document.getElementById('ev-end-date').value;
if (!endVal || endVal < result) {
setDtValue('ev-end-date', result, 'date');
}
}
} else {
// End moved before start → move the start back instead of erroring
// (mirror of the start handler, which moves the end).
if (mode === 'datetime') {
const startVal = document.getElementById('ev-start').value;
if (startVal && new Date(result) < new Date(startVal)) {
const os = oldStart ? new Date(oldStart) : null;
const oe = oldEnd ? new Date(oldEnd) : null;
const duration = (os && oe && oe > os) ? (oe - os) : 3600000;
const ns = new Date(new Date(result).getTime() - duration);
setDtValue('ev-start', toLocalDatetimeInput(ns), 'datetime');
}
} else {
const startVal = document.getElementById('ev-start-date').value;
if (startVal && result < startVal) {
setDtValue('ev-start-date', result, 'date');
}
}
}
};
disp.addEventListener('click', open);
disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); });
});
// Color picker: click preview to open gradient picker
const evColorPreview = document.getElementById('ev-color-preview');
const evColorHex = document.getElementById('ev-color-hex');
evColorPreview.addEventListener('click', async () => {
const current = state.selectedEventColor || '#4285f4';
const picked = await openColorPicker(evColorPreview, current);
if (picked) resetColorPicker(picked);
});
evColorHex.addEventListener('change', () => {
let val = evColorHex.value.trim();
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
resetColorPicker(val);
}
});
// ── Recurrence UI ──────────────────────────────────────
const recSel = document.getElementById('ev-recurrence');
const customPanel = document.getElementById('ev-recurrence-custom');
const recFreq = document.getElementById('ev-rec-freq');
const weekdaysDiv = document.getElementById('ev-rec-weekdays');
const endTypeSel = document.getElementById('ev-rec-end-type');
recSel.addEventListener('change', () => {
customPanel.classList.toggle('hidden', recSel.value !== 'custom');
});
recFreq.addEventListener('change', () => {
weekdaysDiv.classList.toggle('hidden', recFreq.value !== 'WEEKLY');
});
document.querySelectorAll('.rec-day-btn').forEach(btn => {
btn.addEventListener('click', () => btn.classList.toggle('active'));
});
endTypeSel.addEventListener('change', () => {
document.getElementById('ev-rec-end-count').classList.toggle('hidden', endTypeSel.value !== 'count');
document.getElementById('ev-rec-end-until').classList.toggle('hidden', endTypeSel.value !== 'until');
});
const untilDisp = document.getElementById('ev-rec-until-display');
if (untilDisp) {
const openUntil = async () => {
const current = document.getElementById('ev-rec-until').value || '';
const result = await openDatePicker(untilDisp, current, 'date');
if (result !== null) setDtValue('ev-rec-until', result, 'date');
};
untilDisp.addEventListener('click', openUntil);
untilDisp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openUntil(); });
}
document.getElementById('ev-save').onclick = async () => {
const title = document.getElementById('ev-title').value.trim();
if (!title) { showToast(t('error_enter_title'), true); return; }
const allDay = document.getElementById('ev-allday').checked;
const calVal = document.getElementById('ev-calendar').value;
const isLocal = calVal.startsWith('local-');
const isGoogle = calVal.startsWith('google-');
const isHA = calVal.startsWith('homeassistant-');
const loc = document.getElementById('ev-location').value.trim();
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) {
start = document.getElementById('ev-start-date').value;
end = document.getElementById('ev-end-date').value;
if (!start) { showToast(t('error_enter_date'), true); return; }
if (!end || end < start) end = start;
// User picker uses inclusive end; storage uses exclusive (iCal convention)
end = allDayEndToExclusive(end);
} else {
const sv = document.getElementById('ev-start').value;
const ev2 = document.getElementById('ev-end').value;
if (!sv) { showToast(t('error_enter_start'), true); return; }
start = new Date(sv).toISOString();
end = ev2 ? new Date(ev2).toISOString() : new Date(new Date(sv).getTime() + 3600000).toISOString();
}
try {
if (state.editingEvent) {
const ev = state.editingEvent;
if (ev.source === 'google') {
const accId = ev.calendar_id.replace('google-', '');
await api.put(`/google/events/${accId}/${encodeURIComponent(ev.id)}`,
{ title, start, end, allDay, location: loc, description: desc }
);
} 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 || '', private: isPrivate, reminders: state.eventReminders }
);
// Staged adds/removals are applied only now, so cancelling the modal
// really does change nothing.
await flushAttachmentChanges(ev.id);
} else if (ev.source === 'ical') {
showToast(t('event_readonly'), true);
return;
} else if (ev.source === 'homeassistant') {
const haCalId = ev.calendar_id.replace('homeassistant-', '');
await api.put(`/homeassistant/events/${haCalId}/${encodeURIComponent(ev.id)}`,
{ title, start, end, allDay, location: loc, description: desc }
);
} else {
await api.put(
`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`,
{ title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' }
);
}
// Patch the cached event in-place so the UI reflects changes immediately
applyEventPatch(ev.id, ev.url, {
title, start, end, allDay,
location: loc, description: desc,
color: color || null,
rrule: rrule || null,
private: ev.source === 'local' ? isPrivate : ev.private,
reminders: ev.source === 'local' ? state.eventReminders.slice() : ev.reminders,
});
showToast(t('event_updated'));
} else if (isGoogle) {
const calDbId = parseInt(calVal.replace('google-', ''));
await api.post('/google/events', {
calendar_db_id: calDbId, title, start, end, allDay,
location: loc, description: desc,
});
showToast(t('event_created'));
} else if (isLocal) {
const calId = parseInt(calVal.replace('local-', ''));
// A new event has no uid until the server answers, so files picked
// beforehand were only staged — upload them now against the real uid.
const created = await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
rrule: rrule || null, private: isPrivate, reminders: state.eventReminders,
});
showToast(t('event_created'));
if (created && created.id) await flushAttachmentChanges(created.id);
} else if (isHA) {
const haCalId = parseInt(calVal.replace('homeassistant-', ''));
await api.post('/homeassistant/events', {
calendar_id: haCalId, title, start, end, allDay,
location: loc, description: desc,
});
showToast(t('event_created'));
} else {
const calId = parseInt(calVal);
await api.post('/caldav/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
rrule: rrule || null,
});
showToast(t('event_created'));
}
closeModal('modal-event');
fetchAndRender(true);
} catch (e) {
showToast(e.message, true);
}
};
document.getElementById('ev-delete').onclick = async () => {
const ev = state.editingEvent;
if (!ev) return;
const scope = await showDeleteConfirm(ev);
if (!scope) return;
try {
await deleteEventByScope(ev, scope);
showToast(t('event_deleted'));
closeModal('modal-event');
fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
};
}
// ── Account Modal ─────────────────────────────────────────
function openAccountModal() {
document.getElementById('acc-name').value = '';
document.getElementById('acc-url').value = '';
document.getElementById('acc-username').value = '';
document.getElementById('acc-password').value = '';
document.getElementById('acc-color-hex').value = '#4285f4';
document.getElementById('acc-color-preview').style.background = '#4285f4';
document.getElementById('acc-error').classList.add('hidden');
openModal('modal-account');
}
function bindAccountModal() {
const accPreview = document.getElementById('acc-color-preview');
const accHex = document.getElementById('acc-color-hex');
accPreview.addEventListener('click', async () => {
const picked = await openColorPicker(accPreview, accHex.value || '#4285f4');
if (picked) { accHex.value = picked.toUpperCase(); accPreview.style.background = picked; }
});
accHex.addEventListener('change', () => {
let val = accHex.value.trim();
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) { accHex.value = val.toUpperCase(); accPreview.style.background = val; }
});
document.getElementById('acc-save').onclick = async () => {
const name = document.getElementById('acc-name').value.trim();
const url = document.getElementById('acc-url').value.trim();
const username = document.getElementById('acc-username').value.trim();
const password = document.getElementById('acc-password').value;
const color = document.getElementById('acc-color-hex').value;
const errEl = document.getElementById('acc-error');
if (!name || !url || !username || !password) {
errEl.textContent = 'Bitte alle Felder ausfüllen';
errEl.classList.remove('hidden');
return;
}
errEl.classList.add('hidden');
document.getElementById('acc-save').disabled = true;
document.getElementById('acc-save').textContent = 'Verbinde…';
try {
const acc = await api.post('/caldav/accounts', { name, url, username, password, color });
state.accounts.push(acc);
renderCalendarList();
closeModal('modal-account');
showToast(t("account_added", {name}));
fetchAndRender(true);
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
document.getElementById('acc-save').disabled = false;
document.getElementById('acc-save').textContent = 'Verbinden';
}
};
}
// ── Birthday reminder options (shared by the calendar & birthday modals) ──
// value -1 = off, 0 = on the day, N = N days before.
function fillBirthdayNotifySelect(sel) {
sel.innerHTML =
`${escHtml(t('birthday_notify_off'))} ` +
`${escHtml(t('birthday_notify_same_day'))} ` +
`${escHtml(t('birthday_notify_one_day'))} ` +
[2, 3, 7].map(d => `${escHtml(t('birthday_notify_days', { n: d }))} `).join('');
}
// ── Local Calendar Modal ──────────────────────────────────
function openLocalCalModal() {
document.getElementById('local-cal-name').value = '';
document.getElementById('local-cal-color-hex').value = '#34a853';
document.getElementById('local-cal-color-preview').style.background = '#34a853';
openModal('modal-local-cal');
}
function bindLocalCalModal() {
const preview = document.getElementById('local-cal-color-preview');
const hex = document.getElementById('local-cal-color-hex');
preview.addEventListener('click', async () => {
const picked = await openColorPicker(preview, hex.value || '#34a853');
if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
});
hex.addEventListener('change', () => {
let val = hex.value.trim();
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
});
document.getElementById('local-cal-save').onclick = async () => {
const name = document.getElementById('local-cal-name').value.trim();
if (!name) { showToast(t('error_enter_name'), true); return; }
const color = hex.value;
try {
const cal = await api.post('/local/calendars', { name, color });
state.localCalendars.push(cal);
renderCalendarList();
closeModal('modal-local-cal');
showToast(t("calendar_created", {name}));
} catch (e) { showToast(e.message, true); }
};
}
// ── Birthdays (single dedicated calendar per user) ─────────
const BIRTHDAY_DEFAULT_COLOR = '#e0407f';
/** The user's single birthday calendar (owned, is_birthday), or null. */
function birthdayCalendar() {
return (state.localCalendars || []).find(c => c.is_birthday && c.owned !== false) || null;
}
/** Create the single "Geburtstage" birthday calendar if none exists; returns it. */
async function ensureBirthdayCalendar() {
const existing = birthdayCalendar();
if (existing) return existing;
const cal = await api.post('/local/calendars', {
name: t('birthday_calendar_name'), color: BIRTHDAY_DEFAULT_COLOR, is_birthday: true,
});
state.localCalendars.push(cal);
renderCalendarList();
return cal;
}
/** Explicit "add birthday calendar" action (sidebar dropdown + settings). Only
* one per account — if one exists, just inform the user. */
async function addBirthdayCalendar() {
if (birthdayCalendar()) { showToast(t('birthday_calendar_exists')); return; }
try {
await ensureBirthdayCalendar();
renderBirthdaySettings();
renderCalendarTable();
fetchAndRender();
showToast(t('calendar_created', { name: t('birthday_calendar_name') }));
} catch (e) { showToast(e.message, true); }
}
// ── Birthday Modal (manual entry) ─────────────────────────
function fillBirthdayDateSelects() {
const daySel = document.getElementById('birthday-day');
const monthSel = document.getElementById('birthday-month');
if (daySel.options.length === 0) {
daySel.innerHTML = Array.from({ length: 31 }, (_, i) =>
`${i + 1} `).join('');
}
if (monthSel.options.length === 0) {
const months = t('months');
monthSel.innerHTML = months.map((m, i) => `${escHtml(m)} `).join('');
}
}
function openBirthdayModal() {
const cal = birthdayCalendar();
document.getElementById('birthday-no-cal').classList.toggle('hidden', !!cal);
document.getElementById('birthday-form').classList.toggle('hidden', !cal);
document.getElementById('birthday-save').classList.toggle('hidden', !cal);
if (cal) {
fillBirthdayDateSelects();
const today = new Date();
document.getElementById('birthday-name').value = '';
document.getElementById('birthday-day').value = String(today.getDate());
document.getElementById('birthday-month').value = String(today.getMonth() + 1);
document.getElementById('birthday-year').value = String(today.getFullYear());
document.getElementById('birthday-year-unknown').checked = false;
document.getElementById('birthday-year').classList.remove('hidden');
}
openModal('modal-birthday');
}
function bindBirthdayModal() {
const yearUnknown = document.getElementById('birthday-year-unknown');
const yearInput = document.getElementById('birthday-year');
yearUnknown.addEventListener('change', () => {
yearInput.classList.toggle('hidden', yearUnknown.checked);
});
document.getElementById('birthday-activate').onclick = async () => {
try {
await ensureBirthdayCalendar();
showToast(t('calendar_created', { name: t('birthday_calendar_name') }));
openBirthdayModal(); // re-render the modal in form mode
} catch (e) { showToast(e.message, true); }
};
document.getElementById('birthday-save').onclick = async () => {
const cal = birthdayCalendar();
if (!cal) return;
const name = document.getElementById('birthday-name').value.trim();
if (!name) { showToast(t('error_enter_name'), true); return; }
const d = parseInt(document.getElementById('birthday-day').value, 10);
const m = parseInt(document.getElementById('birthday-month').value, 10);
const unknown = yearUnknown.checked;
const y = unknown ? 1970 : parseInt(yearInput.value, 10) || new Date().getFullYear();
// All-day anchor: birth year if known, else 1970 so FREQ=YEARLY expands
// across any queried range. end = next day.
const pad = n => String(n).padStart(2, '0');
const start = `${y}-${pad(m)}-${pad(d)}`;
const endDate = new Date(Date.UTC(y, m - 1, d + 1));
const end = `${endDate.getUTCFullYear()}-${pad(endDate.getUTCMonth() + 1)}-${pad(endDate.getUTCDate())}`;
const body = { calendar_id: cal.id, title: name, start, end, allDay: true, rrule: 'FREQ=YEARLY' };
if (!unknown) body.birth_year = y;
try {
await api.post('/local/events', body);
closeModal('modal-birthday');
showToast(t('birthday_created', { name }));
fetchAndRender();
} catch (e) { showToast(e.message, true); }
};
}
// ── Home Assistant Account Modal ─────────────────────────
function openHAAccountModal() {
document.getElementById('ha-account-name').value = '';
document.getElementById('ha-account-url').value = '';
document.getElementById('ha-account-token').value = '';
document.getElementById('ha-account-error').classList.add('hidden');
// Reset to OAuth method
document.getElementById('ha-auth-oauth').checked = true;
document.getElementById('ha-oauth-info').classList.remove('hidden');
document.getElementById('ha-token-group').classList.add('hidden');
document.getElementById('ha-account-save').textContent = 'Mit Home Assistant anmelden';
openModal('modal-ha-account');
}
function bindHAAccountModal() {
// Toggle auth method fields + save button label
document.querySelectorAll('[name="ha-auth-method"]').forEach(r => {
r.addEventListener('change', () => {
const isOAuth = document.getElementById('ha-auth-oauth').checked;
document.getElementById('ha-oauth-info').classList.toggle('hidden', !isOAuth);
document.getElementById('ha-token-group').classList.toggle('hidden', isOAuth);
document.getElementById('ha-account-save').textContent = isOAuth
? 'Mit Home Assistant anmelden'
: 'Verbinden';
});
});
document.getElementById('ha-account-save').onclick = async () => {
const name = document.getElementById('ha-account-name').value.trim();
const url = document.getElementById('ha-account-url').value.trim();
const isOAuth = document.getElementById('ha-auth-oauth').checked;
const errEl = document.getElementById('ha-account-error');
if (!name || !url) {
errEl.textContent = 'Bitte Name und URL ausfüllen';
errEl.classList.remove('hidden');
return;
}
errEl.classList.add('hidden');
const saveBtn = document.getElementById('ha-account-save');
saveBtn.disabled = true;
if (isOAuth) {
saveBtn.textContent = 'Weiterleiten…';
try {
const base = window.location.origin;
const resp = await api.post('/homeassistant/auth-url', {
name,
url,
client_id: base + '/',
redirect_uri: base + '/api/homeassistant/callback',
});
if (!resp) return;
window.location.href = resp.url;
} catch (e) {
errEl.textContent = e.message || 'Fehler beim Starten der Anmeldung';
errEl.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Mit Home Assistant anmelden';
}
return;
}
// Long-Lived Token flow
const token = document.getElementById('ha-account-token').value.trim();
if (!token) {
errEl.textContent = 'Bitte Access Token ausfüllen';
errEl.classList.remove('hidden');
saveBtn.disabled = false;
return;
}
saveBtn.textContent = 'Verbinde…';
try {
const account = await api.post('/homeassistant/accounts', { name, url, token });
if (!account) return;
state.haAccounts.push(account);
renderCalendarList();
closeModal('modal-ha-account');
showToast(`Home Assistant "${name}" verbunden`);
fetchAndRender(true);
} catch (e) {
errEl.textContent = e.message || 'Home Assistant nicht erreichbar';
errEl.classList.remove('hidden');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Verbinden';
}
};
}
// ── iCal Subscription Modal ──────────────────────────────
function openICalSubModal() {
document.getElementById('ical-sub-name').value = '';
document.getElementById('ical-sub-url').value = '';
document.getElementById('ical-sub-color-hex').value = '#46bdc6';
document.getElementById('ical-sub-color-preview').style.background = '#46bdc6';
document.getElementById('ical-sub-refresh').value = '60';
document.getElementById('ical-sub-error').classList.add('hidden');
openModal('modal-ical-sub');
}
function bindICalSubModal() {
const preview = document.getElementById('ical-sub-color-preview');
const hex = document.getElementById('ical-sub-color-hex');
preview.addEventListener('click', async () => {
const picked = await openColorPicker(preview, hex.value || '#46bdc6');
if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
});
hex.addEventListener('change', () => {
let val = hex.value.trim();
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
});
document.getElementById('ical-sub-save').onclick = async () => {
const name = document.getElementById('ical-sub-name').value.trim();
const url = document.getElementById('ical-sub-url').value.trim();
const errEl = document.getElementById('ical-sub-error');
if (!name || !url) { errEl.textContent = 'Bitte Name und URL eingeben'; errEl.classList.remove('hidden'); return; }
errEl.classList.add('hidden');
const color = hex.value;
const refresh_minutes = parseInt(document.getElementById('ical-sub-refresh').value);
document.getElementById('ical-sub-save').disabled = true;
document.getElementById('ical-sub-save').textContent = 'Lade…';
try {
const sub = await api.post('/ical/subscriptions', { name, url, color, refresh_minutes });
state.icalSubscriptions.push(sub);
renderCalendarList();
closeModal('modal-ical-sub');
showToast(t("ical_subscribed", {name}));
fetchAndRender(true);
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
document.getElementById('ical-sub-save').disabled = false;
document.getElementById('ical-sub-save').textContent = 'Abonnieren';
}
};
}
// ── 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')}
${t('remove')}
`
).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 full directory + which users are already shared, so the picker
// can show a checkbox per user (checked = shared).
const modal = document.getElementById('modal-share');
modal.__users = users;
modal.__sharedIds = sharedIds;
renderShareUserPicker();
}
function renderShareUserPicker() {
const modal = document.getElementById('modal-share');
const users = modal.__users || [];
const sharedIds = modal.__sharedIds || new Set();
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-cb').forEach(cb => {
cb.addEventListener('change', async () => {
const calId = parseInt(modal.dataset.calId);
const userId = parseInt(cb.dataset.userId);
try {
if (cb.checked) {
const permission = document.getElementById('share-permission').value;
await api.post(`/local/calendars/${calId}/shares`, { user_id: userId, permission });
} else {
await api.delete(`/local/calendars/${calId}/shares/${userId}`);
}
await refreshShareModal(calId);
} catch (e) { showToast(e.message, true); cb.checked = !cb.checked; }
});
});
}
// ── 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 = `${t('groups_none')}
`;
return;
}
el.innerHTML = state.groups.map(g =>
`
${groupIconHtml(g.icon)}
${escHtml(g.name)}
`
).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)); });
});
}
// Open the new-event editor preselected to the active group's shared calendar.
function openGroupEventModal() {
const g = state.groups.find(x => x.id === state.activeGroupId);
if (!g || !g.group_calendar_id) { showToast(t('groups_none'), true); return; }
openNewEventModal(state.selectedDate || state.currentDate);
const sel = document.getElementById('ev-calendar');
sel.value = `local-${g.group_calendar_id}`;
updatePrivateRow(false);
}
async function enterGroupView(groupId) {
state.activeGroupId = groupId;
state.hiddenGroupMembers = new Set();
state.activeGroupDetail = null;
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();
// Load the member list (with server colours) for the per-member filter.
try { state.activeGroupDetail = await api.get(`/groups/${groupId}`); } catch (e) { /* ignore */ }
renderGroupMemberFilter();
fetchAndRender(true);
}
function exitGroupView() {
state.activeGroupId = null;
state.activeGroupDetail = null;
state.hiddenGroupMembers = new Set();
document.getElementById('group-view-banner').classList.add('hidden');
renderGroupMemberFilter();
renderGroupList();
fetchAndRender(true);
}
// Group members live in the sidebar (like the calendar list): each row has a
// colour dot (tap to recolour that member just for your view) and a checkbox to
// hide/show them in the combined overlay. The personal calendar list is hidden
// while a group is active.
function renderGroupMemberFilter() {
const section = document.getElementById('group-members');
const items = document.getElementById('group-members-items');
const calList = document.getElementById('cal-list');
const active = !!(state.activeGroupId && state.activeGroupDetail);
if (section) section.classList.toggle('hidden', !active);
if (calList) calList.classList.toggle('hidden', !!state.activeGroupId);
if (!active || !items) return;
const g = state.activeGroupDetail;
const gid = state.activeGroupId;
const row = (numKey, colorKey, name, baseColor) => {
const hidden = state.hiddenGroupMembers.has(numKey);
const color = groupColorOverride(gid, colorKey) || baseColor || '#4285f4';
return ``;
};
// Only members who actually share a calendar into the group get a toggle —
// otherwise we'd show phantom empty entries for members who share nothing.
const parts = (g.members || [])
.filter(m => m.shares_calendar)
.map(m => row(m.id, String(m.id), m.display_name || '—', m.color));
parts.push(row('gc', 'gc', t('group_calendar'), g.group_calendar_color));
items.innerHTML = parts.join('');
items.querySelectorAll('.gm-vis').forEach(cb => {
cb.addEventListener('change', () => {
const ck = cb.dataset.k;
const key = ck === 'gc' ? 'gc' : parseInt(ck);
if (cb.checked) state.hiddenGroupMembers.delete(key);
else state.hiddenGroupMembers.add(key);
renderView();
});
});
items.querySelectorAll('.gm-dot').forEach(dot => {
dot.addEventListener('click', async () => {
const picked = await openColorPicker(dot, dot.dataset.color || '#4285f4');
if (picked) {
setGroupColorOverride(gid, dot.dataset.k, picked);
renderGroupMemberFilter();
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 = false; // rename supported via PUT
// Icon picker
modal.__icon = (detail && GROUP_ICON_KEYS.includes(detail.icon)) ? detail.icon : 'people';
renderGroupIconPicker();
// 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();
// Member colours (edit mode only): owner can recolour any member.
const colorsGroup = document.getElementById('group-colors-group');
if (isEdit && detail) {
colorsGroup.style.display = '';
renderGroupMemberColors(groupId, detail.members || []);
} else {
colorsGroup.style.display = 'none';
}
openModal('modal-group');
}
function renderGroupMemberColors(groupId, members) {
const el = document.getElementById('group-member-colors');
if (!el) return;
el.innerHTML = members.map(m =>
`
${escHtml(m.display_name || '')}
`
).join('');
el.querySelectorAll('.group-color-dot').forEach(dot => {
dot.addEventListener('click', async () => {
const picked = await openColorPicker(dot, dot.dataset.color);
if (!picked) return;
try {
await api.put(`/groups/${groupId}/members/${dot.dataset.uid}/color`, { color: picked });
dot.style.background = picked;
dot.dataset.color = picked;
} catch (e) { showToast(e.message, true); }
});
});
}
// Cross-platform group icons: semantic keys stored server-side, rendered as
// inline SVG here (SF Symbols on iOS, Material on Android) so they look the same
// everywhere instead of relying on OS-specific emoji.
const GROUP_ICON_KEYS = ['people', 'home', 'heart', 'work', 'school', 'sports',
'party', 'pet', 'travel', 'music', 'food', 'star'];
const GROUP_ICON_PATHS = {
people: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z',
home: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z',
heart: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
work: 'M20 6h-4V4c0-1.11-.89-2-2-2h-4c-1.11 0-2 .89-2 2v2H4c-1.11 0-1.99.89-1.99 2L2 19c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-6 0h-4V4h4v2z',
school: 'M5 13.18v4L12 21l7-3.82v-4L12 17l-7-3.82zM12 3L1 9l11 6 9-4.91V17h2V9L12 3z',
sports: 'M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94.63 1.5 1.98 2.63 3.61 2.96V19H7v2h10v-2h-4v-3.1c1.63-.33 2.98-1.46 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z',
party: 'M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z',
pet: 'M12 14c-2.5 0-4.5 1.8-4.5 4 0 .6.4 1 1 1h7c.6 0 1-.4 1-1 0-2.2-2-4-4.5-4zM6 9.5a1.5 2 0 1 0 0 4 1.5 2 0 0 0 0-4zm12 0a1.5 2 0 1 0 0 4 1.5 2 0 0 0 0-4zM9 6a1.5 2 0 1 0 0 4 1.5 2 0 0 0 0-4zm6 0a1.5 2 0 1 0 0 4 1.5 2 0 0 0 0-4z',
travel: 'M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z',
music: 'M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z',
food: 'M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z',
star: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z',
};
function groupIconSvg(key, size = 18) {
const p = GROUP_ICON_PATHS[key] || GROUP_ICON_PATHS.people;
return ` `;
}
// Render a group's stored icon: SVG for known keys, legacy emoji as-is.
function groupIconHtml(icon, size = 18) {
if (GROUP_ICON_KEYS.includes(icon)) return groupIconSvg(icon, size);
if (icon) return escHtml(icon);
return groupIconSvg('people', size);
}
// Icons specifically for the "this calendar is shared with a group" indicator.
const SHARE_ICON_KEYS = ['share', 'link', 'send', 'eye', 'upload', 'wifi', 'person_add', 'ios_share'];
const SHARE_ICON_PATHS = {
share: 'M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z',
link: 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z',
send: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z',
eye: 'M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z',
upload: 'M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z',
wifi: 'M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z',
person_add: 'M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
ios_share: 'M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z',
};
function shareIconSvg(key, size = 18) {
const p = SHARE_ICON_PATHS[key] || SHARE_ICON_PATHS.share;
return ` `;
}
function renderGroupIconPicker() {
const modal = document.getElementById('modal-group');
const sel = modal.__icon || 'people';
const picker = document.getElementById('group-icon-picker');
if (!picker) return;
picker.innerHTML = GROUP_ICON_KEYS.map(ic =>
`${groupIconSvg(ic, 22)} `
).join('');
picker.querySelectorAll('.group-icon-opt').forEach(b => {
b.addEventListener('click', () => {
modal.__icon = b.dataset.icon;
renderGroupIconPicker();
});
});
}
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 => {
const on = picked.has(u.id);
return `
${on ? '✓' : ''}
${escHtml(u.display_name || '')}
`;
}).join('')
: `${t('share_no_users')} `;
picker.querySelectorAll('.pick-row').forEach(rowEl => {
rowEl.addEventListener('click', () => {
const id = parseInt(rowEl.dataset.memberId);
if (picked.has(id)) picked.delete(id); else picked.add(id);
renderGroupMemberPicker();
});
});
}
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;
const newEvtBtn = document.getElementById('group-view-new-event');
if (newEvtBtn) newEvtBtn.onclick = openGroupEventModal;
document.getElementById('group-save').onclick = async () => {
const modal = document.getElementById('modal-group');
const groupId = modal.dataset.groupId;
const memberIds = [...(modal.__memberIds || new Set())];
try {
const name = document.getElementById('group-name').value.trim();
const icon = modal.__icon || 'people';
if (!name) { showToast(t('error_enter_title'), true); return; }
if (groupId) {
// Manage mode: update name/icon, then sync member additions/removals.
await api.put(`/groups/${groupId}`, { name, icon });
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 {
await api.post('/groups/', { name, member_ids: memberIds, icon });
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); }
};
}
// Radio list of the user's OWN local calendars to pick the one visible to
// group members (plus a "none" option). Selection is read on settings save.
function renderGroupVisibleList(selectedId) {
const el = document.getElementById('cfg-group-visible-list');
if (!el) return;
el.dataset.selected = (selectedId == null) ? '' : String(selectedId);
// A birthday calendar may be shared directly, but never stand in as the
// group-visible personal calendar.
const own = state.localCalendars.filter(c => c.owned !== false && !c.group && !c.is_birthday);
const selVal = el.dataset.selected;
const row = (id, name, color) => {
const val = (id == null) ? '' : String(id);
const sel = val === selVal;
const dot = color
? ` `
: ` `;
return `
${dot}
${escHtml(name)}
`;
};
el.innerHTML =
row(null, t('group_visible_none'), null) +
own.map(c => row(c.id, c.name, c.color)).join('');
el.querySelectorAll('.pick-row').forEach(r => {
r.addEventListener('click', () => {
el.dataset.selected = r.dataset.pick;
renderGroupVisibleList(r.dataset.pick === '' ? null : parseInt(r.dataset.pick));
});
});
}
// ── Settings Modal ────────────────────────────────────────
function openSettingsModal() {
uiSettingsOpen = true;
writeUrlState();
// Open the modal FIRST so a later error while populating a field (or in one of
// the render helpers below) can never prevent settings from opening at all.
openModal('modal-settings');
try {
populateSettings();
} catch (e) {
console.error('populateSettings failed', e);
}
}
function populateSettings() {
// Appearance / syncable settings render into the unified sync table.
renderSettingsTable();
// Account settings (kept as a dedicated profile block, not synced per-device).
const s = state.settings;
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
renderGroupVisibleList(s.group_visible_calendar_id);
// Profile chapter: name (from cached user) + email (fresh from /profile).
const pu = JSON.parse(localStorage.getItem('user') || '{}');
document.getElementById('cfg-display-name').value = pu.display_name || pu.username || '';
document.getElementById('cfg-login-name').value = pu.username || '';
api.get('/profile/').then(p => {
document.getElementById('cfg-email').value = p.email || '';
const dh = document.getElementById('cfg-directory-hidden');
if (dh) dh.checked = !!p.directory_hidden;
}).catch(() => {});
initAppPasswords();
initSSOIdentities();
// Show users nav button only for admins
const user = JSON.parse(localStorage.getItem('user') || '{}');
const usersNavBtn = document.getElementById('settings-nav-users');
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
if (user.is_admin) { loadUsers(); loadAdminPanel(); }
// Activate panel from URL or fall back to first visible
const urlTab = readUrlState().stab;
const tabBtn = urlTab && document.querySelector(`.settings-nav-btn[data-panel="${urlTab}"]:not(.hidden)`);
const firstBtn = tabBtn || document.querySelector('.settings-nav-btn:not(.hidden)');
if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel);
// Render unified calendar table
renderAllAccounts();
}
// ── Settings sync table ───────────────────────────────────
const SYNC_ICON_SVG = ' ';
// Same sync glyph with a diagonal slash — the clear "not synced" state.
const SYNC_OFF_ICON_SVG = ' ';
const RESET_ICON_SVG = ' ';
function normalizeHex(raw, fallback) {
let v = (raw || '').trim();
if (!v) return fallback;
if (!v.startsWith('#')) v = '#' + v;
return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : fallback;
}
function renderSettingsTable() {
const container = document.getElementById('settings-appearance-table');
if (!container) return;
container.innerHTML = SETTING_GROUPS.map(group =>
`${escHtml(t(group.titleKey))}
` +
group.rows.map(settingRowHtml).join('')
).join('');
SETTING_GROUPS.forEach(g => g.rows.forEach(wireSettingRow));
updateSyncAllToggle();
}
function settingRowHtml(def) {
const synced = !!state.syncFlags[def.key];
const val = state.settings[def.key];
let valueHtml = '';
if (def.type === 'select') {
valueHtml = `` + def.opts.map(o => {
const lbl = o.tk ? t(o.tk) : o.label;
const sel = String(o.v) === String(val) ? ' selected' : '';
return `${escHtml(lbl)} `;
}).join('') + ' ';
} else if (def.type === 'toggle') {
valueHtml = ` `;
} else if (def.type === 'color') {
const hex = normalizeHex(val, baseColor(def.key));
// No own value means the row inherits the admin instance default. The
// field still shows that colour, but saving must send null — writing the
// hex back would freeze today's admin default into the user's row and
// later admin changes would stop reaching them.
const inherits = val == null ? ' data-inherit="1"' : '';
valueHtml = ``;
} else if (def.type === 'icon') {
const cur = val || 'share';
valueHtml = `` + SHARE_ICON_KEYS.map(k =>
`${shareIconSvg(k, 18)} `
).join('') + '
';
}
return `
${SYNC_ICON_SVG} ${SYNC_OFF_ICON_SVG}
${escHtml(t(def.labelKey))}
${valueHtml}
`;
}
function wireSettingRow(def) {
const row = document.querySelector(`.settings-row[data-row="${def.key}"]`);
if (!row) return;
const syncBtn = row.querySelector('[data-sync]');
if (syncBtn) syncBtn.addEventListener('click', () => {
const on = !state.syncFlags[def.key];
state.syncFlags[def.key] = on;
syncBtn.classList.toggle('on', on);
syncBtn.setAttribute('aria-checked', String(on));
updateSyncAllToggle();
});
if (def.type === 'select') {
// Hour height affects the theme — preview it live; other selects apply on Save.
if (def.key === 'hour_height') {
const sel = row.querySelector('[data-skey]');
if (sel) sel.addEventListener('change', () => {
state.settings.hour_height = Number(sel.value);
applyTheme(state.settings);
});
}
} else if (def.type === 'toggle') {
const vt = row.querySelector('[data-vtoggle]');
if (vt) vt.addEventListener('click', () => {
const on = !vt.classList.contains('on');
vt.classList.toggle('on', on);
vt.setAttribute('aria-checked', String(on));
});
} else if (def.type === 'color') {
const hex = row.querySelector('[data-shex]');
const prev = row.querySelector('[data-sprev]');
const reset = row.querySelector('[data-sreset]');
const apply = (color) => {
prev.style.background = color;
state.settings[def.key] = color;
applyTheme(state.settings);
};
const own = () => { delete hex.dataset.inherit; };
prev.addEventListener('click', async () => {
const picked = await openColorPicker(prev, hex.value || baseColor(def.key));
if (picked) { hex.value = picked.toUpperCase(); own(); apply(picked); }
});
hex.addEventListener('input', () => {
const norm = normalizeHex(hex.value, null);
if (norm) { own(); apply(norm); }
});
hex.addEventListener('change', () => {
const norm = normalizeHex(hex.value, baseColor(def.key));
hex.value = norm; own(); apply(norm);
});
reset.addEventListener('click', () => {
// Back to inheriting, not "copy the current admin default in".
const d = baseColor(def.key);
hex.dataset.inherit = '1';
hex.value = d; apply(d);
});
} else if (def.type === 'icon') {
row.querySelectorAll('.group-icon-opt').forEach(btn => {
btn.addEventListener('click', () => {
row.querySelectorAll('.group-icon-opt').forEach(b => b.classList.remove('on'));
btn.classList.add('on');
});
});
}
}
// Read the current value of every syncable web setting from the table DOM.
function readAppearanceTable() {
const out = {};
const numKeys = new Set(['hour_height', 'default_event_duration_minutes']);
const boolKeys = new Set(['month_view_paged']);
SETTING_GROUPS.forEach(g => g.rows.forEach(def => {
if (def.type === 'select') {
const el = document.querySelector(`[data-skey="${def.key}"]`);
let v = el ? el.value : state.settings[def.key];
if (numKeys.has(def.key)) v = Number(v);
else if (boolKeys.has(def.key)) v = (String(v) === 'true');
out[def.key] = v;
} else if (def.type === 'toggle') {
const el = document.querySelector(`[data-vtoggle="${def.key}"]`);
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
} else if (def.type === 'color') {
const el = document.querySelector(`[data-shex="${def.key}"]`);
out[def.key] = (el && el.dataset.inherit === '1')
? null // inherit the admin default
: normalizeHex(el && el.value, baseColor(def.key));
} else if (def.type === 'icon') {
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
}
}));
return out;
}
function updateSyncAllToggle() {
const btn = document.getElementById('cfg-sync-all');
if (!btn) return;
const allOn = SYNCABLE_KEYS.every(k => state.syncFlags[k]);
btn.classList.toggle('on', allOn);
btn.setAttribute('aria-checked', String(allOn));
}
function toggleSyncAll() {
const next = !SYNCABLE_KEYS.every(k => state.syncFlags[k]);
SYNCABLE_KEYS.forEach(k => { state.syncFlags[k] = next; });
document.querySelectorAll('.settings-row .sync-icon[data-sync]').forEach(el => {
const on = !!state.syncFlags[el.dataset.sync];
el.classList.toggle('on', on);
el.setAttribute('aria-checked', String(on));
});
updateSyncAllToggle();
}
// Human-facing docs describing every theme parameter (linked from export files).
const THEME_DOCS_URL = 'https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md';
// Keys that are colours (validated on import).
function colorSettingKeys() {
const out = new Set();
SETTING_GROUPS.forEach(g => g.rows.forEach(r => { if (r.type === 'color') out.add(r.key); }));
return out;
}
// Export the current appearance (colours + display settings) as a .theme file
// named after the local date+time, with a link to the parameter docs.
function exportTheme() {
const now = new Date();
const p = (n) => String(n).padStart(2, '0');
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}_${p(now.getHours())}-${p(now.getMinutes())}`;
const payload = {
_format: 'calendarr-theme',
_version: 1,
_docs: THEME_DOCS_URL,
exported_at: now.toISOString(),
settings: readAppearanceTable(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${stamp}.theme.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// Read a chosen .theme file locally and apply its values. A theme may be
// partial (only some parameters) — only the keys present are written; omitted
// keys are left untouched (the server accepts partial updates). If the file
// contains parameters this version doesn't know, the user is asked whether to
// import the rest anyway.
async function importThemeFile(input) {
const file = input.files && input.files[0];
input.value = ''; // allow re-importing the same file later
if (!file) return;
let incoming;
try {
const data = JSON.parse(await file.text());
incoming = (data && typeof data === 'object' && data.settings && typeof data.settings === 'object')
? data.settings : data;
if (!incoming || typeof incoming !== 'object') throw new Error('bad');
} catch (e) {
showToast(t('theme_import_invalid'), true);
return;
}
const known = new Set(SYNCABLE_KEYS);
const unknownKeys = Object.keys(incoming).filter(k => !known.has(k));
if (unknownKeys.length) {
const ok = await confirmModal(
t('theme_unknown_params', { count: unknownKeys.length, names: unknownKeys.join(', ') }),
{ title: t('theme_unknown_title'), okLabel: t('theme_import_rest'), danger: false }
);
if (!ok) return; // user declined → import nothing
}
// Collect the known, valid parameters to apply.
const colorKeys = colorSettingKeys();
const toApply = {};
for (const key of SYNCABLE_KEYS) {
if (!(key in incoming)) continue;
let val = incoming[key];
if (colorKeys.has(key)) {
const norm = normalizeHex(val, null);
if (!norm) continue; // skip invalid colour values
val = norm;
}
toApply[key] = val;
}
const applied = Object.keys(toApply);
if (!applied.length) { showToast(t('theme_import_invalid'), true); return; }
// Persist ONLY the present keys (partial). Keys whose sync flag is on are sent
// to the server; the rest just update this browser's local copy.
const local = loadLocal();
const serverPayload = {};
for (const key of applied) {
state.settings[key] = toApply[key];
local[key] = toApply[key];
if (state.syncFlags[key]) serverPayload[key] = toApply[key];
}
saveLocal(local);
try {
if (Object.keys(serverPayload).length) await api.put('/settings/', serverPayload);
state.serverSettings = { ...state.serverSettings, ...serverPayload };
state.settings = mergeEffective(state.serverSettings, state.syncFlags, loadLocal());
renderSettingsTable();
applyTheme(state.settings);
applyFavicon(state.settings.primary_color);
setLang(state.settings.language);
renderCalendarList();
renderMiniCal();
fetchAndRender();
showToast(t('theme_imported_saved', { count: applied.length }));
} catch (e) {
showToast(e.message, true);
}
}
// Save profile identity fields (name/login/email/hidden) — server rotates the
// JWT if the login name changed.
async function saveProfileFields() {
const email = document.getElementById('cfg-email').value.trim();
const displayName = document.getElementById('cfg-display-name').value.trim();
const loginName = document.getElementById('cfg-login-name').value.trim();
const user = JSON.parse(localStorage.getItem('user') || '{}');
const body = { email: email || null };
if (displayName) body.display_name = displayName;
if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName;
const dh = document.getElementById('cfg-directory-hidden');
if (dh) body.directory_hidden = dh.checked;
const res = await api.put('/profile/', body);
if (res && res.access_token) localStorage.setItem('token', res.access_token);
const updated = { ...user };
if (displayName) updated.display_name = displayName;
if (body.username) updated.username = body.username.toLowerCase();
localStorage.setItem('user', JSON.stringify(updated));
const dd = document.getElementById('dropdown-username');
if (dd) dd.textContent = updated.display_name || updated.username || 'Benutzer';
}
function activateSettingsPanel(panel) {
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.toggle('active', b.dataset.panel === panel));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.toggle('active', p.id === 'settings-panel-' + panel));
writeUrlState();
}
function renderGoogleAccounts() {
const list = document.getElementById('google-accounts-list');
if (!list) return;
if (!state.googleAccounts.length) {
list.innerHTML = `${t('settings_no_google')} `;
return;
}
list.innerHTML = state.googleAccounts.map(acc =>
`
${escHtml(acc.email)}
${t('sync')}
${t('disconnect')}
`
).join('');
list.querySelectorAll('[data-sync-acc]').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = '…';
try {
const updated = await api.post(`/google/accounts/${btn.dataset.syncAcc}/sync`);
const idx = state.googleAccounts.findIndex(a => a.id === updated.id);
if (idx !== -1) state.googleAccounts[idx] = updated;
renderGoogleAccounts();
renderCalendarList();
fetchAndRender(true);
showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); }
});
});
list.querySelectorAll('[data-disconnect-acc]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!await confirmModal(t('confirm_google_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
try {
await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`);
state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc));
renderGoogleAccounts();
renderCalendarList();
fetchAndRender(true);
showToast(t('google_disconnected'));
} catch (e) { showToast(e.message, true); }
});
});
}
function renderAllAccounts() {
// CalDAV section
const caldavList = document.getElementById('accounts-caldav-list');
if (caldavList) {
if (!state.accounts.length) {
caldavList.innerHTML = `${t('settings_no_caldav_accounts')} `;
} else {
caldavList.innerHTML = state.accounts.map(acc =>
`
${escHtml(acc.name)}
${escHtml(acc.url || '')}
${t('sync')}
${t('disconnect')}
`
).join('');
caldavList.querySelectorAll('[data-caldav-sync]').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true; btn.textContent = '…';
try {
await api.post(`/caldav/accounts/${btn.dataset.caldavSync}/sync`);
renderCalendarList(); fetchAndRender(true);
showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); }
finally { btn.disabled = false; btn.textContent = t('sync'); }
});
});
caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!await confirmModal(t('confirm_caldav_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
try {
await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`);
state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect));
renderAllAccounts(); renderCalendarList(); fetchAndRender(true);
showToast(t('caldav_disconnected'));
} catch (e) { showToast(e.message, true); }
});
});
}
}
// Local calendars section
const localList = document.getElementById('accounts-local-list');
if (localList) {
if (!state.localCalendars.length) {
localList.innerHTML = `${t('settings_no_local_cals')} `;
} else {
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(`${t('share')} `);
if (canWrite) actions.push(`${t('import')} `);
actions.push(`${t('export')} `);
return `
${escHtml(cal.name)}
${sharedBadge}
${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)));
});
}
}
// iCal subscriptions section
const icalList = document.getElementById('accounts-ical-list');
if (icalList) {
if (!state.icalSubscriptions.length) {
icalList.innerHTML = `${t('settings_no_ical_subs')} `;
} else {
icalList.innerHTML = state.icalSubscriptions.map(sub =>
`
${escHtml(sub.name)}
${escHtml(sub.url || '')}
${t('delete')}
`
).join('');
icalList.querySelectorAll('[data-ical-delete]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!await confirmModal(t('confirm_remove_ical'), { title: t('confirm_remove_ical_title'), okLabel: t('delete') })) return;
try {
await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete));
renderAllAccounts(); renderCalendarList(); fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
});
});
}
}
// Home Assistant accounts section
const haList = document.getElementById('accounts-ha-list');
if (haList) {
if (!state.haAccounts.length) {
haList.innerHTML = 'Keine HA-Konten ';
} else {
haList.innerHTML = state.haAccounts.map(acc =>
`
${escHtml(acc.name)}
${escHtml(acc.url || '')}
${t('sync')}
${t('disconnect')}
`
).join('');
haList.querySelectorAll('[data-ha-sync]').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true; btn.textContent = '…';
try {
const updated = await api.post(`/homeassistant/accounts/${btn.dataset.haSync}/sync`);
const idx = state.haAccounts.findIndex(a => a.id === parseInt(btn.dataset.haSync));
if (idx !== -1) state.haAccounts[idx] = updated;
renderAllAccounts(); renderCalendarList(); fetchAndRender(true);
showToast('Home Assistant synchronisiert');
} catch (e) { showToast(e.message, true); }
finally { btn.disabled = false; btn.textContent = t('sync'); }
});
});
haList.querySelectorAll('[data-ha-disconnect]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!await confirmModal(t('confirm_ha_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
try {
await api.delete(`/homeassistant/accounts/${btn.dataset.haDisconnect}`);
state.haAccounts = state.haAccounts.filter(a => a.id !== parseInt(btn.dataset.haDisconnect));
renderAllAccounts(); renderCalendarList(); fetchAndRender(true);
showToast('Home Assistant getrennt');
} catch (e) { showToast(e.message, true); }
});
});
}
}
renderCalendarTable();
renderBirthdaySettings();
}
/** Settings › Kalender › Geburtstage: activate the single birthday calendar,
* or (when active) tweak its "notify N days before". Colour/visibility live in
* the sidebar like any other calendar. */
function renderBirthdaySettings() {
const container = document.getElementById('birthday-settings');
if (!container) return;
container.innerHTML = '';
const cal = birthdayCalendar();
if (!cal) {
// Creation lives in the add row ("+ Geburtstage") / sidebar dropdown now.
const hint = document.createElement('div');
hint.className = 'form-hint';
hint.textContent = t('birthday_settings_hint');
container.append(hint);
return;
}
const status = document.createElement('div');
status.className = 'form-hint';
status.textContent = t('birthday_active');
const notifyWrap = document.createElement('div');
notifyWrap.className = 'form-group';
const label = document.createElement('label');
label.textContent = t('birthday_notify');
const sel = document.createElement('select');
fillBirthdayNotifySelect(sel);
sel.value = String(cal.birthday_notify_days_before ?? -1);
sel.onchange = async () => {
const v = parseInt(sel.value, 10);
try {
await api.put(`/local/calendars/${cal.id}`, { birthday_notify_days_before: v });
cal.birthday_notify_days_before = v < 0 ? null : v;
} catch (e) { showToast(e.message, true); }
};
notifyWrap.append(label, sel);
const colorHint = document.createElement('div');
colorHint.className = 'form-hint';
colorHint.textContent = t('birthday_color_hint');
const devicesWrap = document.createElement('div');
devicesWrap.style.marginTop = '12px';
container.append(status, notifyWrap, colorHint, devicesWrap);
// Which devices contribute birthdays (from Contacts sync).
api.get('/birthdays/devices').then(devs => {
if (!Array.isArray(devs) || !devs.length) return;
const title = document.createElement('div');
title.className = 'panel-desc';
title.style.margin = '0 0 4px';
title.textContent = t('birthday_devices_title');
devicesWrap.appendChild(title);
devs.forEach(d => {
const rowEl = document.createElement('div');
rowEl.className = 'form-hint';
rowEl.style.margin = '0 0 2px';
const when = d.last_sync ? new Date(d.last_sync).toLocaleDateString(getLocale()) : '';
rowEl.textContent = `${d.device_name} — ${t('birthday_devices_count', { n: d.count })}${when ? ' · ' + when : ''}`;
devicesWrap.appendChild(rowEl);
});
}).catch(() => {});
}
function renderHiddenCalendars() {
const list = document.getElementById('hidden-cals-list');
if (!list) return;
const hidden = [];
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name, source: 'caldav' });
}
}
for (const acc of state.googleAccounts) {
for (const cal of acc.calendars) {
if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.email, source: 'google' });
}
}
for (const acc of state.haAccounts) {
for (const cal of acc.calendars) {
if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name, source: 'homeassistant' });
}
}
if (!hidden.length) {
list.innerHTML = `${t('settings_no_hidden_cals')} `;
return;
}
list.innerHTML = hidden.map(c =>
`
${escHtml(c.acc)} / ${escHtml(c.name)}
${t('show_cal')}
`
).join('');
list.querySelectorAll('[data-restore-cal]').forEach(btn => {
btn.addEventListener('click', async () => {
const calId = parseInt(btn.dataset.restoreCal);
const source = btn.dataset.restoreSource;
if (source === 'google') {
await api.put(`/google/calendars/${calId}`, { enabled: true, sidebar_hidden: false });
for (const acc of state.googleAccounts) {
for (const cal of acc.calendars) {
if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; }
}
}
} else if (source === 'homeassistant') {
await api.put(`/homeassistant/calendars/${calId}`, { enabled: true, sidebar_hidden: false });
for (const acc of state.haAccounts) {
for (const cal of acc.calendars) {
if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; }
}
}
} else {
await api.put(`/caldav/calendars/${calId}`, { enabled: true, sidebar_hidden: false });
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; }
}
}
}
renderHiddenCalendars();
renderCalendarList();
fetchAndRender();
});
});
}
/** Make the calendar-management table's columns drag-resizable. Widths persist
* in localStorage and are re-applied on every re-render. */
function initResizableCalTable(table) {
if (!table || !table.tHead) return;
const ths = Array.from(table.tHead.rows[0].cells);
let saved = null;
try { saved = JSON.parse(localStorage.getItem('calTableColWidths') || 'null'); } catch { saved = null; }
// Lock to px + fixed layout so dragging one column doesn't reflow the others.
// Sensible defaults per column (Name / Herkunft / Sichtbar / Benachrichtigungen
// / Export-Import / Sync / trash); user drags override and persist.
const DEFAULTS = [200, 90, 80, 150, 300, 90, 44];
ths.forEach((th, i) => {
const w = (Array.isArray(saved) && saved[i]) ? saved[i]
: (DEFAULTS[i] || th.offsetWidth || 100);
th.style.width = Math.max(50, w) + 'px';
});
table.style.tableLayout = 'fixed';
const sync = () => {
const total = ths.reduce((s, t) => s + (parseFloat(t.style.width) || 0), 0);
table.style.width = total + 'px';
};
sync();
ths.forEach((th, i) => {
if (i >= ths.length - 1) return; // no handle on the last (trash) column
const res = document.createElement('div');
res.className = 'col-resizer';
th.appendChild(res);
res.addEventListener('mousedown', e => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX, startW = th.offsetWidth;
document.body.style.cursor = 'col-resize';
const onMove = ev => {
th.style.width = Math.max(60, startW + (ev.clientX - startX)) + 'px';
sync();
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
try {
localStorage.setItem('calTableColWidths',
JSON.stringify(ths.map(t => Math.round(parseFloat(t.style.width) || 0))));
} catch {}
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
}
function renderCalendarTable() {
const container = document.getElementById('cal-settings-table');
if (!container) return;
const TRASH = ` `;
const EYE_ON = ` `;
const EYE_OFF = ` `;
const BELL_ON = ` `;
const BELL_OFF = ` `;
let rowCount = 0;
const hid = (src, id, isVisible) =>
`${isVisible ? EYE_ON : EYE_OFF} `;
const rem = (src, id, on) =>
`${on ? BELL_ON : BELL_OFF} `;
const dot = (color, fb) =>
` `;
let rows = '';
// Local calendars I own. Calendars shared WITH me (owned === false) are shown
// in a separate "shared with me" section further down.
for (const cal of state.localCalendars) {
if (cal.group || cal.owned === false) continue;
const pub = !!cal.caldav_published;
rows += `
${dot(cal.color, '#34a853')}${escHtml(cal.name)}
Lokal
${hid('local', cal.id, !cal.sidebar_hidden)}
${rem('local', cal.id, cal.reminders_enabled !== false)}
${t('share')}
Export
Import
CalDAV
—
${TRASH}
`;
rowCount++;
if (cal.owned !== false && pub) {
rows += `
${t('caldav_published_url')}
${t('copy')}
${t('caldav_token_rotate')}
${t('caldav_hint')}
${t('caldav_login_url')}
${t('copy')}
${t('caldav_login_hint')}
`;
}
}
// iCal subscriptions
for (const sub of state.icalSubscriptions) {
rows += `
${dot(sub.color, '#aa66cc')}${escHtml(sub.name)}
iCal
${hid('ical', sub.id, !sub.sidebar_hidden)}
${rem('ical', sub.id, sub.reminders_enabled !== false)}
—
—
${TRASH}
`;
rowCount++;
}
// CalDAV accounts + their calendars
for (const acc of state.accounts) {
rows += `
${escHtml(acc.name)} CalDAV
— — —
${t('sync')}
${t('disconnect')}
`;
rowCount++;
for (const cal of (acc.calendars || [])) {
rows += `
${dot(cal.color, '#4285f4')}${escHtml(cal.name)}
${escHtml(acc.name)}
${hid('caldav', cal.id, !cal.sidebar_hidden)}
${rem('caldav', cal.id, cal.reminders_enabled !== false)}
— — —
`;
rowCount++;
}
}
// Google accounts + their calendars
for (const acc of state.googleAccounts) {
rows += `
${escHtml(acc.email)} Google
— — —
${t('sync')}
${t('disconnect')}
`;
rowCount++;
for (const cal of (acc.calendars || [])) {
rows += `
${dot(cal.color, '#4285f4')}${escHtml(cal.name)}
${escHtml(acc.email)}
${hid('google', cal.id, !cal.sidebar_hidden)}
${rem('google', cal.id, cal.reminders_enabled !== false)}
— — —
`;
rowCount++;
}
}
// Home Assistant accounts + their calendars
for (const acc of state.haAccounts) {
rows += `
${escHtml(acc.name)} Home Assistant
— — —
${t('sync')}
${t('disconnect')}
`;
rowCount++;
for (const cal of (acc.calendars || [])) {
rows += `
${dot(cal.color, '#03a9f4')}${escHtml(cal.name)}
${escHtml(acc.name)}
${hid('ha', cal.id, !cal.sidebar_hidden)}
${rem('ha', cal.id, cal.reminders_enabled !== false)}
— — —
`;
rowCount++;
}
}
// Calendars shared WITH me (direct shares + group-shared) — read-only, no
// management actions since I don't own them. Shown under the owner's name.
const sharedWithMe = state.localCalendars.filter(c => c.owned === false && !c.group);
if (sharedWithMe.length) {
const sharedHidden = loadSidebarHiddenShared();
rows += `${t('shared_with_me')} `;
for (const cal of sharedWithMe) {
const readOnly = cal.permission !== 'read_write';
rows += `
${dot(cal.color, '#34a853')}${escHtml(cal.shared_by || cal.name)}
${escHtml(cal.name)} ${readOnly ? t('perm_read') : t('perm_read_write')}
${hid('shared', cal.id, !sharedHidden.has(cal.id))}
${rem('local', cal.id, cal.reminders_enabled !== false)} — — —
`;
rowCount++;
}
}
if (!rowCount) {
rows = `Keine Kalender vorhanden `;
}
container.innerHTML = `
Name Herkunft Sichtbar ${t('calendar_reminders_col')} Export / Import Sync
${rows}
`;
initResizableCalTable(container.querySelector('.cal-manage-table'));
// Visibility eye toggle
container.querySelectorAll('.ct-eye[data-ct-hid]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctHid, id = parseInt(btn.dataset.ctId);
const hidden = btn.dataset.ctVisible === '1'; // currently visible → we're hiding it
try {
if (src === 'shared') {
// Shared-with-me calendar: per-device sidebar hide (not owned).
setSidebarHiddenShared(id, hidden);
} else if (src === 'ical') {
await api.put(`/ical/subscriptions/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
const s = state.icalSubscriptions.find(s => s.id === id);
if (s) { s.sidebar_hidden = hidden; s.enabled = !hidden; }
} else if (src === 'local') {
await api.put(`/local/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
const c = state.localCalendars.find(c => c.id === id);
if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; }
} else if (src === 'caldav') {
await api.put(`/caldav/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } }
} else if (src === 'google') {
await api.put(`/google/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
for (const acc of state.googleAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } }
} else if (src === 'ha') {
await api.put(`/homeassistant/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
for (const acc of state.haAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } }
}
renderCalendarTable();
renderCalendarList();
} catch (e) { showToast(e.message, true); }
});
});
// Reminders bell toggle
container.querySelectorAll('.ct-bell[data-ct-rem]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctRem, id = parseInt(btn.dataset.ctId);
const enabled = btn.dataset.ctOn !== '1'; // currently on → turn off, currently off → turn on
const path = src === 'local' ? `/local/calendars/${id}/reminders`
: src === 'ical' ? `/ical/subscriptions/${id}`
: `/${src === 'ha' ? 'homeassistant' : src}/calendars/${id}`;
try {
await api.put(path, { reminders_enabled: enabled });
if (src === 'local') { const c = state.localCalendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; }
else if (src === 'ical') { const s = state.icalSubscriptions.find(s => s.id === id); if (s) s.reminders_enabled = enabled; }
else if (src === 'caldav') { for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } }
else if (src === 'google') { for (const acc of state.googleAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } }
else if (src === 'ha') { for (const acc of state.haAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } }
renderCalendarTable();
renderCalendarList();
} catch (e) { showToast(e.message, true); }
});
});
// Sync buttons
container.querySelectorAll('[data-ct-sync]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctSync, id = parseInt(btn.dataset.ctId);
const label = btn.textContent;
btn.disabled = true; btn.textContent = '…';
try {
if (src === 'caldav') {
await api.post(`/caldav/accounts/${id}/sync`);
} else if (src === 'google') {
const updated = await api.post(`/google/accounts/${id}/sync`);
const idx = state.googleAccounts.findIndex(a => a.id === updated.id);
if (idx !== -1) state.googleAccounts[idx] = updated;
} else if (src === 'ha') {
const updated = await api.post(`/homeassistant/accounts/${id}/sync`);
const idx = state.haAccounts.findIndex(a => a.id === updated.id);
if (idx !== -1) state.haAccounts[idx] = updated;
}
renderCalendarTable(); renderCalendarList(); fetchAndRender(true);
showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); }
finally { btn.disabled = false; btn.textContent = label; }
});
});
// Disconnect buttons
container.querySelectorAll('[data-ct-disc]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctDisc, id = parseInt(btn.dataset.ctId);
const msg = src === 'caldav' ? t('confirm_caldav_disconnect')
: src === 'google' ? t('confirm_google_disconnect')
: t('confirm_ha_disconnect');
if (!await confirmModal(msg, { title: t('disconnect'), okLabel: t('disconnect') })) return;
try {
if (src === 'caldav') {
await api.delete(`/caldav/accounts/${id}`);
state.accounts = state.accounts.filter(a => a.id !== id);
} else if (src === 'google') {
await api.delete(`/google/accounts/${id}`);
state.googleAccounts = state.googleAccounts.filter(a => a.id !== id);
} else if (src === 'ha') {
await api.delete(`/homeassistant/accounts/${id}`);
state.haAccounts = state.haAccounts.filter(a => a.id !== id);
}
renderCalendarTable(); renderCalendarList(); fetchAndRender(true);
showToast(t('caldav_disconnected'));
} catch (e) { showToast(e.message, true); }
});
});
// Export / Import
container.querySelectorAll('[data-ct-export]').forEach(btn => {
btn.addEventListener('click', async () => {
try { await api.download(`/local/calendars/${btn.dataset.ctId}/export`, `${btn.dataset.ctName || 'calendar'}.ics`); }
catch (e) { showToast(e.message, true); }
});
});
container.querySelectorAll('[data-ct-import]').forEach(btn => {
btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId)));
});
// Share a calendar with specific users (person-to-person)
container.querySelectorAll('[data-ct-share]').forEach(btn => {
btn.addEventListener('click', () => openShareModal(parseInt(btn.dataset.ctShare)));
});
// CalDAV publishing
container.querySelectorAll('.ct-dav-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.ctDavId);
const newVal = btn.dataset.ctDavOn !== '1';
const cal = state.localCalendars.find(c => c.id === id);
if (!cal) return;
try {
const updated = await api.put(`/local/calendars/${id}`, { caldav_published: newVal });
cal.caldav_published = updated.caldav_published;
cal.caldav_url = updated.caldav_url;
renderCalendarTable();
} catch (e) { showToast(e.message, true); }
});
});
container.querySelectorAll('.ct-dav-copy').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.parentElement.querySelector('.ct-dav-url');
if (!input || !input.value) return;
navigator.clipboard.writeText(input.value).then(() => showToast(t('caldav_url_copied')));
});
});
container.querySelectorAll('.ct-dav-rotate').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.ctDavId);
const cal = state.localCalendars.find(c => c.id === id);
if (!cal || !confirm(t('caldav_rotate_confirm'))) return;
try {
const updated = await api.post(`/local/calendars/${id}/dav-token/rotate`);
cal.caldav_url = updated.caldav_url;
renderCalendarTable();
showToast(t('caldav_token_rotated'));
} catch (e) { showToast(e.message, true); }
});
});
// Delete
container.querySelectorAll('[data-ct-del]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctDel, id = parseInt(btn.dataset.ctId);
const msg = src === 'local' ? t('confirm_delete_local_cal') : t('confirm_remove_ical');
const title = src === 'local' ? t('confirm_delete_cal_title') : t('confirm_remove_ical_title');
if (!await confirmModal(msg, { title, okLabel: t('delete') })) return;
try {
if (src === 'local') {
await api.delete(`/local/calendars/${id}`);
state.localCalendars = state.localCalendars.filter(c => c.id !== id);
} else {
await api.delete(`/ical/subscriptions/${id}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== id);
}
renderCalendarTable(); renderCalendarList(); fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
});
});
}
async function loadUsers() {
try {
const users = await api.get('/users/');
const list = document.getElementById('users-list');
const selfId = JSON.parse(localStorage.getItem('user') || '{}').id;
list.innerHTML = users.map(u => {
const isSelf = u.id === selfId;
return `
${escHtml(u.username)}
${u.email ? `
${escHtml(u.email)}
` : ''}
${u.is_admin ? 'Admin ' : ''}
${isSelf ? '' :
`${u.is_admin ? t('users_revoke_admin') : t('users_make_admin')} `}
${isSelf ? '' :
`${t('delete')} `}
`;
}).join('');
list.querySelectorAll('[data-admin-user]').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await api.put(`/users/${btn.dataset.adminUser}/admin`, { is_admin: btn.dataset.adminNext === '1' });
loadUsers();
} catch (e) { showToast(e.message, true); }
});
});
list.querySelectorAll('[data-del-user]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(t('confirm_delete_user'))) return;
try {
await api.delete(`/users/${btn.dataset.delUser}`);
loadUsers();
} catch (e) { showToast(e.message, true); }
});
});
} catch (e) { /* not admin */ }
}
// ── Admin: instance default theme + branding ──────────────
let adminThemeDraft = {}; // {colorKey: hex} being edited (instance default)
let adminPreviewActive = false; // whether the app view currently shows the draft
// The colour rows offered in the default-theme editor (same keys as the user table).
function adminColorDefs() {
return SETTING_GROUPS.flatMap(g => g.rows).filter(r => r.type === 'color');
}
// Restore the admin's own (personal) theme after a live preview.
function restoreOwnTheme() {
adminPreviewActive = false;
applyTheme(state.settings);
applyFavicon(state.settings.primary_color);
}
function previewAdminTheme() {
adminPreviewActive = true;
applyTheme(adminThemeDraft);
applyFavicon(adminThemeDraft.primary_color);
}
function renderAdminThemeGrid() {
const grid = document.getElementById('admin-theme-grid');
if (!grid) return;
grid.innerHTML = adminColorDefs().map(def => {
const val = adminThemeDraft[def.key] || DEFAULT_COLORS[def.key];
return `
${escHtml(t(def.labelKey))}
`;
}).join('');
adminColorDefs().forEach(def => {
const row = grid.querySelector(`.admin-theme-row[data-arow="${def.key}"]`);
if (!row) return;
const hex = row.querySelector('[data-ahex]');
const prev = row.querySelector('[data-aprev]');
const reset = row.querySelector('[data-areset]');
const apply = (color) => {
adminThemeDraft[def.key] = color;
prev.style.background = color;
previewAdminTheme();
};
prev.addEventListener('click', async () => {
const picked = await openColorPicker(prev, hex.value || DEFAULT_COLORS[def.key]);
if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
});
hex.addEventListener('input', () => { const n = normalizeHex(hex.value, null); if (n) apply(n); });
hex.addEventListener('change', () => { const n = normalizeHex(hex.value, DEFAULT_COLORS[def.key]); hex.value = n; apply(n); });
reset.addEventListener('click', () => {
// Reset a default-theme colour to the built-in default.
const d = DEFAULT_COLORS[def.key];
hex.value = d; apply(d);
});
});
}
function renderAdminBranding() {
const logoPrev = document.getElementById('admin-logo-preview');
const favPrev = document.getElementById('admin-favicon-preview');
if (logoPrev) logoPrev.innerHTML = instanceConfig.has_logo ? ` ` : '';
if (favPrev) favPrev.innerHTML = instanceConfig.has_favicon ? ` ` : '';
const logoRemove = document.getElementById('admin-logo-remove');
const favRemove = document.getElementById('admin-favicon-remove');
if (logoRemove) logoRemove.classList.toggle('hidden', !instanceConfig.has_logo);
if (favRemove) favRemove.classList.toggle('hidden', !instanceConfig.has_favicon);
}
// Populate the admin panel (default-theme editor + branding). Admin-only.
function loadAdminPanel() {
adminThemeDraft = { ...(instanceConfig.default_theme || {}) };
renderAdminThemeGrid();
renderAdminBranding();
}
// Import a .theme.json into the default-theme editor (colours only). Fills the
// draft + live preview; the admin still clicks "Standard speichern" to persist.
async function importAdminTheme(input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
let incoming;
try {
const data = JSON.parse(await file.text());
incoming = (data && data.settings && typeof data.settings === 'object') ? data.settings : data;
if (!incoming || typeof incoming !== 'object') throw new Error('bad');
} catch (e) { showToast(t('theme_import_invalid'), true); return; }
const colorKeys = colorSettingKeys();
let applied = 0;
for (const key of colorKeys) {
if (!(key in incoming)) continue;
const norm = normalizeHex(incoming[key], null);
if (!norm) continue;
adminThemeDraft[key] = norm;
applied++;
}
if (!applied) { showToast(t('theme_import_invalid'), true); return; }
renderAdminThemeGrid();
previewAdminTheme();
showToast(t('admin_theme_imported', { count: applied }));
}
async function uploadBranding(kind, input) {
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
try {
const form = new FormData();
form.append('file', file);
await api.upload(`/instance/${kind}`, form);
await reloadInstance(); // re-fetch + apply logo/favicon live
renderAdminBranding();
showToast(t('admin_branding_saved'));
} catch (e) { showToast(e.message, true); }
}
async function removeBranding(kind) {
try {
await api.delete(`/instance/${kind}`);
await reloadInstance();
renderAdminBranding();
showToast(t('admin_branding_removed'));
} catch (e) { showToast(e.message, true); }
}
function bindAdminPanel() {
const save = document.getElementById('admin-theme-save');
if (save) save.addEventListener('click', async () => {
try {
await api.put('/instance/theme', { default_theme: adminThemeDraft });
setInstanceDefaults(adminThemeDraft);
instanceConfig.default_theme = { ...adminThemeDraft };
restoreOwnTheme(); // back to the admin's own view
showToast(t('admin_theme_saved'));
} catch (e) { showToast(e.message, true); }
});
const discard = document.getElementById('admin-theme-discard');
if (discard) discard.addEventListener('click', () => {
adminThemeDraft = { ...(instanceConfig.default_theme || {}) };
renderAdminThemeGrid();
restoreOwnTheme();
});
const themeImport = document.getElementById('admin-theme-import');
const themeFile = document.getElementById('admin-theme-file');
if (themeImport && themeFile) {
themeImport.addEventListener('click', () => themeFile.click());
themeFile.addEventListener('change', () => importAdminTheme(themeFile));
}
const bindUpload = (btnId, fileId, kind) => {
const btn = document.getElementById(btnId);
const file = document.getElementById(fileId);
if (btn && file) {
btn.addEventListener('click', () => file.click());
file.addEventListener('change', () => uploadBranding(kind, file));
}
};
bindUpload('admin-logo-upload', 'admin-logo-file', 'logo');
bindUpload('admin-favicon-upload', 'admin-favicon-file', 'favicon');
const logoRemove = document.getElementById('admin-logo-remove');
if (logoRemove) logoRemove.addEventListener('click', () => removeBranding('logo'));
const favRemove = document.getElementById('admin-favicon-remove');
if (favRemove) favRemove.addEventListener('click', () => removeBranding('favicon'));
}
function bindSettingsModal() {
// Global "sync everything" master toggle (per-row toggles are wired per render).
const syncAllBtn = document.getElementById('cfg-sync-all');
if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll);
// Admin panel (default-theme editor + branding) — buttons bound once.
bindAdminPanel();
// Theme export / import (client-side .theme files).
const themeExportBtn = document.getElementById('cfg-theme-export');
if (themeExportBtn) themeExportBtn.addEventListener('click', exportTheme);
const themeImportBtn = document.getElementById('cfg-theme-import');
const themeFileInput = document.getElementById('cfg-theme-file');
if (themeImportBtn && themeFileInput) {
themeImportBtn.addEventListener('click', () => themeFileInput.click());
themeFileInput.addEventListener('change', () => importThemeFile(themeFileInput));
}
// Panel navigation
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel));
});
document.getElementById('btn-add-user').onclick = () => {
document.getElementById('add-user-form').classList.toggle('hidden');
};
document.getElementById('new-user-save').onclick = async () => {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const is_admin = document.getElementById('new-is-admin').checked;
if (!username || !password) { showToast(t('error_username_password'), true); return; }
try {
await api.post('/users/', { username, password, is_admin });
showToast(t("user_created", {name: username}));
document.getElementById('add-user-form').classList.add('hidden');
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
loadUsers();
} catch (e) { showToast(e.message, true); }
};
// Unified save: appearance sync table + sync flags + account settings + profile.
document.getElementById('settings-save').onclick = async () => {
try {
const appearance = readAppearanceTable();
// Persist every syncable value into this browser's local copy so that
// "not synced" keys survive per-device.
const local = loadLocal();
for (const k of SYNCABLE_KEYS) local[k] = appearance[k];
saveLocal(local);
// Account settings (kept out of the sync table — always account-wide).
const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
const payload = {
sync_flags: state.syncFlags,
private_event_visibility: document.getElementById('cfg-private-visibility').value,
group_visible_calendar_id: gvVal ? parseInt(gvVal) : null,
};
// Push only the values whose sync flag is on.
for (const k of SYNCABLE_KEYS) {
if (state.syncFlags[k]) payload[k] = appearance[k];
}
await api.put('/settings/', payload);
await saveProfileFields();
// Recompute effective settings: server holds the synced values, unsynced
// keys resolve from the freshly-saved local copy.
state.serverSettings = { ...state.serverSettings, ...payload };
state.settings = mergeEffective(state.serverSettings, state.syncFlags, loadLocal());
state.dimPast = state.settings.dim_past_events;
weekStartDay = state.settings.week_start_day;
setLang(appearance.language);
applyTheme(state.settings);
applyFavicon(state.settings.primary_color);
showToast(t('settings_saved'));
closeModal('modal-settings');
renderCalendarList();
renderMiniCal();
fetchAndRender();
} catch (e) { showToast(e.message, true); }
};
// Add-account buttons in calendar panel
const addBtnMap = {
'settings-btn-add-local': openLocalCalModal,
'settings-btn-add-caldav': openAccountModal,
'settings-btn-add-ical': openICalSubModal,
'settings-btn-add-ha': openHAAccountModal,
'settings-btn-add-google': async () => {
const url = await api.get('/google/auth/url');
if (url && url.auth_url) window.open(url.auth_url, '_blank');
},
};
Object.entries(addBtnMap).forEach(([id, fn]) => {
const btn = document.getElementById(id);
if (btn) btn.addEventListener('click', () => { closeModal('modal-settings'); fn(); });
});
// Birthday calendar is created inline (no modal) — keep settings open so the
// new calendar + its options show up immediately.
const bdayBtn = document.getElementById('settings-btn-add-birthday');
if (bdayBtn) bdayBtn.addEventListener('click', () => addBirthdayCalendar());
}
// ── Profile Modal ─────────────────────────────────────────
export function openProfileModal() {
const user = JSON.parse(localStorage.getItem('user') || '{}');
// Names & email
document.getElementById('profile-username').value = user.username || '';
document.getElementById('profile-display-name-input').value = user.display_name || user.username || '';
document.getElementById('profile-display-name').textContent = user.display_name || user.username || '';
// Load fresh profile data
api.get('/profile/').then(profile => {
document.getElementById('profile-email').value = profile.email || '';
// Avatar
const letter = document.getElementById('profile-avatar-letter');
const img = document.getElementById('profile-avatar-img');
const removeBtn = document.getElementById('profile-avatar-remove');
if (profile.has_avatar) {
fetchAvatarBlob().then(blobUrl => {
img.src = blobUrl;
img.classList.remove('hidden');
letter.classList.add('hidden');
}).catch(() => {
img.classList.add('hidden');
letter.classList.remove('hidden');
letter.textContent = (user.username || '?')[0].toUpperCase();
});
removeBtn.classList.remove('hidden');
} else {
img.classList.add('hidden');
letter.classList.remove('hidden');
letter.textContent = (user.username || '?')[0].toUpperCase();
removeBtn.classList.add('hidden');
}
// 2FA status
document.getElementById('2fa-disabled-section').classList.toggle('hidden', profile.totp_enabled);
document.getElementById('2fa-setup-section').classList.add('hidden');
document.getElementById('2fa-enabled-section').classList.toggle('hidden', !profile.totp_enabled);
}).catch(() => {});
// Clear password fields
document.getElementById('profile-pw-current').value = '';
document.getElementById('profile-pw-new').value = '';
document.getElementById('profile-pw-confirm').value = '';
document.getElementById('2fa-disable-pw').value = '';
document.getElementById('2fa-verify-code').value = '';
// Load calendars
renderProfileCalendars();
openModal('modal-profile');
}
function renderProfileCalendars() {
const container = document.getElementById('profile-calendars');
if (!state.accounts.length) {
container.innerHTML = `${t('no_caldav')}
`;
return;
}
const html = state.accounts.map(acc =>
acc.calendars.map(cal =>
`
${escHtml(cal.name)}
${escHtml(acc.name)}
`
).join('')
).join('');
container.innerHTML = html;
}
function bindProfileModal() {
// Save profile info (display name, login name, email)
document.getElementById('profile-save-info').onclick = async () => {
const email = document.getElementById('profile-email').value.trim();
const displayName = document.getElementById('profile-display-name-input').value.trim();
const loginName = document.getElementById('profile-username').value.trim();
const user = JSON.parse(localStorage.getItem('user') || '{}');
const body = { email: email || null };
if (displayName) body.display_name = displayName;
if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName;
try {
const res = await api.put('/profile/', body);
// A login-name change returns a fresh token (the old one is now invalid).
if (res && res.access_token) localStorage.setItem('token', res.access_token);
// Keep the cached user (menu, "created by", etc.) in sync.
const updated = { ...user };
if (displayName) updated.display_name = displayName;
if (body.username) updated.username = body.username.toLowerCase();
localStorage.setItem('user', JSON.stringify(updated));
const dd = document.getElementById('dropdown-username');
if (dd) dd.textContent = updated.display_name || updated.username || 'Benutzer';
showToast(t('profile_saved'));
} catch (e) { showToast(e.message, true); }
};
// Avatar upload
document.getElementById('profile-avatar-upload').onclick = () => {
document.getElementById('profile-avatar-input').click();
};
document.getElementById('profile-avatar-input').onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
e.target.value = '';
openCropModal(file);
};
document.getElementById('profile-avatar-remove').onclick = async () => {
try {
await api.delete('/profile/avatar');
showToast(t('avatar_removed'));
document.getElementById('profile-avatar-img').classList.add('hidden');
document.getElementById('profile-avatar-letter').classList.remove('hidden');
document.getElementById('profile-avatar-remove').classList.add('hidden');
updateTopbarAvatar(false);
} catch (e) { showToast(e.message, true); }
};
// Password change
document.getElementById('profile-pw-save').onclick = async () => {
const current = document.getElementById('profile-pw-current').value;
const newPw = document.getElementById('profile-pw-new').value;
const confirm = document.getElementById('profile-pw-confirm').value;
if (!current || !newPw) { showToast(t('error_fill_all'), true); return; }
if (newPw !== confirm) { showToast(t('error_pw_mismatch'), true); return; }
if (newPw.length < 6) { showToast(t('error_pw_length'), true); return; }
try {
await api.post('/profile/password', { current_password: current, new_password: newPw });
showToast(t('password_changed'));
document.getElementById('profile-pw-current').value = '';
document.getElementById('profile-pw-new').value = '';
document.getElementById('profile-pw-confirm').value = '';
} catch (e) { showToast(e.message, true); }
};
// 2FA Setup
document.getElementById('2fa-setup-btn').onclick = async () => {
try {
const res = await api.post('/profile/2fa/setup', {});
document.getElementById('2fa-qr-img').src = res.qr_code;
document.getElementById('2fa-secret-code').textContent = res.secret;
document.getElementById('2fa-disabled-section').classList.add('hidden');
document.getElementById('2fa-setup-section').classList.remove('hidden');
} catch (e) { showToast(e.message, true); }
};
document.getElementById('2fa-copy-secret').onclick = () => {
const secret = document.getElementById('2fa-secret-code').textContent;
navigator.clipboard.writeText(secret).then(() => showToast(t('key_copied')));
};
document.getElementById('2fa-enable-btn').onclick = async () => {
const code = document.getElementById('2fa-verify-code').value.trim();
if (!code || code.length !== 6) { showToast(t('error_enter_6digit'), true); return; }
try {
await api.post('/profile/2fa/enable', { code });
showToast(t('totp_enabled'));
document.getElementById('2fa-setup-section').classList.add('hidden');
document.getElementById('2fa-enabled-section').classList.remove('hidden');
} catch (e) { showToast(e.message, true); }
};
document.getElementById('2fa-cancel-btn').onclick = () => {
document.getElementById('2fa-setup-section').classList.add('hidden');
document.getElementById('2fa-disabled-section').classList.remove('hidden');
};
document.getElementById('2fa-disable-btn').onclick = async () => {
const pw = document.getElementById('2fa-disable-pw').value;
if (!pw) { showToast(t('error_enter_password'), true); return; }
try {
await api.post('/profile/2fa/disable', { password: pw });
showToast(t('totp_disabled'));
document.getElementById('2fa-enabled-section').classList.add('hidden');
document.getElementById('2fa-disabled-section').classList.remove('hidden');
document.getElementById('2fa-disable-pw').value = '';
} catch (e) { showToast(e.message, true); }
};
}
// ── Linked SSO accounts — lives in Settings → Profile ──
async function initSSOIdentities() {
const section = document.getElementById('sso-section');
const list = document.getElementById('sso-identity-list');
if (!section || !list) return;
let data;
try {
data = await api.oidcIdentities();
} catch (e) {
section.classList.add('hidden');
return;
}
if (!data) return;
const identities = data.identities || [];
// An SSO-provisioned account cannot fall back to a password, so its last
// link must stay — otherwise the user locks themselves out for good.
const isSSOOnly = data.auth_source === 'sso';
if (!identities.length) {
// Nothing linked: only worth showing if SSO is available at all.
let enabled = false;
try {
const cfg = await api.oidcProviders();
enabled = !!(cfg && cfg.enabled);
} catch (e) { /* treat as unavailable */ }
if (!enabled) { section.classList.add('hidden'); return; }
list.innerHTML = `${t('sso_none_linked')}
`;
section.classList.remove('hidden');
return;
}
list.innerHTML = identities.map(i => `
${escHtml(i.name || i.provider)}
${i.email ? escHtml(i.email) + ' · ' : ''}${
i.last_login_at
? t('sso_last_login') + ' ' + new Date(i.last_login_at).toLocaleDateString(getLocale())
: t('sso_never_used')}
${t('sso_unlink')}
`).join('');
section.classList.remove('hidden');
list.querySelectorAll('.sso-unlink').forEach(btn => {
btn.onclick = async () => {
try {
await api.oidcUnlink(btn.dataset.id);
showToast(t('sso_unlinked'));
initSSOIdentities();
} catch (e) {
showToast(e.message === 'oidc_last_identity' ? t('sso_last_identity_hint') : e.message, true);
}
};
});
}
// ── App passwords (CalDAV) — lives in Settings → Profile ──
function initAppPasswords() {
const appPwList = document.getElementById('app-pw-list');
if (!appPwList) return;
const newBox = document.getElementById('app-pw-new');
newBox.classList.add('hidden');
document.getElementById('app-pw-new-value').textContent = '';
const load = async () => {
try {
const rows = await api.get('/profile/app-passwords');
appPwList.innerHTML = rows.length
? rows.map(r => `
${escHtml(r.label)}
${r.last_used_at ? t('app_pw_last_used') + ' ' + new Date(r.last_used_at).toLocaleDateString(getLocale()) : t('app_pw_never_used')}
${t('app_pw_revoke')}
`).join('')
: `${t('app_pw_none')}
`;
} catch (e) { /* ignore */ }
};
load();
document.getElementById('app-pw-create-btn').onclick = async () => {
const label = document.getElementById('app-pw-label').value.trim() || 'CalDAV';
try {
const res = await api.post('/profile/app-passwords', { label });
document.getElementById('app-pw-new-value').textContent = res.password;
newBox.classList.remove('hidden');
document.getElementById('app-pw-label').value = '';
load();
} catch (e) { showToast(e.message, true); }
};
document.getElementById('app-pw-copy').onclick = () => {
const v = document.getElementById('app-pw-new-value').textContent;
if (v) navigator.clipboard.writeText(v).then(() => showToast(t('app_pw_copied')));
};
appPwList.onclick = async (e) => {
const btn = e.target.closest('.app-pw-del');
if (!btn) return;
if (!confirm(t('app_pw_revoke_confirm'))) return;
try {
await api.delete(`/profile/app-passwords/${btn.dataset.id}`);
load();
} catch (err) { showToast(err.message, true); }
};
}
function updateTopbarAvatar(hasAvatar) {
const avatar = document.getElementById('user-avatar');
if (hasAvatar) {
fetchAvatarBlob().then(blobUrl => {
const img = new Image();
img.onload = () => {
avatar.innerHTML = '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0';
avatar.appendChild(img);
};
img.src = blobUrl;
}).catch(() => {
avatar.innerHTML = '';
const u = JSON.parse(localStorage.getItem('user')||'{}');
avatar.textContent = (u.username||'?')[0].toUpperCase();
});
// Update localStorage so avatar persists across reloads
const u = JSON.parse(localStorage.getItem('user')||'{}');
u.has_avatar = true;
localStorage.setItem('user', JSON.stringify(u));
} else {
avatar.innerHTML = '';
const user = JSON.parse(localStorage.getItem('user') || '{}');
avatar.textContent = (user.username || '?')[0].toUpperCase();
user.has_avatar = false;
localStorage.setItem('user', JSON.stringify(user));
}
}
// ── Calendar Color Picker ─────────────────────────────────
async function openCalColorPicker(anchor, calId) {
// Find current color of the calendar
let currentColor = '#4285f4';
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.id === calId && cal.color) currentColor = cal.color;
}
}
const picked = await openColorPicker(anchor, currentColor);
if (!picked) return;
await api.put(`/caldav/calendars/${calId}`, { color: picked });
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.id === calId) cal.color = picked;
}
}
applyCalendarColor('caldav', calId, picked);
}
// ── Avatar Crop ──────────────────────────────────────────
let activeCropper = null;
function openCropModal(file) {
const reader = new FileReader();
reader.onload = (e) => {
const cropImg = document.getElementById('crop-image');
// Destroy previous cropper if any
if (activeCropper) { activeCropper.destroy(); activeCropper = null; }
// Reset image src first to force reload
cropImg.removeAttribute('src');
openModal('modal-crop');
// Use requestAnimationFrame to ensure modal is visible before initializing cropper
requestAnimationFrame(() => {
cropImg.onload = () => {
// Small delay to ensure the image is fully rendered in the DOM
setTimeout(() => {
if (activeCropper) { activeCropper.destroy(); activeCropper = null; }
activeCropper = new Cropper(cropImg, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 1,
cropBoxResizable: true,
cropBoxMovable: true,
background: false,
});
}, 100);
};
cropImg.src = e.target.result;
});
};
reader.readAsDataURL(file);
}
document.getElementById('crop-save').onclick = async () => {
if (!activeCropper) return;
const canvas = activeCropper.getCroppedCanvas({
width: 512,
height: 512,
imageSmoothingQuality: 'high',
});
canvas.toBlob(async (blob) => {
const form = new FormData();
form.append('file', blob, 'avatar.jpg');
try {
await api.upload('/profile/avatar', form);
showToast(t('avatar_uploaded'));
// Update profile modal avatar
const img = document.getElementById('profile-avatar-img');
fetchAvatarBlob().then(blobUrl => {
img.src = blobUrl;
img.classList.remove('hidden');
document.getElementById('profile-avatar-letter').classList.add('hidden');
});
document.getElementById('profile-avatar-remove').classList.remove('hidden');
// Update topbar avatar
updateTopbarAvatar(true);
closeModal('modal-crop');
} catch (err) { showToast(err.message, true); }
}, 'image/jpeg', 0.9);
};
// Clean up cropper when modal closes
document.getElementById('modal-crop').addEventListener('click', (e) => {
if (e.target.matches('[data-modal="modal-crop"]') || e.target === document.getElementById('modal-crop')) {
if (activeCropper) { activeCropper.destroy(); activeCropper = null; }
}
});
// ── Modal helpers ─────────────────────────────────────────
function openModal(id) {
document.getElementById(id).classList.remove('hidden');
}
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
if (id === 'modal-settings') {
uiSettingsOpen = false;
// If the admin was live-previewing the default theme, restore their own view.
if (adminPreviewActive) restoreOwnTheme();
writeUrlState();
}
}
// Close button bindings (added once)
document.querySelectorAll('.modal-close, [data-modal]').forEach(el => {
el.addEventListener('click', () => {
const target = el.dataset.modal || el.closest('.modal-overlay')?.id;
if (target) closeModal(target);
});
});
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', e => {
if (e.target === overlay) closeModal(overlay.id);
});
});
// ── Toast ─────────────────────────────────────────────────
let toastTimer = null;
export function showToast(msg, isError = false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast' + (isError ? ' error' : '');
el.classList.remove('hidden');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.add('hidden'), 3500);
}
// ── Helpers ───────────────────────────────────────────────
function fmtTime(d) {
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
}
function fmtDatetime(d) {
return d.toLocaleString(getLocale(), { weekday:'short', day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' });
}
function escHtml(s) {
return String(s).replace(/&/g,'&').replace(//g,'>');
}
function buildWritableCalendars(excludeEv) {
// Determine the event's current calendar to exclude it from copy targets
let excludeType = null;
let excludeId = null;
if (excludeEv) {
const cid = String(excludeEv.calendar_id);
if (excludeEv.source === 'local') {
excludeType = 'local'; excludeId = parseInt(cid.replace('local-', ''));
} else if (excludeEv.source === 'google') {
excludeType = 'google'; excludeId = parseInt(cid.replace('google-', ''));
} else if (excludeEv.source === 'homeassistant') {
excludeType = 'homeassistant'; excludeId = parseInt(cid.replace('homeassistant-', ''));
} else if (excludeEv.source === 'caldav' || !excludeEv.source) {
excludeType = 'caldav'; excludeId = parseInt(cid);
}
}
const skip = (type, id) => excludeType === type && excludeId === id;
const list = [];
let idx = 0;
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.sidebar_hidden || skip('caldav', cal.id)) continue;
list.push({ _idx: idx++, id: cal.id, name: `${acc.name} / ${cal.name}`, color: cal.color || '#4285f4', type: 'caldav' });
}
}
for (const cal of state.localCalendars) {
if (cal.sidebar_hidden || skip('local', cal.id)) continue;
list.push({ _idx: idx++, id: cal.id, name: cal.name, color: cal.color || '#34a853', type: 'local' });
}
for (const acc of state.googleAccounts) {
for (const cal of acc.calendars) {
if (cal.sidebar_hidden || skip('google', cal.id)) continue;
list.push({ _idx: idx++, id: cal.id, name: `${acc.email} / ${cal.name}`, color: cal.color || '#4285f4', type: 'google' });
}
}
for (const acc of state.haAccounts) {
for (const cal of acc.calendars) {
if (cal.sidebar_hidden || skip('homeassistant', cal.id)) continue;
list.push({ _idx: idx++, id: cal.id, name: `${acc.name} / ${cal.name}`, color: cal.color || '#03a9f4', type: 'homeassistant' });
}
}
return list;
}
async function copyEventToCalendar(ev, cal) {
const { title, allDay, location, description, color } = ev;
// Normalize to UTC ISO string so the backend doesn't misinterpret bare local times
const toISO = s => (s && s.length > 10) ? new Date(s).toISOString() : s;
const start = allDay ? ev.start : toISO(ev.start);
const end = allDay ? ev.end : toISO(ev.end);
try {
if (cal.type === 'google') {
await api.post('/google/events', {
calendar_db_id: cal.id, title, start, end, allDay,
location: location || '', description: description || '',
});
} else if (cal.type === 'local') {
await api.post('/local/events', {
calendar_id: cal.id, title, start, end, allDay,
location: location || '', description: description || '', color: color || null,
});
} else if (cal.type === 'homeassistant') {
await api.post('/homeassistant/events', {
calendar_id: cal.id, title, start, end, allDay,
location: location || '', description: description || '',
});
} else {
await api.post('/caldav/events', {
calendar_id: cal.id, title, start, end, allDay,
location: location || '', description: description || '', color: color || null,
});
}
showToast(t('event_copied'));
fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
}