- GoogleCalendar-Modell hinzugefügt (pro Account, mit enabled/color/name) - Kalender werden nach OAuth automatisch synchronisiert - Sidebar zeigt individuelle Google-Kalender mit Checkbox, Farbpunkt und Ausblenden-Button - Einstellungen: Google-Konten-Bereich mit Sync- und Trennen-Button - Ausgeblendete Kalender-Liste zeigt auch Google-Kalender - Event-Erstellung/Bearbeitung/Löschung nutzt GoogleCalendar-ID statt Account-ID
1585 lines
64 KiB
JavaScript
1585 lines
64 KiB
JavaScript
import { api } from './api.js';
|
||
import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart } from './utils.js';
|
||
import { renderMonth } from './views/month.js';
|
||
import { renderWeek } from './views/week.js';
|
||
import { renderAgenda } from './views/agenda.js';
|
||
import { openColorPicker } from './color-picker.js';
|
||
|
||
// Fetch avatar image as blob URL (with auth header)
|
||
function fetchAvatarBlob() {
|
||
const token = localStorage.getItem('token');
|
||
return fetch(`/api/profile/avatar?t=${Date.now()}`, {
|
||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||
}).then(res => {
|
||
if (!res.ok) throw new Error('No avatar');
|
||
return res.blob();
|
||
}).then(blob => URL.createObjectURL(blob));
|
||
}
|
||
|
||
// week start day global (loaded from settings)
|
||
let weekStartDay = 'monday';
|
||
|
||
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
|
||
'Juli','August','September','Oktober','November','Dezember'];
|
||
|
||
let state = {
|
||
currentDate: new Date(),
|
||
currentView: 'month',
|
||
events: [],
|
||
accounts: [],
|
||
localCalendars: [],
|
||
icalSubscriptions: [],
|
||
googleAccounts: [],
|
||
settings: {},
|
||
dimPast: false,
|
||
editingEvent: null, // null = new event
|
||
selectedEventColor: '', // '' = use calendar color
|
||
};
|
||
|
||
// ── Public init ───────────────────────────────────────────
|
||
export async function initCalendar() {
|
||
const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts] = await Promise.all([
|
||
api.get('/settings/'),
|
||
api.get('/caldav/accounts'),
|
||
api.get('/local/calendars').catch(() => []),
|
||
api.get('/ical/subscriptions').catch(() => []),
|
||
api.get('/google/accounts').catch(() => []),
|
||
]);
|
||
|
||
state.settings = settings;
|
||
state.accounts = accounts;
|
||
state.localCalendars = localCalendars;
|
||
state.icalSubscriptions = icalSubscriptions;
|
||
state.googleAccounts = googleAccounts;
|
||
state.currentView = settings.default_view || 'month';
|
||
state.dimPast = settings.dim_past_events;
|
||
weekStartDay = settings.week_start_day || 'monday';
|
||
|
||
applyTheme(settings);
|
||
updateViewButtons();
|
||
renderCalendarList();
|
||
renderMiniCal();
|
||
await fetchAndRender();
|
||
bindTopbar();
|
||
bindSidebar();
|
||
bindEventModal();
|
||
bindAccountModal();
|
||
bindLocalCalModal();
|
||
bindICalSubModal();
|
||
bindSettingsModal();
|
||
bindProfileModal();
|
||
}
|
||
|
||
// ── Data fetching ─────────────────────────────────────────
|
||
async function fetchAndRender() {
|
||
const { start, end } = getViewRange();
|
||
showLoading();
|
||
try {
|
||
const events = await api.get(`/caldav/events?start=${start.toISOString()}&end=${end.toISOString()}`);
|
||
state.events = events;
|
||
} catch (e) {
|
||
showToast('Fehler beim Laden der Termine: ' + e.message, true);
|
||
state.events = [];
|
||
}
|
||
renderView();
|
||
updateTitle();
|
||
renderMiniCal();
|
||
}
|
||
|
||
function getViewRange() {
|
||
const d = state.currentDate;
|
||
let start, end;
|
||
|
||
if (state.currentView === 'month') {
|
||
start = new Date(d.getFullYear(), d.getMonth(), 1);
|
||
start.setDate(start.getDate() - dayOfWeek(start, weekStartDay) - 1);
|
||
end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||
end.setDate(end.getDate() + (6 - dayOfWeek(end, weekStartDay)) + 1);
|
||
} else if (state.currentView === 'week') {
|
||
start = weekStart(d, weekStartDay);
|
||
end = new Date(start);
|
||
end.setDate(start.getDate() + 7);
|
||
} else if (state.currentView === 'day') {
|
||
start = new Date(d);
|
||
start.setHours(0, 0, 0, 0);
|
||
end = new Date(start);
|
||
end.setDate(end.getDate() + 1);
|
||
} else { // agenda
|
||
start = new Date(d);
|
||
start.setHours(0, 0, 0, 0);
|
||
end = new Date(start);
|
||
end.setDate(end.getDate() + 60);
|
||
}
|
||
return { start, end };
|
||
}
|
||
|
||
// ── Rendering ─────────────────────────────────────────────
|
||
function renderView() {
|
||
const container = document.getElementById('view-container');
|
||
const evs = filterEvents(state.events);
|
||
|
||
if (state.currentView === 'month') {
|
||
renderMonth(container, state.currentDate, evs,
|
||
date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); },
|
||
showEventPopup,
|
||
weekStartDay
|
||
);
|
||
} else if (state.currentView === 'week') {
|
||
renderWeek(container, state.currentDate, evs,
|
||
(date, switchDay) => {
|
||
if (switchDay) { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }
|
||
else openNewEventModal(date);
|
||
},
|
||
showEventPopup,
|
||
false,
|
||
weekStartDay
|
||
);
|
||
} else if (state.currentView === 'day') {
|
||
renderWeek(container, state.currentDate, evs,
|
||
(date, switchDay) => { if (!switchDay) openNewEventModal(date); },
|
||
showEventPopup,
|
||
true,
|
||
weekStartDay
|
||
);
|
||
} else {
|
||
renderAgenda(container, state.currentDate, evs, showEventPopup);
|
||
}
|
||
}
|
||
|
||
function filterEvents(events) {
|
||
// If dimPast is enabled, events are still shown but CSS handles opacity via .past class
|
||
return events;
|
||
}
|
||
|
||
function showLoading() {
|
||
document.getElementById('view-container').innerHTML =
|
||
`<div class="loading-view"><div class="spinner"></div></div>`;
|
||
}
|
||
|
||
function updateTitle() {
|
||
const d = state.currentDate;
|
||
let title = '';
|
||
if (state.currentView === 'month') {
|
||
title = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||
} else if (state.currentView === 'week') {
|
||
const mon = weekStart(d, weekStartDay);
|
||
const sun = new Date(mon);
|
||
sun.setDate(mon.getDate() + 6);
|
||
const sameMonth = mon.getMonth() === sun.getMonth();
|
||
title = sameMonth
|
||
? `${mon.getDate()}. – ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}`
|
||
: `${mon.getDate()}. ${MONTHS[mon.getMonth()]} – ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}`;
|
||
} else if (state.currentView === 'day') {
|
||
title = `${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||
} else {
|
||
title = `Ab ${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||
}
|
||
document.getElementById('view-title').textContent = title;
|
||
document.title = `Calendarr - ${title}`;
|
||
}
|
||
|
||
function updateViewButtons() {
|
||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.view === state.currentView);
|
||
});
|
||
}
|
||
|
||
// ── Mini Calendar ─────────────────────────────────────────
|
||
function renderMiniCal() {
|
||
const d = state.currentDate;
|
||
const miniD = new Date(d.getFullYear(), d.getMonth(), 1);
|
||
document.getElementById('mini-title').textContent =
|
||
`${MONTHS[miniD.getMonth()]} ${miniD.getFullYear()}`;
|
||
|
||
const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1);
|
||
const gridStart = new Date(firstDay);
|
||
gridStart.setDate(gridStart.getDate() - dayOfWeek(firstDay, weekStartDay));
|
||
|
||
// Update mini-cal DOW headers based on weekStartDay
|
||
const miniDowEls = document.querySelectorAll('.mini-cal-grid .mini-dow');
|
||
const DOW_MONDAY = ['Mo','Di','Mi','Do','Fr','Sa','So'];
|
||
const DOW_SUNDAY = ['So','Mo','Di','Mi','Do','Fr','Sa'];
|
||
const DOW_LABELS = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY;
|
||
miniDowEls.forEach((el, i) => { el.textContent = DOW_LABELS[i]; });
|
||
|
||
// Build event date set
|
||
const eventDates = new Set(state.events.map(ev => {
|
||
const s = new Date(ev.start);
|
||
return `${s.getFullYear()}-${s.getMonth()}-${s.getDate()}`;
|
||
}));
|
||
|
||
const days = [];
|
||
const cur = new Date(gridStart);
|
||
for (let i = 0; i < 42; i++) {
|
||
days.push(new Date(cur));
|
||
cur.setDate(cur.getDate() + 1);
|
||
}
|
||
|
||
const html = days.map(day => {
|
||
const isOther = day.getMonth() !== miniD.getMonth();
|
||
const isToday_ = isToday(day);
|
||
const isSelected = isSameDay(day, state.currentDate);
|
||
const hasEvs = eventDates.has(`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`);
|
||
const cls = [
|
||
'mini-day',
|
||
isOther ? 'other-month' : '',
|
||
isToday_ ? 'today' : '',
|
||
isSelected && !isToday_ ? 'selected' : '',
|
||
hasEvs ? 'has-events' : '',
|
||
].filter(Boolean).join(' ');
|
||
return `<div class="${cls}" data-date="${dateKey(day)}">${day.getDate()}</div>`;
|
||
}).join('');
|
||
|
||
document.getElementById('mini-days').innerHTML = html;
|
||
|
||
document.querySelectorAll('.mini-day').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
state.currentDate = new Date(el.dataset.date + 'T00:00:00');
|
||
if (state.currentView === 'agenda' || state.currentView === 'month') {
|
||
// Stay in current view but update date
|
||
}
|
||
fetchAndRender();
|
||
});
|
||
});
|
||
|
||
document.getElementById('mini-prev').onclick = () => {
|
||
state.currentDate = new Date(state.currentDate.getFullYear(), state.currentDate.getMonth() - 1, 1);
|
||
renderMiniCal();
|
||
fetchAndRender();
|
||
};
|
||
document.getElementById('mini-next').onclick = () => {
|
||
state.currentDate = new Date(state.currentDate.getFullYear(), state.currentDate.getMonth() + 1, 1);
|
||
renderMiniCal();
|
||
fetchAndRender();
|
||
};
|
||
}
|
||
|
||
// ── Calendar List ─────────────────────────────────────────
|
||
function renderCalendarList() {
|
||
const container = document.getElementById('cal-list-items');
|
||
let html = '';
|
||
|
||
// ── CalDAV accounts ────────────────────────────────────
|
||
if (state.accounts.length) {
|
||
html += state.accounts.map(acc => {
|
||
const visibleCals = acc.calendars.filter(c => !c._hidden);
|
||
if (!visibleCals.length) return '';
|
||
return `<div class="cal-account-name">${escHtml(acc.name)}</div>` +
|
||
visibleCals.map(cal =>
|
||
`<div class="cal-item" data-cal-id="${cal.id}" data-source="caldav">
|
||
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="caldav" />
|
||
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="caldav" title="Farbe ändern"></div>
|
||
<span class="cal-item-name" data-source="caldav">${escHtml(cal.name)}</span>
|
||
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="caldav" title="Kalender ausblenden">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
|
||
</button>
|
||
</div>`
|
||
).join('');
|
||
}).join('');
|
||
}
|
||
|
||
// ── Local calendars ────────────────────────────────────
|
||
if (state.localCalendars.length) {
|
||
html += `<div class="cal-account-name">Lokale Kalender</div>`;
|
||
html += state.localCalendars.map(cal =>
|
||
`<div class="cal-item" data-cal-id="${cal.id}" data-source="local">
|
||
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="local" />
|
||
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="local" title="Farbe ändern"></div>
|
||
<span class="cal-item-name" data-source="local">${escHtml(cal.name)}</span>
|
||
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="local" title="Kalender entfernen">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||
</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
// ── iCal subscriptions ─────────────────────────────────
|
||
if (state.icalSubscriptions.length) {
|
||
html += `<div class="cal-account-name">Abonnements</div>`;
|
||
html += state.icalSubscriptions.map(sub =>
|
||
`<div class="cal-item" data-sub-id="${sub.id}" data-source="ical">
|
||
<input type="checkbox" ${sub.enabled ? 'checked' : ''} data-sub-id="${sub.id}" data-source="ical" />
|
||
<div class="cal-item-dot" style="background:${sub.color}" data-sub-id="${sub.id}" data-source="ical" title="Farbe ändern"></div>
|
||
<span class="cal-item-name" data-source="ical">${escHtml(sub.name)}</span>
|
||
<button class="icon-btn mini-btn cal-item-remove" data-sub-id="${sub.id}" data-source="ical" title="Abo entfernen">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||
</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
// ── Google accounts ───────────────────────────────────
|
||
if (state.googleAccounts.length) {
|
||
html += state.googleAccounts.map(acc => {
|
||
const visibleCals = acc.calendars.filter(c => !c._hidden);
|
||
if (!visibleCals.length) return `<div class="cal-account-name">${escHtml(acc.email)}</div>`;
|
||
return `<div class="cal-account-name">${escHtml(acc.email)}</div>` +
|
||
visibleCals.map(cal =>
|
||
`<div class="cal-item" data-cal-id="${cal.id}" data-source="google">
|
||
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="google" />
|
||
<div class="cal-item-dot" style="background:${cal.color || '#4285f4'}" data-cal-id="${cal.id}" data-source="google" title="Farbe ändern"></div>
|
||
<span class="cal-item-name" data-source="google">${escHtml(cal.name)}</span>
|
||
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="google" title="Kalender ausblenden">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
|
||
</button>
|
||
</div>`
|
||
).join('');
|
||
}).join('');
|
||
}
|
||
|
||
if (!html) {
|
||
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">Keine Kalender</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
|
||
// ── Checkbox handlers ──────────────────────────────────
|
||
container.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||
cb.addEventListener('change', async () => {
|
||
const source = cb.dataset.source;
|
||
if (source === 'caldav') {
|
||
const calId = parseInt(cb.dataset.calId);
|
||
await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked });
|
||
for (const acc of state.accounts) {
|
||
for (const cal of acc.calendars) {
|
||
if (cal.id === calId) cal.enabled = cb.checked;
|
||
}
|
||
}
|
||
} else if (source === 'local') {
|
||
const calId = parseInt(cb.dataset.calId);
|
||
await api.put(`/local/calendars/${calId}`, { enabled: cb.checked });
|
||
const cal = state.localCalendars.find(c => c.id === calId);
|
||
if (cal) cal.enabled = cb.checked;
|
||
} else if (source === 'ical') {
|
||
const subId = parseInt(cb.dataset.subId);
|
||
await api.put(`/ical/subscriptions/${subId}`, { enabled: cb.checked });
|
||
const sub = state.icalSubscriptions.find(s => s.id === subId);
|
||
if (sub) sub.enabled = cb.checked;
|
||
} else if (source === 'google') {
|
||
const calId = parseInt(cb.dataset.calId);
|
||
await api.put(`/google/calendars/${calId}`, { enabled: cb.checked });
|
||
for (const acc of state.googleAccounts) {
|
||
const cal = acc.calendars.find(c => c.id === calId);
|
||
if (cal) cal.enabled = cb.checked;
|
||
}
|
||
}
|
||
fetchAndRender();
|
||
});
|
||
});
|
||
|
||
// ── Color dot handlers ─────────────────────────────────
|
||
container.querySelectorAll('.cal-item-dot').forEach(dot => {
|
||
dot.addEventListener('click', async e => {
|
||
e.stopPropagation();
|
||
const source = dot.dataset.source;
|
||
if (source === 'caldav') {
|
||
openCalColorPicker(dot, parseInt(dot.dataset.calId));
|
||
} else if (source === 'local') {
|
||
const calId = parseInt(dot.dataset.calId);
|
||
const cal = state.localCalendars.find(c => c.id === calId);
|
||
const picked = await openColorPicker(dot, cal?.color || '#34a853');
|
||
if (picked) {
|
||
await api.put(`/local/calendars/${calId}`, { color: picked });
|
||
if (cal) cal.color = picked;
|
||
renderCalendarList();
|
||
fetchAndRender();
|
||
}
|
||
} else if (source === 'ical') {
|
||
const subId = parseInt(dot.dataset.subId);
|
||
const sub = state.icalSubscriptions.find(s => s.id === subId);
|
||
const picked = await openColorPicker(dot, sub?.color || '#46bdc6');
|
||
if (picked) {
|
||
await api.put(`/ical/subscriptions/${subId}`, { color: picked });
|
||
if (sub) sub.color = picked;
|
||
renderCalendarList();
|
||
fetchAndRender();
|
||
}
|
||
} else if (source === 'google') {
|
||
const calId = parseInt(dot.dataset.calId);
|
||
let gcal = null;
|
||
for (const acc of state.googleAccounts) {
|
||
gcal = acc.calendars.find(c => c.id === calId);
|
||
if (gcal) break;
|
||
}
|
||
const picked = await openColorPicker(dot, gcal?.color || '#4285f4');
|
||
if (picked) {
|
||
await api.put(`/google/calendars/${calId}`, { color: picked });
|
||
if (gcal) gcal.color = picked;
|
||
renderCalendarList();
|
||
fetchAndRender();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── Rename on double-click ─────────────────────────────
|
||
container.querySelectorAll('.cal-item-name').forEach(nameEl => {
|
||
nameEl.addEventListener('dblclick', e => {
|
||
e.stopPropagation();
|
||
const item = nameEl.closest('.cal-item');
|
||
const source = nameEl.dataset.source;
|
||
const currentName = nameEl.textContent;
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.value = currentName;
|
||
input.className = 'cal-rename-input';
|
||
nameEl.replaceWith(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
const save = async () => {
|
||
const newName = input.value.trim();
|
||
if (newName && newName !== currentName) {
|
||
if (source === 'caldav') {
|
||
const calId = parseInt(item.dataset.calId);
|
||
await api.put(`/caldav/calendars/${calId}`, { name: newName });
|
||
for (const acc of state.accounts) {
|
||
for (const cal of acc.calendars) { if (cal.id === calId) cal.name = newName; }
|
||
}
|
||
} else if (source === 'local') {
|
||
const calId = parseInt(item.dataset.calId);
|
||
await api.put(`/local/calendars/${calId}`, { name: newName });
|
||
const cal = state.localCalendars.find(c => c.id === calId);
|
||
if (cal) cal.name = newName;
|
||
} else if (source === 'ical') {
|
||
const subId = parseInt(item.dataset.subId);
|
||
await api.put(`/ical/subscriptions/${subId}`, { name: newName });
|
||
const sub = state.icalSubscriptions.find(s => s.id === subId);
|
||
if (sub) sub.name = newName;
|
||
}
|
||
}
|
||
renderCalendarList();
|
||
};
|
||
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') save();
|
||
if (e.key === 'Escape') renderCalendarList();
|
||
});
|
||
input.addEventListener('blur', save);
|
||
});
|
||
});
|
||
|
||
// ── Remove handlers ────────────────────────────────────
|
||
container.querySelectorAll('.cal-item-remove').forEach(btn => {
|
||
btn.addEventListener('click', async e => {
|
||
e.stopPropagation();
|
||
const source = btn.dataset.source;
|
||
if (source === 'caldav') {
|
||
const calId = parseInt(btn.dataset.calId);
|
||
await api.put(`/caldav/calendars/${calId}`, { enabled: false });
|
||
for (const acc of state.accounts) {
|
||
for (const cal of acc.calendars) {
|
||
if (cal.id === calId) { cal.enabled = false; cal._hidden = true; }
|
||
}
|
||
}
|
||
} else if (source === 'local') {
|
||
if (!confirm('Lokalen Kalender wirklich löschen?')) return;
|
||
const calId = parseInt(btn.dataset.calId);
|
||
await api.delete(`/local/calendars/${calId}`);
|
||
state.localCalendars = state.localCalendars.filter(c => c.id !== calId);
|
||
} else if (source === 'ical') {
|
||
if (!confirm('Abonnement wirklich entfernen?')) return;
|
||
const subId = parseInt(btn.dataset.subId);
|
||
await api.delete(`/ical/subscriptions/${subId}`);
|
||
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
|
||
} else if (source === 'google') {
|
||
const calId = parseInt(btn.dataset.calId);
|
||
await api.put(`/google/calendars/${calId}`, { enabled: false });
|
||
for (const acc of state.googleAccounts) {
|
||
for (const cal of acc.calendars) {
|
||
if (cal.id === calId) { cal.enabled = false; cal._hidden = true; }
|
||
}
|
||
}
|
||
}
|
||
renderCalendarList();
|
||
fetchAndRender();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Navigation ────────────────────────────────────────────
|
||
function navigate(dir) {
|
||
const d = state.currentDate;
|
||
if (state.currentView === 'month') {
|
||
state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1);
|
||
} else if (state.currentView === 'week') {
|
||
state.currentDate = new Date(d);
|
||
state.currentDate.setDate(d.getDate() + dir * 7);
|
||
} else if (state.currentView === 'day') {
|
||
state.currentDate = new Date(d);
|
||
state.currentDate.setDate(d.getDate() + dir);
|
||
} else {
|
||
state.currentDate = new Date(d);
|
||
state.currentDate.setDate(d.getDate() + dir * 30);
|
||
}
|
||
fetchAndRender();
|
||
}
|
||
|
||
// ── Topbar bindings ───────────────────────────────────────
|
||
function bindTopbar() {
|
||
document.getElementById('btn-today').onclick = () => {
|
||
state.currentDate = new Date();
|
||
fetchAndRender();
|
||
};
|
||
document.getElementById('btn-prev').onclick = () => navigate(-1);
|
||
document.getElementById('btn-next').onclick = () => navigate(1);
|
||
|
||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
state.currentView = btn.dataset.view;
|
||
updateViewButtons();
|
||
fetchAndRender();
|
||
});
|
||
});
|
||
|
||
document.getElementById('btn-settings').onclick = openSettingsModal;
|
||
document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
|
||
}
|
||
|
||
// ── Sidebar toggle ────────────────────────────────────────
|
||
function bindSidebar() {
|
||
document.getElementById('sidebar-toggle').onclick = () => {
|
||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||
};
|
||
|
||
// Add calendar dropdown
|
||
const addBtn = document.getElementById('btn-add-cal');
|
||
const dropdown = document.getElementById('add-cal-dropdown');
|
||
|
||
addBtn.onclick = e => {
|
||
e.stopPropagation();
|
||
dropdown.classList.toggle('hidden');
|
||
};
|
||
document.addEventListener('click', e => {
|
||
if (!dropdown.contains(e.target) && e.target !== addBtn) {
|
||
dropdown.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
dropdown.querySelector('[data-action="caldav"]').onclick = () => {
|
||
dropdown.classList.add('hidden');
|
||
openAccountModal();
|
||
};
|
||
dropdown.querySelector('[data-action="local"]').onclick = () => {
|
||
dropdown.classList.add('hidden');
|
||
openLocalCalModal();
|
||
};
|
||
dropdown.querySelector('[data-action="ical"]').onclick = () => {
|
||
dropdown.classList.add('hidden');
|
||
openICalSubModal();
|
||
};
|
||
dropdown.querySelector('[data-action="google"]').onclick = async () => {
|
||
dropdown.classList.add('hidden');
|
||
try {
|
||
const { configured } = await api.get('/google/configured');
|
||
if (!configured) {
|
||
showToast('Google OAuth ist nicht konfiguriert (Admin muss GOOGLE_CLIENT_ID/SECRET setzen)', true);
|
||
return;
|
||
}
|
||
const { url } = await api.get('/google/auth-url');
|
||
window.location.href = url;
|
||
} catch (e) {
|
||
showToast('Fehler: ' + e.message, true);
|
||
}
|
||
};
|
||
}
|
||
|
||
// ── Event Popup ───────────────────────────────────────────
|
||
function showEventPopup(ev, anchor) {
|
||
const popup = document.getElementById('popup-event');
|
||
popup.classList.remove('hidden');
|
||
|
||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||
document.getElementById('popup-color-dot').style.background = color;
|
||
document.getElementById('popup-title').textContent = ev.title;
|
||
|
||
// Time
|
||
if (ev.allDay) {
|
||
document.getElementById('popup-time').textContent = 'Ganztägig';
|
||
} else {
|
||
const s = new Date(ev.start);
|
||
const e = new Date(ev.end);
|
||
document.getElementById('popup-time').textContent =
|
||
`${fmtDatetime(s)} – ${fmtTime(e)}`;
|
||
}
|
||
|
||
document.getElementById('popup-location').textContent = ev.location || '';
|
||
document.getElementById('popup-location').style.display = ev.location ? '' : 'none';
|
||
document.getElementById('popup-description').textContent = ev.description || '';
|
||
document.getElementById('popup-description').style.display = ev.description ? '' : 'none';
|
||
document.getElementById('popup-calendar').textContent = ev.calendar_name || '';
|
||
|
||
// Position near anchor
|
||
const rect = anchor.getBoundingClientRect();
|
||
const pw = 300, ph = 200;
|
||
let left = rect.right + 8;
|
||
let top = rect.top;
|
||
if (left + pw > window.innerWidth) left = rect.left - pw - 8;
|
||
if (top + ph > window.innerHeight) top = window.innerHeight - ph - 16;
|
||
popup.style.left = Math.max(8, left) + 'px';
|
||
popup.style.top = Math.max(8, top) + 'px';
|
||
|
||
document.getElementById('popup-edit').onclick = () => {
|
||
popup.classList.add('hidden');
|
||
openEditEventModal(ev);
|
||
};
|
||
document.getElementById('popup-delete').onclick = async () => {
|
||
if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
|
||
popup.classList.add('hidden');
|
||
try {
|
||
if (ev.source === 'google') {
|
||
const accId = ev.calendar_id.replace('google-', '');
|
||
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
|
||
} else if (ev.source === 'local') {
|
||
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
||
} else if (ev.source === 'ical') {
|
||
const subId = ev.calendar_id.replace('ical-', '');
|
||
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
|
||
} else {
|
||
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
|
||
}
|
||
showToast('Termin gelöscht');
|
||
fetchAndRender();
|
||
} catch (e) { showToast(e.message, true); }
|
||
};
|
||
document.getElementById('popup-close').onclick = () => popup.classList.add('hidden');
|
||
}
|
||
|
||
// Close popup on outside click
|
||
document.addEventListener('click', e => {
|
||
const popup = document.getElementById('popup-event');
|
||
if (!popup.classList.contains('hidden') && !popup.contains(e.target)) {
|
||
popup.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// ── Event Modal ───────────────────────────────────────────
|
||
function populateCalendarSelect(selectedId) {
|
||
const sel = document.getElementById('ev-calendar');
|
||
sel.innerHTML = '';
|
||
// CalDAV calendars
|
||
state.accounts.forEach(acc => {
|
||
acc.calendars.filter(c => c.enabled).forEach(cal => {
|
||
const opt = document.createElement('option');
|
||
opt.value = cal.id;
|
||
opt.textContent = `${acc.name} / ${cal.name}`;
|
||
if (cal.id === selectedId) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
});
|
||
// Local calendars
|
||
state.localCalendars.filter(c => c.enabled).forEach(cal => {
|
||
const opt = document.createElement('option');
|
||
opt.value = `local-${cal.id}`;
|
||
opt.textContent = cal.name;
|
||
if (`local-${cal.id}` === selectedId) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
// iCal subscriptions are read-only, not shown here
|
||
// Google calendars (read/write)
|
||
state.googleAccounts.forEach(acc => {
|
||
acc.calendars.filter(c => c.enabled).forEach(cal => {
|
||
const opt = document.createElement('option');
|
||
opt.value = `google-${cal.id}`;
|
||
opt.textContent = `${acc.email} / ${cal.name}`;
|
||
if (`google-${cal.id}` === selectedId) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
});
|
||
}
|
||
|
||
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 end = new Date(date);
|
||
end.setHours(end.getHours() + 1);
|
||
document.getElementById('ev-start').value = toLocalDatetimeInput(start);
|
||
document.getElementById('ev-end').value = toLocalDatetimeInput(end);
|
||
document.getElementById('ev-start-date').value = toDateInput(start);
|
||
document.getElementById('ev-end-date').value = toDateInput(start);
|
||
|
||
toggleAlldayFields(false);
|
||
populateCalendarSelect(null);
|
||
resetColorPicker('');
|
||
document.getElementById('ev-delete').classList.add('hidden');
|
||
openModal('modal-event');
|
||
}
|
||
|
||
function openEditEventModal(ev) {
|
||
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) {
|
||
document.getElementById('ev-start-date').value = ev.start.slice(0, 10);
|
||
document.getElementById('ev-end-date').value = ev.end.slice(0, 10);
|
||
toggleAlldayFields(true);
|
||
} else {
|
||
const s = new Date(ev.start);
|
||
const e = new Date(ev.end);
|
||
document.getElementById('ev-start').value = toLocalDatetimeInput(s);
|
||
document.getElementById('ev-end').value = toLocalDatetimeInput(e);
|
||
toggleAlldayFields(false);
|
||
}
|
||
|
||
populateCalendarSelect(ev.calendar_id);
|
||
resetColorPicker(ev.color || '');
|
||
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';
|
||
}
|
||
|
||
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)';
|
||
}
|
||
|
||
function bindEventModal() {
|
||
document.getElementById('ev-allday').addEventListener('change', e => {
|
||
toggleAlldayFields(e.target.checked);
|
||
});
|
||
|
||
// 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);
|
||
}
|
||
});
|
||
|
||
document.getElementById('ev-save').onclick = async () => {
|
||
const title = document.getElementById('ev-title').value.trim();
|
||
if (!title) { showToast('Bitte Titel eingeben', 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 loc = document.getElementById('ev-location').value.trim();
|
||
const desc = document.getElementById('ev-description').value.trim();
|
||
const color = state.selectedEventColor;
|
||
|
||
let start, end;
|
||
if (allDay) {
|
||
start = document.getElementById('ev-start-date').value;
|
||
end = document.getElementById('ev-end-date').value;
|
||
if (!start) { showToast('Bitte Datum eingeben', true); return; }
|
||
if (!end || end < start) end = start;
|
||
} else {
|
||
const sv = document.getElementById('ev-start').value;
|
||
const ev2 = document.getElementById('ev-end').value;
|
||
if (!sv) { showToast('Bitte Start-Zeit eingeben', 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 }
|
||
);
|
||
} else if (ev.source === 'ical') {
|
||
const subId = ev.calendar_id.replace('ical-', '');
|
||
await api.put(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`,
|
||
{ title, start, end, allDay, location: loc, description: desc, color: color || null }
|
||
);
|
||
} else {
|
||
await api.put(
|
||
`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`,
|
||
{ title, start, end, allDay, location: loc, description: desc, color: color || null }
|
||
);
|
||
}
|
||
showToast('Termin aktualisiert');
|
||
} 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('Termin erstellt');
|
||
} else if (isLocal) {
|
||
const calId = parseInt(calVal.replace('local-', ''));
|
||
await api.post('/local/events', {
|
||
calendar_id: calId, title, start, end, allDay,
|
||
location: loc, description: desc, color: color || null,
|
||
});
|
||
showToast('Termin erstellt');
|
||
} else {
|
||
const calId = parseInt(calVal);
|
||
await api.post('/caldav/events', {
|
||
calendar_id: calId, title, start, end, allDay,
|
||
location: loc, description: desc, color: color || null,
|
||
});
|
||
showToast('Termin erstellt');
|
||
}
|
||
closeModal('modal-event');
|
||
fetchAndRender();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
};
|
||
|
||
document.getElementById('ev-delete').onclick = async () => {
|
||
const ev = state.editingEvent;
|
||
if (!ev) return;
|
||
if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
|
||
try {
|
||
if (ev.source === 'google') {
|
||
const accId = ev.calendar_id.replace('google-', '');
|
||
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
|
||
} else if (ev.source === 'local') {
|
||
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
||
} else if (ev.source === 'ical') {
|
||
const subId = ev.calendar_id.replace('ical-', '');
|
||
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
|
||
} else {
|
||
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
|
||
}
|
||
showToast('Termin gelöscht');
|
||
closeModal('modal-event');
|
||
fetchAndRender();
|
||
} 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(`Konto "${name}" hinzugefügt`);
|
||
fetchAndRender();
|
||
} catch (e) {
|
||
errEl.textContent = e.message;
|
||
errEl.classList.remove('hidden');
|
||
} finally {
|
||
document.getElementById('acc-save').disabled = false;
|
||
document.getElementById('acc-save').textContent = 'Verbinden';
|
||
}
|
||
};
|
||
}
|
||
|
||
// ── 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('Bitte Name eingeben', 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(`Kalender "${name}" erstellt`);
|
||
} catch (e) { showToast(e.message, true); }
|
||
};
|
||
}
|
||
|
||
// ── 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(`"${name}" abonniert`);
|
||
fetchAndRender();
|
||
} 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';
|
||
}
|
||
};
|
||
}
|
||
|
||
// ── Settings Modal ────────────────────────────────────────
|
||
function openSettingsModal() {
|
||
const s = state.settings;
|
||
document.getElementById('cfg-default-view').value = s.default_view || 'month';
|
||
document.getElementById('cfg-week-start').value = s.week_start_day || 'monday';
|
||
const colors = [
|
||
{ id: 'cfg-primary', val: s.primary_color || '#4285f4' },
|
||
{ id: 'cfg-accent', val: s.accent_color || '#ea4335' },
|
||
{ id: 'cfg-today', val: s.today_color || '#4285f4' },
|
||
];
|
||
colors.forEach(({ id, val }) => {
|
||
document.getElementById(id + '-hex').value = val.toUpperCase();
|
||
document.getElementById(id + '-preview').style.background = val;
|
||
});
|
||
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
|
||
|
||
// Show users section only for admins
|
||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||
const usersSection = document.getElementById('settings-users-section');
|
||
if (user.is_admin) {
|
||
usersSection.classList.remove('hidden');
|
||
loadUsers();
|
||
} else {
|
||
usersSection.classList.add('hidden');
|
||
}
|
||
|
||
// Render Google accounts
|
||
renderGoogleAccounts();
|
||
|
||
// Render hidden calendars
|
||
renderHiddenCalendars();
|
||
|
||
openModal('modal-settings');
|
||
}
|
||
|
||
function renderGoogleAccounts() {
|
||
const list = document.getElementById('google-accounts-list');
|
||
if (!list) return;
|
||
if (!state.googleAccounts.length) {
|
||
list.innerHTML = '<span style="font-size:13px;color:var(--text-3)">Keine Google-Konten verbunden</span>';
|
||
return;
|
||
}
|
||
list.innerHTML = state.googleAccounts.map(acc =>
|
||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
|
||
<span style="font-size:13px">${escHtml(acc.email)}</span>
|
||
<div style="display:flex;gap:6px">
|
||
<button class="btn btn-secondary btn-sm" data-sync-acc="${acc.id}">Sync</button>
|
||
<button class="btn btn-ghost btn-sm" data-disconnect-acc="${acc.id}">Trennen</button>
|
||
</div>
|
||
</div>`
|
||
).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();
|
||
showToast('Kalender synchronisiert');
|
||
} catch (e) { showToast(e.message, true); }
|
||
});
|
||
});
|
||
list.querySelectorAll('[data-disconnect-acc]').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
if (!confirm('Google-Konto wirklich trennen?')) 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();
|
||
showToast('Google-Konto getrennt');
|
||
} catch (e) { showToast(e.message, true); }
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderHiddenCalendars() {
|
||
const list = document.getElementById('hidden-cals-list');
|
||
const hidden = [];
|
||
for (const acc of state.accounts) {
|
||
for (const cal of acc.calendars) {
|
||
if (!cal.enabled || cal._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.enabled || cal._hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.email, source: 'google' });
|
||
}
|
||
}
|
||
if (!hidden.length) {
|
||
list.innerHTML = '<span style="font-size:13px;color:var(--text-3)">Keine ausgeblendeten Kalender</span>';
|
||
return;
|
||
}
|
||
list.innerHTML = hidden.map(c =>
|
||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
|
||
<span style="font-size:13px">${escHtml(c.acc)} / ${escHtml(c.name)}</span>
|
||
<button class="btn btn-secondary btn-sm" data-restore-cal="${c.id}" data-restore-source="${c.source}">Einblenden</button>
|
||
</div>`
|
||
).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 });
|
||
for (const acc of state.googleAccounts) {
|
||
for (const cal of acc.calendars) {
|
||
if (cal.id === calId) { cal.enabled = true; delete cal._hidden; }
|
||
}
|
||
}
|
||
} else {
|
||
await api.put(`/caldav/calendars/${calId}`, { enabled: true });
|
||
for (const acc of state.accounts) {
|
||
for (const cal of acc.calendars) {
|
||
if (cal.id === calId) { cal.enabled = true; delete cal._hidden; }
|
||
}
|
||
}
|
||
}
|
||
renderHiddenCalendars();
|
||
renderCalendarList();
|
||
fetchAndRender();
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
const users = await api.get('/users/');
|
||
const list = document.getElementById('users-list');
|
||
list.innerHTML = users.map(u =>
|
||
`<div class="users-list-item">
|
||
<div>
|
||
<div class="uname">${escHtml(u.username)}</div>
|
||
${u.email ? `<div class="uemail">${escHtml(u.email)}</div>` : ''}
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
${u.is_admin ? '<span class="ubadge">Admin</span>' : ''}
|
||
${u.id !== JSON.parse(localStorage.getItem('user')||'{}').id
|
||
? `<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" data-del-user="${u.id}">Löschen</button>`
|
||
: ''}
|
||
</div>
|
||
</div>`
|
||
).join('');
|
||
|
||
list.querySelectorAll('[data-del-user]').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
if (!confirm('Benutzer löschen?')) return;
|
||
try {
|
||
await api.delete(`/users/${btn.dataset.delUser}`);
|
||
loadUsers();
|
||
} catch (e) { showToast(e.message, true); }
|
||
});
|
||
});
|
||
} catch (e) { /* not admin */ }
|
||
}
|
||
|
||
function bindSettingsModal() {
|
||
['cfg-primary','cfg-accent','cfg-today'].forEach(prefix => {
|
||
const preview = document.getElementById(prefix + '-preview');
|
||
const hex = document.getElementById(prefix + '-hex');
|
||
preview.addEventListener('click', async () => {
|
||
const picked = await openColorPicker(preview, hex.value || '#4285f4');
|
||
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('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('Benutzername und Passwort erforderlich', true); return; }
|
||
try {
|
||
await api.post('/users/', { username, password, is_admin });
|
||
showToast(`Benutzer "${username}" erstellt`);
|
||
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); }
|
||
};
|
||
|
||
document.getElementById('settings-save').onclick = async () => {
|
||
const settings = {
|
||
default_view: document.getElementById('cfg-default-view').value,
|
||
week_start_day: document.getElementById('cfg-week-start').value,
|
||
primary_color: document.getElementById('cfg-primary-hex').value,
|
||
accent_color: document.getElementById('cfg-accent-hex').value,
|
||
today_color: document.getElementById('cfg-today-hex').value,
|
||
dim_past_events: document.getElementById('cfg-dim-past').checked,
|
||
};
|
||
try {
|
||
await api.put('/settings/', settings);
|
||
state.settings = { ...state.settings, ...settings };
|
||
state.dimPast = settings.dim_past_events;
|
||
weekStartDay = settings.week_start_day;
|
||
applyTheme(settings);
|
||
showToast('Einstellungen gespeichert');
|
||
closeModal('modal-settings');
|
||
renderMiniCal();
|
||
fetchAndRender();
|
||
} catch (e) { showToast(e.message, true); }
|
||
};
|
||
}
|
||
|
||
// ── Profile Modal ─────────────────────────────────────────
|
||
export function openProfileModal() {
|
||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||
|
||
// Username & email
|
||
document.getElementById('profile-username').value = user.username || '';
|
||
document.getElementById('profile-display-name').textContent = 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 = '<p class="text-muted">Keine CalDAV-Konten verbunden.</p>';
|
||
return;
|
||
}
|
||
const html = state.accounts.map(acc =>
|
||
acc.calendars.map(cal =>
|
||
`<div class="profile-cal-item">
|
||
<div class="profile-cal-dot" style="background:${cal.color}"></div>
|
||
<div>
|
||
<div class="profile-cal-name">${escHtml(cal.name)}</div>
|
||
<div class="profile-cal-account">${escHtml(acc.name)}</div>
|
||
</div>
|
||
</div>`
|
||
).join('')
|
||
).join('');
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function bindProfileModal() {
|
||
// Save profile info (email)
|
||
document.getElementById('profile-save-info').onclick = async () => {
|
||
const email = document.getElementById('profile-email').value.trim();
|
||
try {
|
||
await api.put('/profile/', { email: email || null });
|
||
showToast('Profil gespeichert');
|
||
} 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('Profilbild entfernt');
|
||
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('Bitte alle Felder ausfüllen', true); return; }
|
||
if (newPw !== confirm) { showToast('Passwörter stimmen nicht überein', true); return; }
|
||
if (newPw.length < 6) { showToast('Mindestens 6 Zeichen', true); return; }
|
||
try {
|
||
await api.post('/profile/password', { current_password: current, new_password: newPw });
|
||
showToast('Passwort geändert');
|
||
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('Schlüssel kopiert'));
|
||
};
|
||
|
||
document.getElementById('2fa-enable-btn').onclick = async () => {
|
||
const code = document.getElementById('2fa-verify-code').value.trim();
|
||
if (!code || code.length !== 6) { showToast('Bitte 6-stelligen Code eingeben', true); return; }
|
||
try {
|
||
await api.post('/profile/2fa/enable', { code });
|
||
showToast('2FA aktiviert');
|
||
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('Bitte Passwort eingeben', true); return; }
|
||
try {
|
||
await api.post('/profile/2fa/disable', { password: pw });
|
||
showToast('2FA deaktiviert');
|
||
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); }
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
renderCalendarList();
|
||
fetchAndRender();
|
||
}
|
||
|
||
// ── 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('Profilbild hochgeladen');
|
||
// 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');
|
||
}
|
||
|
||
// 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('de', { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
function fmtDatetime(d) {
|
||
return d.toLocaleString('de', { weekday:'short', day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' });
|
||
}
|
||
function escHtml(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|