initialer commit, Grundcode
This commit is contained in:
49
frontend/js/api.js
Normal file
49
frontend/js/api.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const BASE = '/api';
|
||||
|
||||
async function request(method, path, body = null, formEncoded = false) {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = {};
|
||||
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
let bodyStr = null;
|
||||
if (body !== null) {
|
||||
if (formEncoded) {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
bodyStr = new URLSearchParams(body).toString();
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
bodyStr = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr });
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.reload();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Unbekannter Fehler' }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request('GET', path),
|
||||
post: (path, body) => request('POST', path, body),
|
||||
put: (path, body) => request('PUT', path, body),
|
||||
delete: (path) => request('DELETE', path),
|
||||
|
||||
login: (username, password) =>
|
||||
request('POST', '/auth/token', { username, password }, true),
|
||||
|
||||
setupRequired: () => request('GET', '/auth/setup-required'),
|
||||
setup: (data) => request('POST', '/auth/setup', data),
|
||||
};
|
||||
129
frontend/js/app.js
Normal file
129
frontend/js/app.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { api } from './api.js';
|
||||
import { initCalendar, showToast } from './calendar.js';
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────
|
||||
async function boot() {
|
||||
// Check if setup is required
|
||||
let setupRequired = false;
|
||||
try {
|
||||
const res = await api.setupRequired();
|
||||
setupRequired = res.required;
|
||||
} catch (e) {
|
||||
showScreen('login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (setupRequired) {
|
||||
showScreen('setup');
|
||||
bindSetupForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already logged in
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
await api.get('/auth/me'); // validate token
|
||||
await launchApp();
|
||||
return;
|
||||
} catch (_) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
|
||||
showScreen('login');
|
||||
bindLoginForm();
|
||||
}
|
||||
|
||||
function showScreen(name) {
|
||||
document.getElementById('screen-setup').classList.add('hidden');
|
||||
document.getElementById('screen-login').classList.add('hidden');
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
|
||||
if (name === 'setup') document.getElementById('screen-setup').classList.remove('hidden');
|
||||
else if (name === 'login') document.getElementById('screen-login').classList.remove('hidden');
|
||||
else if (name === 'app') document.getElementById('app').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function launchApp() {
|
||||
showScreen('app');
|
||||
|
||||
// Set user avatar initials
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const avatar = document.getElementById('user-avatar');
|
||||
if (user.username) {
|
||||
avatar.textContent = user.username[0].toUpperCase();
|
||||
avatar.title = user.username;
|
||||
}
|
||||
|
||||
// Logout on avatar click (simple UX)
|
||||
avatar.addEventListener('click', () => {
|
||||
if (confirm('Abmelden?')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
await initCalendar();
|
||||
}
|
||||
|
||||
// ── Setup Form ────────────────────────────────────────────
|
||||
function bindSetupForm() {
|
||||
document.getElementById('setup-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('setup-username').value.trim();
|
||||
const email = document.getElementById('setup-email').value.trim() || null;
|
||||
const pw1 = document.getElementById('setup-password').value;
|
||||
const pw2 = document.getElementById('setup-password2').value;
|
||||
const errEl = document.getElementById('setup-error');
|
||||
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
if (pw1 !== pw2) {
|
||||
errEl.textContent = 'Passwörter stimmen nicht überein';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
if (pw1.length < 6) {
|
||||
errEl.textContent = 'Passwort muss mindestens 6 Zeichen haben';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.setup({ username, email, password: pw1 });
|
||||
localStorage.setItem('token', res.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(res.user));
|
||||
await launchApp();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Login Form ────────────────────────────────────────────
|
||||
function bindLoginForm() {
|
||||
document.getElementById('login-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const res = await api.login(username, password);
|
||||
localStorage.setItem('token', res.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(res.user));
|
||||
await launchApp();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────
|
||||
boot();
|
||||
719
frontend/js/calendar.js
Normal file
719
frontend/js/calendar.js
Normal file
@@ -0,0 +1,719 @@
|
||||
import { api } from './api.js';
|
||||
import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey } from './utils.js';
|
||||
import { renderMonth } from './views/month.js';
|
||||
import { renderWeek } from './views/week.js';
|
||||
import { renderAgenda } from './views/agenda.js';
|
||||
|
||||
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
|
||||
'Juli','August','September','Oktober','November','Dezember'];
|
||||
|
||||
let state = {
|
||||
currentDate: new Date(),
|
||||
currentView: 'month',
|
||||
events: [],
|
||||
accounts: [],
|
||||
settings: {},
|
||||
dimPast: false,
|
||||
editingEvent: null, // null = new event
|
||||
selectedEventColor: '', // '' = use calendar color
|
||||
};
|
||||
|
||||
// ── Public init ───────────────────────────────────────────
|
||||
export async function initCalendar() {
|
||||
const [settings, accounts] = await Promise.all([
|
||||
api.get('/settings/'),
|
||||
api.get('/caldav/accounts'),
|
||||
]);
|
||||
|
||||
state.settings = settings;
|
||||
state.accounts = accounts;
|
||||
state.currentView = settings.default_view || 'month';
|
||||
state.dimPast = settings.dim_past_events;
|
||||
|
||||
applyTheme(settings);
|
||||
updateViewButtons();
|
||||
renderCalendarList();
|
||||
renderMiniCal();
|
||||
await fetchAndRender();
|
||||
bindTopbar();
|
||||
bindSidebar();
|
||||
bindEventModal();
|
||||
bindAccountModal();
|
||||
bindSettingsModal();
|
||||
}
|
||||
|
||||
// ── 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() - start.getDay() - 1);
|
||||
end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||
end.setDate(end.getDate() + (6 - end.getDay()) + 1);
|
||||
} else if (state.currentView === 'week') {
|
||||
start = new Date(d);
|
||||
start.setDate(d.getDate() - d.getDay());
|
||||
start.setHours(0, 0, 0, 0);
|
||||
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
|
||||
);
|
||||
} 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
|
||||
);
|
||||
} else if (state.currentView === 'day') {
|
||||
renderWeek(container, state.currentDate, evs,
|
||||
(date, switchDay) => { if (!switchDay) openNewEventModal(date); },
|
||||
showEventPopup,
|
||||
true
|
||||
);
|
||||
} 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 sun = new Date(d);
|
||||
sun.setDate(d.getDate() - d.getDay());
|
||||
const sat = new Date(sun);
|
||||
sat.setDate(sun.getDate() + 6);
|
||||
const sameMonth = sun.getMonth() === sat.getMonth();
|
||||
title = sameMonth
|
||||
? `${sun.getDate()}. – ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}`
|
||||
: `${sun.getDate()}. ${MONTHS[sun.getMonth()]} – ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.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;
|
||||
}
|
||||
|
||||
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 lastDay = new Date(miniD.getFullYear(), miniD.getMonth() + 1, 0);
|
||||
const gridStart = new Date(firstDay);
|
||||
gridStart.setDate(gridStart.getDate() - firstDay.getDay());
|
||||
|
||||
// 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="${dayKey(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');
|
||||
if (!state.accounts.length) {
|
||||
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">Kein CalDAV-Konto</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const html = state.accounts.map(acc =>
|
||||
`<div class="cal-account-name">${escHtml(acc.name)}</div>` +
|
||||
acc.calendars.map(cal =>
|
||||
`<div class="cal-item" data-cal-id="${cal.id}">
|
||||
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" />
|
||||
<div class="cal-item-dot" style="background:${cal.color}"></div>
|
||||
<span class="cal-item-name">${escHtml(cal.name)}</span>
|
||||
<button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" title="Konto 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('')
|
||||
).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
container.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||
cb.addEventListener('change', async () => {
|
||||
const calId = parseInt(cb.dataset.calId);
|
||||
await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked });
|
||||
// Update local state
|
||||
for (const acc of state.accounts) {
|
||||
for (const cal of acc.calendars) {
|
||||
if (cal.id === calId) cal.enabled = cb.checked;
|
||||
}
|
||||
}
|
||||
fetchAndRender();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.cal-item-remove').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('CalDAV-Konto wirklich entfernen?')) return;
|
||||
const accId = parseInt(btn.dataset.accId);
|
||||
await api.delete(`/caldav/accounts/${accId}`);
|
||||
state.accounts = state.accounts.filter(a => a.id !== accId);
|
||||
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');
|
||||
};
|
||||
document.getElementById('btn-add-account').onclick = openAccountModal;
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
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 = '';
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
document.querySelectorAll('#ev-color-picker .color-swatch').forEach(sw => {
|
||||
sw.classList.toggle('active', (sw.dataset.color || '') === color);
|
||||
});
|
||||
}
|
||||
|
||||
function bindEventModal() {
|
||||
document.getElementById('ev-allday').addEventListener('change', e => {
|
||||
toggleAlldayFields(e.target.checked);
|
||||
});
|
||||
|
||||
document.querySelectorAll('#ev-color-picker .color-swatch').forEach(sw => {
|
||||
sw.addEventListener('click', () => {
|
||||
state.selectedEventColor = sw.dataset.color || '';
|
||||
resetColorPicker(state.selectedEventColor);
|
||||
});
|
||||
});
|
||||
|
||||
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 calId = parseInt(document.getElementById('ev-calendar').value);
|
||||
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) {
|
||||
await api.put(
|
||||
`/caldav/events/${encodeURIComponent(state.editingEvent.id)}?event_url=${encodeURIComponent(state.editingEvent.url)}`,
|
||||
{ title, start, end, allDay, location: loc, description: desc, color: color || null }
|
||||
);
|
||||
showToast('Termin aktualisiert');
|
||||
} else {
|
||||
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 {
|
||||
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').value = '#4285f4';
|
||||
document.getElementById('acc-error').classList.add('hidden');
|
||||
openModal('modal-account');
|
||||
}
|
||||
|
||||
function bindAccountModal() {
|
||||
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').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';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Settings Modal ────────────────────────────────────────
|
||||
function openSettingsModal() {
|
||||
const s = state.settings;
|
||||
document.getElementById('cfg-default-view').value = s.default_view || 'month';
|
||||
document.getElementById('cfg-primary-color').value = s.primary_color || '#4285f4';
|
||||
document.getElementById('cfg-accent-color').value = s.accent_color || '#ea4335';
|
||||
document.getElementById('cfg-today-color').value = s.today_color || '#4285f4';
|
||||
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
|
||||
|
||||
document.getElementById('cfg-primary-label').textContent = s.primary_color || '#4285f4';
|
||||
document.getElementById('cfg-accent-label').textContent = s.accent_color || '#ea4335';
|
||||
document.getElementById('cfg-today-label').textContent = s.today_color || '#4285f4';
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
openModal('modal-settings');
|
||||
}
|
||||
|
||||
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-color','cfg-accent-color','cfg-today-color'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', e => {
|
||||
const labelId = id.replace('color', 'label');
|
||||
document.getElementById(labelId).textContent = e.target.value;
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
primary_color: document.getElementById('cfg-primary-color').value,
|
||||
accent_color: document.getElementById('cfg-accent-color').value,
|
||||
today_color: document.getElementById('cfg-today-color').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;
|
||||
applyTheme(settings);
|
||||
showToast('Einstellungen gespeichert');
|
||||
closeModal('modal-settings');
|
||||
renderView();
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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,'>');
|
||||
}
|
||||
53
frontend/js/utils.js
Normal file
53
frontend/js/utils.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export function isToday(d) {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
export function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function isPast(ev) {
|
||||
const end = new Date(ev.end);
|
||||
return end < new Date();
|
||||
}
|
||||
|
||||
export function formatDate(d, opts = {}) {
|
||||
return d.toLocaleDateString('de', opts);
|
||||
}
|
||||
|
||||
export function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
export function toLocalDatetimeInput(d) {
|
||||
const Y = d.getFullYear();
|
||||
const M = String(d.getMonth()+1).padStart(2,'0');
|
||||
const D = String(d.getDate()).padStart(2,'0');
|
||||
const h = String(d.getHours()).padStart(2,'0');
|
||||
const m = String(d.getMinutes()).padStart(2,'0');
|
||||
return `${Y}-${M}-${D}T${h}:${m}`;
|
||||
}
|
||||
|
||||
export function toDateInput(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
export function applyTheme(settings) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
|
||||
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
|
||||
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
const r = parseInt(hex.slice(1,3), 16);
|
||||
const g = parseInt(hex.slice(3,5), 16);
|
||||
const b = parseInt(hex.slice(5,7), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
94
frontend/js/views/agenda.js
Normal file
94
frontend/js/views/agenda.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { isPast } from '../utils.js';
|
||||
|
||||
const DOW = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
||||
const MON = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
||||
|
||||
export function renderAgenda(container, currentDate, events, onEventClick) {
|
||||
if (!events.length) {
|
||||
container.innerHTML = `<div class="agenda-view"><div class="agenda-empty">Keine Termine im angezeigten Zeitraum</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group events by date
|
||||
const groups = {};
|
||||
events.forEach(ev => {
|
||||
const d = new Date(ev.start);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(ev);
|
||||
});
|
||||
|
||||
// Sort groups
|
||||
const sortedKeys = Object.keys(groups).sort();
|
||||
|
||||
const html = sortedKeys.map(key => {
|
||||
const date = new Date(key + 'T00:00:00');
|
||||
const isToday = isTodayDate(date);
|
||||
const todayCls = isToday ? 'today' : '';
|
||||
|
||||
const evHtml = groups[key]
|
||||
.sort((a, b) => {
|
||||
if (a.allDay && !b.allDay) return -1;
|
||||
if (!a.allDay && b.allDay) return 1;
|
||||
return new Date(a.start) - new Date(b.start);
|
||||
})
|
||||
.map(ev => {
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
let timeStr = 'Ganztägig';
|
||||
if (!ev.allDay) {
|
||||
const s = new Date(ev.start);
|
||||
const e = new Date(ev.end);
|
||||
timeStr = `${fmtTime(s)} – ${fmtTime(e)}`;
|
||||
}
|
||||
const locHtml = ev.location
|
||||
? `<span class="agenda-ev-meta"> · ${escHtml(ev.location)}</span>`
|
||||
: '';
|
||||
return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}">
|
||||
<div class="agenda-ev-color" style="background:${color}"></div>
|
||||
<div class="agenda-ev-info">
|
||||
<div class="agenda-ev-title">${escHtml(ev.title)}</div>
|
||||
<div class="agenda-ev-meta">${timeStr}${locHtml}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="agenda-day">
|
||||
<div class="agenda-date ${todayCls}">
|
||||
<div class="agenda-date-num">${date.getDate()}</div>
|
||||
<div class="agenda-date-label">
|
||||
<span class="wd">${DOW[date.getDay()]}</span>
|
||||
<span class="mo">${MON[date.getMonth()]} ${date.getFullYear()}</span>
|
||||
</div>
|
||||
</div>
|
||||
${evHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `<div class="agenda-view">${html}</div>`;
|
||||
|
||||
container.querySelectorAll('.agenda-event').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
|
||||
if (ev) onEventClick(ev, el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isTodayDate(d) {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
121
frontend/js/views/month.js
Normal file
121
frontend/js/views/month.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { formatDate, isSameDay, isToday, isPast } from '../utils.js';
|
||||
|
||||
const DOW = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
export function renderMonth(container, currentDate, events, onDayClick, onEventClick) {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Start grid on Sunday of the week containing the 1st
|
||||
const gridStart = new Date(firstDay);
|
||||
gridStart.setDate(gridStart.getDate() - firstDay.getDay());
|
||||
|
||||
const cells = [];
|
||||
const d = new Date(gridStart);
|
||||
for (let i = 0; i < 42; i++) {
|
||||
cells.push(new Date(d));
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
// Build event map keyed by date string
|
||||
const evMap = {};
|
||||
events.forEach(ev => {
|
||||
const s = new Date(ev.start);
|
||||
const e = ev.allDay ? new Date(ev.end) : new Date(ev.end);
|
||||
// Spread multi-day events across cells
|
||||
const cur = new Date(s);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(e);
|
||||
endNorm.setHours(0, 0, 0, 0);
|
||||
if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
|
||||
while (cur <= endNorm) {
|
||||
const key = dateKey(cur);
|
||||
if (!evMap[key]) evMap[key] = [];
|
||||
evMap[key].push(ev);
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Header
|
||||
const headerHtml = DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
|
||||
|
||||
// Cells
|
||||
const cellsHtml = cells.map(cell => {
|
||||
const key = dateKey(cell);
|
||||
const cellEvs = (evMap[key] || []).slice().sort((a, b) => {
|
||||
if (a.allDay && !b.allDay) return -1;
|
||||
if (!a.allDay && b.allDay) return 1;
|
||||
return new Date(a.start) - new Date(b.start);
|
||||
});
|
||||
|
||||
const isOther = cell.getMonth() !== month;
|
||||
const todayClass = isToday(cell) ? 'today' : '';
|
||||
const otherClass = isOther ? 'other-month' : '';
|
||||
const numClass = isToday(cell) ? 'today' : '';
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
const visible = cellEvs.slice(0, MAX_VISIBLE);
|
||||
const hiddenCount = cellEvs.length - MAX_VISIBLE;
|
||||
|
||||
const evHtml = visible.map(ev => {
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastClass = isPast(ev) ? 'past' : '';
|
||||
const title = ev.allDay ? ev.title : `${fmtTime(new Date(ev.start))} ${ev.title}`;
|
||||
return `<div class="month-event ${pastClass}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"
|
||||
style="background:${color};color:#fff"
|
||||
title="${escAttr(ev.title)}">${escHtml(title)}</div>`;
|
||||
}).join('');
|
||||
|
||||
const moreHtml = hiddenCount > 0
|
||||
? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>`
|
||||
: '';
|
||||
|
||||
return `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
|
||||
<div class="cell-day ${numClass}">${cell.getDate()}</div>
|
||||
${evHtml}${moreHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `<div class="month-view">
|
||||
<div class="month-header">${headerHtml}</div>
|
||||
<div class="month-grid">${cellsHtml}</div>
|
||||
</div>`;
|
||||
|
||||
// Events
|
||||
container.querySelectorAll('.month-cell').forEach(cell => {
|
||||
cell.addEventListener('click', e => {
|
||||
const evEl = e.target.closest('.month-event');
|
||||
if (evEl) {
|
||||
e.stopPropagation();
|
||||
const ev = events.find(ev => ev.id === evEl.dataset.id && ev.url === evEl.dataset.url);
|
||||
if (ev) onEventClick(ev, evEl);
|
||||
return;
|
||||
}
|
||||
const moreEl = e.target.closest('.month-more');
|
||||
if (moreEl) {
|
||||
e.stopPropagation();
|
||||
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
|
||||
return;
|
||||
}
|
||||
onDayClick(new Date(cell.dataset.date + 'T00:00:00'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
244
frontend/js/views/week.js
Normal file
244
frontend/js/views/week.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import { isToday, isPast } from '../utils.js';
|
||||
|
||||
const DOW_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false) {
|
||||
// Build the days array (7 days for week, 1 for day)
|
||||
const days = [];
|
||||
if (isSingleDay) {
|
||||
days.push(new Date(currentDate));
|
||||
} else {
|
||||
const sunday = new Date(currentDate);
|
||||
sunday.setDate(sunday.getDate() - sunday.getDay());
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(sunday);
|
||||
d.setDate(d.getDate() + i);
|
||||
days.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate all-day and timed events
|
||||
const allDayEvs = events.filter(ev => ev.allDay);
|
||||
const timedEvs = events.filter(ev => !ev.allDay);
|
||||
|
||||
// ── Header ────────────────────────────────────────────
|
||||
const headerCols = days.map(day => {
|
||||
const todayCls = isToday(day) ? 'today' : '';
|
||||
return `<div class="week-day-header ${todayCls}" data-date="${dayKey(day)}">
|
||||
<div class="day-name">${DOW_SHORT[day.getDay()]}</div>
|
||||
<div class="day-num">${day.getDate()}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// ── All-day row ───────────────────────────────────────
|
||||
const alldayCols = days.map(day => {
|
||||
const key = dayKey(day);
|
||||
const dayEvs = allDayEvs.filter(ev => {
|
||||
const s = new Date(ev.start); s.setHours(0,0,0,0);
|
||||
const e = new Date(ev.end); e.setHours(0,0,0,0);
|
||||
const d = new Date(day); d.setHours(0,0,0,0);
|
||||
return d >= s && d < e || isSameDay(d, s);
|
||||
});
|
||||
const inner = dayEvs.map(ev => {
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
return `<div class="allday-event" style="background:${color};color:#fff"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(ev.title)}</div>`;
|
||||
}).join('');
|
||||
return `<div class="allday-col" data-date="${key}">${inner}</div>`;
|
||||
}).join('');
|
||||
|
||||
// ── Time column labels ────────────────────────────────
|
||||
const timeLabels = Array.from({length: 24}, (_, h) =>
|
||||
`<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>`
|
||||
).join('');
|
||||
|
||||
// ── Day columns ───────────────────────────────────────
|
||||
// For each day, lay out timed events
|
||||
const dayCols = days.map(day => {
|
||||
const key = dayKey(day);
|
||||
const dayEvs = timedEvs.filter(ev => {
|
||||
const s = new Date(ev.start);
|
||||
return isSameDay(s, day);
|
||||
});
|
||||
|
||||
// Compute layout columns for overlapping events
|
||||
const positioned = layoutEvents(dayEvs);
|
||||
|
||||
const hourLines = Array.from({length: 24}, (_, h) =>
|
||||
`<div class="hour-line" style="top:${h * 60}px"><div class="half-line"></div></div>`
|
||||
).join('');
|
||||
|
||||
const evHtml = positioned.map(({ ev, col, cols }) => {
|
||||
const s = new Date(ev.start);
|
||||
const e = new Date(ev.end);
|
||||
const top = (s.getHours() * 60 + s.getMinutes());
|
||||
const height = Math.max(20, (e - s) / 60000);
|
||||
const left = (col / cols) * 100;
|
||||
const width = (1 / cols) * 100 - 0.5;
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
const startStr = fmtTime(s);
|
||||
const locHtml = ev.location ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
|
||||
return `<div class="timed-event ${pastCls}"
|
||||
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
|
||||
<div class="ev-time">${startStr}</div>
|
||||
<div class="ev-title">${escHtml(ev.title)}</div>
|
||||
${locHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="week-day-col" data-date="${key}" style="height:${60*24}px">
|
||||
${hourLines}
|
||||
${evHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const viewClass = isSingleDay ? 'day-view' : 'week-view';
|
||||
|
||||
container.innerHTML = `<div class="${viewClass}">
|
||||
<div class="week-header-row">
|
||||
<div class="week-time-gutter"></div>
|
||||
${headerCols}
|
||||
</div>
|
||||
<div class="week-allday-row">
|
||||
<div class="allday-gutter">ganztägig</div>
|
||||
<div class="allday-cols">${alldayCols}</div>
|
||||
</div>
|
||||
<div class="week-body">
|
||||
<div class="week-time-col">${timeLabels}</div>
|
||||
<div class="week-days-col">${dayCols}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Scroll to ~8:00
|
||||
const body = container.querySelector('.week-body');
|
||||
if (body) body.scrollTop = 8 * 60 - 20;
|
||||
|
||||
// Render current-time line
|
||||
renderNowLine(container, days);
|
||||
|
||||
// Click: slot
|
||||
container.querySelectorAll('.week-day-col').forEach(col => {
|
||||
col.addEventListener('click', e => {
|
||||
if (e.target.closest('.timed-event')) return;
|
||||
const rect = col.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top + (container.querySelector('.week-body')?.scrollTop || 0);
|
||||
const mins = Math.floor(y);
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = Math.round((mins % 60) / 15) * 15;
|
||||
const date = new Date(col.dataset.date + 'T00:00:00');
|
||||
date.setHours(h, m, 0, 0);
|
||||
onSlotClick(date);
|
||||
});
|
||||
});
|
||||
|
||||
// Click: header (navigate to day)
|
||||
container.querySelectorAll('.week-day-header').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
onSlotClick(new Date(el.dataset.date + 'T09:00:00'), true);
|
||||
});
|
||||
});
|
||||
|
||||
// Click: timed event
|
||||
container.querySelectorAll('.timed-event').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
|
||||
if (ev) onEventClick(ev, el);
|
||||
});
|
||||
});
|
||||
|
||||
// Click: all-day event
|
||||
container.querySelectorAll('.allday-event').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
|
||||
if (ev) onEventClick(ev, el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderNowLine(container, days) {
|
||||
const now = new Date();
|
||||
const todayCol = container.querySelector(`.week-day-col[data-date="${dayKey(now)}"]`);
|
||||
if (!todayCol) return;
|
||||
|
||||
const top = now.getHours() * 60 + now.getMinutes();
|
||||
const line = document.createElement('div');
|
||||
line.className = 'now-line';
|
||||
line.style.top = top + 'px';
|
||||
line.innerHTML = '<div class="now-dot"></div>';
|
||||
todayCol.appendChild(line);
|
||||
|
||||
// Update every minute
|
||||
setTimeout(() => renderNowLine(container, days), 60000);
|
||||
}
|
||||
|
||||
function layoutEvents(events) {
|
||||
if (!events.length) return [];
|
||||
|
||||
// Sort by start time
|
||||
const sorted = events.slice().sort((a, b) => new Date(a.start) - new Date(b.start));
|
||||
const columns = []; // each column is an array of events
|
||||
|
||||
const result = sorted.map(ev => {
|
||||
const start = new Date(ev.start);
|
||||
const end = new Date(ev.end);
|
||||
|
||||
// Find the first column where the event doesn't overlap
|
||||
let placed = false;
|
||||
for (let c = 0; c < columns.length; c++) {
|
||||
const lastInCol = columns[c][columns[c].length - 1];
|
||||
if (new Date(lastInCol.end) <= start) {
|
||||
columns[c].push(ev);
|
||||
placed = true;
|
||||
ev._col = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
ev._col = columns.length;
|
||||
columns.push([ev]);
|
||||
}
|
||||
return ev;
|
||||
});
|
||||
|
||||
// Calculate how many columns each event spans
|
||||
return result.map(ev => {
|
||||
const start = new Date(ev.start);
|
||||
const end = new Date(ev.end);
|
||||
// Count overlapping events
|
||||
let maxCol = ev._col;
|
||||
sorted.forEach(other => {
|
||||
if (other === ev) return;
|
||||
const os = new Date(other.start);
|
||||
const oe = new Date(other.end);
|
||||
if (os < end && oe > start) {
|
||||
maxCol = Math.max(maxCol, other._col ?? 0);
|
||||
}
|
||||
});
|
||||
return { ev, col: ev._col, cols: maxCol + 1 };
|
||||
});
|
||||
}
|
||||
|
||||
function dayKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
Reference in New Issue
Block a user