Files
mailgoserver/internal/webui/templates/webmail_calendar.html
T

590 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "webmail_calendar.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar - Webmail</title>
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="/webmail/static/img/favicon-webmail.ico">
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
<style>
html, body { background-color: #1a1a1a; color: #e0e0e0; height: 100%; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.cal-shell { padding: 1rem 1.5rem; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); border: 1px solid #404040; border-radius: .375rem; overflow: hidden; }
.cal-dow { background-color: #242424; text-align: center; padding: .4rem 0; font-size: .78rem; text-transform: uppercase; color: #9a9a9a; border-bottom: 1px solid #404040; }
.cal-day { min-height: 110px; border-right: 1px solid #333; border-bottom: 1px solid #333; padding: .3rem; cursor: pointer; display: flex; flex-direction: column; gap: .2rem; overflow-y: auto; }
.cal-day.cal-day-tall { min-height: 60vh; }
.cal-day:hover { background-color: #232323; }
.cal-day.other-month { background-color: #1f1f1f; color: #666; }
.cal-day.today .cal-day-num { background-color: #0d6efd; color: #fff; border-radius: 50%; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; }
.cal-day-num { font-size: .85rem; }
.cal-chip { font-size: .72rem; padding: 1px 5px; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.cal-chip.all-day { border-radius: 10px; }
.cal-chip:hover { filter: brightness(1.15); }
.cal-more { font-size: .7rem; color: #9a9a9a; cursor: pointer; }
.cal-agenda-item { display: flex; gap: 1rem; padding: .75rem 1rem; border-left: 4px solid; border-bottom: 1px solid #333; cursor: pointer; align-items: flex-start; }
.cal-agenda-item:hover { background-color: #232323; }
.cal-agenda-time { width: 150px; flex: 0 0 auto; color: #9a9a9a; font-size: .85rem; }
.cal-agenda-title { font-weight: 600; }
.cal-agenda-loc { font-size: .82rem; color: #9a9a9a; }
.cal-list-month-header { border-bottom: 1px solid #404040; padding-bottom: .3rem; }
.cal-list-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; flex: 0 0 auto; }
.cal-list-table td { vertical-align: middle; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark px-3" style="height: 56px;">
<span class="navbar-brand mb-0 h1"><i class="bi bi-calendar3 me-2"></i>Calendar <small class="text-muted">{{.mailbox.Email}}</small></span>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Settings"><i class="bi bi-gear"></i></a>
{{template "webmail_account_menu" .}}
</div>
</nav>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<div class="cal-shell">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="calPrevBtn"><i class="bi bi-chevron-left"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="calTodayBtn">Today</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="calNextBtn"><i class="bi bi-chevron-right"></i></button>
<h5 class="mb-0 ms-2" id="calViewLabel"></h5>
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" aria-label="Calendar view">
<input type="radio" class="btn-check" name="calView" id="calViewDay" value="day" autocomplete="off">
<label class="btn btn-outline-secondary" for="calViewDay">Day</label>
<input type="radio" class="btn-check" name="calView" id="calViewWeek" value="week" autocomplete="off">
<label class="btn btn-outline-secondary" for="calViewWeek">Week</label>
<input type="radio" class="btn-check" name="calView" id="calViewMonth" value="month" autocomplete="off">
<label class="btn btn-outline-secondary" for="calViewMonth">Month</label>
<input type="radio" class="btn-check" name="calView" id="calViewList" value="list" autocomplete="off">
<label class="btn btn-outline-secondary" for="calViewList">List</label>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" id="calDavInfoBtn" title="CalDAV sync setup"><i class="bi bi-info-circle"></i></button>
<button type="button" class="btn btn-primary btn-sm" id="calAddBtn"><i class="bi bi-plus-lg me-1"></i>Add event</button>
</div>
</div>
<div id="calViewContainer"></div>
</div>
<div class="modal fade" id="eventModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/webmail/calendar/events/save" id="eventForm" novalidate>
<div class="modal-header">
<h5 class="modal-title" id="eventModalTitle">New event</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id" id="eventFormId" value="">
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Title</label>
<input type="text" class="form-control" name="summary" id="eventFormSummary" placeholder="Team standup" required>
</div>
<div class="col-auto">
<label class="form-label">Color</label>
<input type="color" class="form-control form-control-color" name="color" id="eventFormColor" value="{{.default_color}}" title="Event color">
</div>
</div>
<div class="mb-3">
<label class="form-label">Location <span class="text-muted">(optional)</span></label>
<input type="text" class="form-control" name="location" id="eventFormLocation">
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="eventFormAllDay">
<label class="form-check-label" for="eventFormAllDay">All day</label>
</div>
<input type="hidden" name="all_day" id="eventFormAllDayField" value="0">
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Starts</label>
<input type="datetime-local" class="form-control form-control-sm" name="start" id="eventFormStart" required>
</div>
<div class="col">
<label class="form-label">Ends</label>
<input type="datetime-local" class="form-control form-control-sm" name="end" id="eventFormEnd" required>
<div class="invalid-feedback" id="eventFormEndError"></div>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Repeats</label>
<select class="form-select form-select-sm" name="recurrence" id="eventFormRecurrence">
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div class="col">
<label class="form-label">Remind me</label>
<select class="form-select form-select-sm" name="reminder" id="eventFormReminder">
<option value="">No reminder</option>
<option value="5">5 minutes before</option>
<option value="15">15 minutes before</option>
<option value="30">30 minutes before</option>
<option value="60">1 hour before</option>
</select>
</div>
</div>
<div class="mb-1">
<label class="form-label">Description <span class="text-muted">(optional)</span></label>
<textarea class="form-control" name="description" id="eventFormDescription" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger me-auto d-none" id="eventFormDeleteBtn"><i class="bi bi-trash me-1"></i>Delete</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save</button>
</div>
</form>
</div>
</div>
</div>
<form method="post" id="eventDeleteForm" class="d-none"></form>
<div class="modal fade" id="calDavInfoModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-info-circle me-2"></i>Sync with Apple Calendar, Thunderbird, DAVx5&hellip;</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Add a new CalDAV account in your calendar app using:</p>
<ul>
<li>Server: this server's own address, path <code>/caldav/</code></li>
<li>Username: <code>{{.mailbox.Email}}</code></li>
<li>Password: an app password from <a href="/webmail/account" class="link-light">Account &rarr; App Passwords</a> — not your regular sign-in password.</li>
</ul>
<p class="text-muted small mb-0">Reminders you set here sync as calendar alarms, so your calendar app notifies you natively — this server doesn't send its own reminder emails/notifications.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
</div>
</div>
</div>
</div>
{{template "compose_widget" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/webmail/static/vendor/quill/quill.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
function showConfirmation(message) {
return new Promise((resolve) => {
const modal = document.getElementById('confirmationModal');
document.getElementById('confirmationModalBody').textContent = message;
const confirmButton = document.getElementById('confirmationModalConfirm');
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
const handleCancel = () => { resolve(false); cleanup(); };
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
modal.removeEventListener('hidden.bs.modal', handleCancel);
};
confirmButton.addEventListener('click', handleConfirm);
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
new bootstrap.Modal(modal).show();
});
}
(function() {
const container = document.getElementById('calViewContainer');
const viewLabel = document.getElementById('calViewLabel');
const eventModalEl = document.getElementById('eventModal');
const eventModal = new bootstrap.Modal(eventModalEl);
const form = document.getElementById('eventForm');
const allDayCheck = document.getElementById('eventFormAllDay');
const allDayField = document.getElementById('eventFormAllDayField');
const startField = document.getElementById('eventFormStart');
const endField = document.getElementById('eventFormEnd');
const endError = document.getElementById('eventFormEndError');
const colorField = document.getElementById('eventFormColor');
const deleteBtn = document.getElementById('eventFormDeleteBtn');
const deleteForm = document.getElementById('eventDeleteForm');
const defaultColor = colorField.value || '#0d6efd';
let viewMode = localStorage.getItem('webmail_calendar_view') || 'week';
if (!['day', 'week', 'month', 'list'].includes(viewMode)) viewMode = 'week';
let cursor = new Date();
function pad(n) { return String(n).padStart(2, '0'); }
function fmtDate(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
function fmtTime(iso) { return new Date(iso).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); }
function fmtDateShort(iso) { return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
function startOfDay(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }
// Monday-first week start, regardless of browser/locale default.
function startOfWeek(d) {
const x = startOfDay(d);
const day = x.getDay(); // 0=Sun..6=Sat
x.setDate(x.getDate() + (day === 0 ? -6 : 1 - day));
return x;
}
// Readable text color for a chip/swatch background — plain contrast heuristic,
// not a full WCAG calculation, good enough for arbitrary user-picked colors.
function contrastColor(hex) {
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.55 ? '#000' : '#fff';
}
function groupByDay(events) {
const perDay = {};
(events || []).forEach(function(e) { (perDay[e.start.slice(0, 10)] = perDay[e.start.slice(0, 10)] || []).push(e); });
Object.values(perDay).forEach(function(list) { list.sort(function(a, b) { return a.start < b.start ? -1 : 1; }); });
return perDay;
}
allDayCheck.addEventListener('change', function() {
allDayField.value = allDayCheck.checked ? '1' : '0';
startField.type = allDayCheck.checked ? 'date' : 'datetime-local';
endField.type = allDayCheck.checked ? 'date' : 'datetime-local';
});
function clearEndError() { endField.classList.remove('is-invalid'); }
startField.addEventListener('input', clearEndError);
endField.addEventListener('input', clearEndError);
// Catch a bad date range (e.g. end before start) before it ever reaches the
// server — a round trip through a server-side error redirect would otherwise
// discard the whole form, forcing the event to be re-entered from scratch.
form.addEventListener('submit', function(ev) {
if (!startField.value || !endField.value) return; // let the native "required" handle empty fields
const allDay = allDayCheck.checked;
const invalid = allDay ? (endField.value < startField.value) : (endField.value <= startField.value);
if (invalid) {
ev.preventDefault();
endError.textContent = allDay ? 'End date must be on or after the start date.' : 'End time must be after the start time.';
endField.classList.add('is-invalid');
endField.focus();
}
});
function openAddModal(dateStr) {
form.reset();
document.getElementById('eventFormId').value = '';
document.getElementById('eventModalTitle').textContent = 'New event';
deleteBtn.classList.add('d-none');
clearEndError();
allDayCheck.checked = false;
allDayField.value = '0';
startField.type = 'datetime-local';
endField.type = 'datetime-local';
colorField.value = defaultColor;
if (dateStr) {
startField.value = dateStr + 'T09:00';
endField.value = dateStr + 'T10:00';
}
eventModal.show();
}
async function openEditModal(id) {
const resp = await fetch('/webmail/calendar/events/' + id);
if (!resp.ok) return;
const e = await resp.json();
form.reset();
clearEndError();
document.getElementById('eventFormId').value = e.id;
document.getElementById('eventModalTitle').textContent = 'Edit event';
document.getElementById('eventFormSummary').value = e.summary;
document.getElementById('eventFormLocation').value = e.location;
document.getElementById('eventFormDescription').value = e.description;
document.getElementById('eventFormRecurrence').value = e.recurrence;
document.getElementById('eventFormReminder').value = e.reminder_minutes ? String(e.reminder_minutes) : '';
colorField.value = e.color || defaultColor;
allDayCheck.checked = e.all_day;
allDayField.value = e.all_day ? '1' : '0';
startField.type = e.all_day ? 'date' : 'datetime-local';
endField.type = e.all_day ? 'date' : 'datetime-local';
startField.value = e.start;
endField.value = e.end;
deleteBtn.classList.remove('d-none');
deleteBtn.onclick = async function() {
if (await showConfirmation('Delete "' + e.summary + '"?' + (e.recurrence !== 'none' ? ' This deletes the whole repeating series.' : ''))) {
deleteForm.action = '/webmail/calendar/events/' + e.id + '/delete';
deleteForm.innerHTML = '<input type="hidden" name="csrf_token" value="' + (window.__csrfToken || '') + '">';
deleteForm.submit();
}
};
eventModal.show();
}
window.__calOpenEditModal = openEditModal;
document.getElementById('calAddBtn').addEventListener('click', function() { openAddModal(fmtDate(new Date())); });
// ---- grid rendering (shared by month/week — week is just a 1-row grid) ----
function renderGrid(gridStart, gridEnd, events, focusMonth, maxChips) {
const grid = document.createElement('div');
grid.className = 'cal-grid';
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(function(d) {
const el = document.createElement('div');
el.className = 'cal-dow';
el.textContent = d;
grid.appendChild(el);
});
const today = fmtDate(new Date());
const perDay = groupByDay(events);
const tall = focusMonth === null; // week view: one row, give it real height
for (let d = new Date(gridStart); d < gridEnd; d.setDate(d.getDate() + 1)) {
const dateStr = fmtDate(d);
const dayEl = document.createElement('div');
dayEl.className = 'cal-day' + (tall ? ' cal-day-tall' : '') +
(focusMonth !== null && d.getMonth() !== focusMonth ? ' other-month' : '') +
(dateStr === today ? ' today' : '');
dayEl.dataset.date = dateStr;
const num = document.createElement('span');
num.className = 'cal-day-num';
num.textContent = tall ? d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' }) : d.getDate();
dayEl.appendChild(num);
dayEl.addEventListener('click', function(e) {
if (e.target === dayEl || e.target === num) openAddModal(dateStr);
});
grid.appendChild(dayEl);
const evs = perDay[dateStr] || [];
const shown = evs.slice(0, maxChips);
shown.forEach(function(e) {
const chip = document.createElement('div');
chip.className = 'cal-chip' + (e.all_day ? ' all-day' : '');
chip.style.backgroundColor = e.color;
chip.style.color = contrastColor(e.color);
const time = e.all_day ? '' : fmtTime(e.start) + ' ';
chip.textContent = time + e.summary;
chip.title = e.summary + (e.location ? ' — ' + e.location : '');
chip.addEventListener('click', function(ev) { ev.stopPropagation(); openEditModal(e.id); });
dayEl.appendChild(chip);
});
if (evs.length > shown.length) {
const more = document.createElement('div');
more.className = 'cal-more';
more.textContent = '+' + (evs.length - shown.length) + ' more';
more.addEventListener('click', function(ev) { ev.stopPropagation(); openEditModal(evs[shown.length].id); });
dayEl.appendChild(more);
}
}
container.appendChild(grid);
}
// ---- day view: agenda list ----
function renderDayAgenda(day, events) {
const dateStr = fmtDate(day);
const list = (events || []).filter(function(e) { return e.start.slice(0, 10) === dateStr; });
list.sort(function(a, b) { return a.start < b.start ? -1 : 1; });
if (!list.length) {
const p = document.createElement('p');
p.className = 'text-muted text-center py-5';
p.textContent = 'No events this day.';
container.appendChild(p);
return;
}
const wrap = document.createElement('div');
wrap.className = 'border rounded';
wrap.style.borderColor = '#404040';
list.forEach(function(e) {
const item = document.createElement('div');
item.className = 'cal-agenda-item';
item.style.borderLeftColor = e.color;
const time = document.createElement('div');
time.className = 'cal-agenda-time';
time.textContent = e.all_day ? 'All day' : (fmtTime(e.start) + ' ' + fmtTime(e.end));
const body = document.createElement('div');
const title = document.createElement('div');
title.className = 'cal-agenda-title';
title.textContent = e.summary;
body.appendChild(title);
if (e.location) {
const loc = document.createElement('div');
loc.className = 'cal-agenda-loc';
loc.textContent = e.location;
body.appendChild(loc);
}
item.append(time, body);
item.addEventListener('click', function() { openEditModal(e.id); });
wrap.appendChild(item);
});
container.appendChild(wrap);
}
// ---- list view: events grouped by month, Title/Time/Description/Location ----
function renderMonthlyList(events, year) {
const byMonth = {};
(events || []).forEach(function(e) {
const m = new Date(e.start).getMonth();
(byMonth[m] = byMonth[m] || []).push(e);
});
let any = false;
for (let m = 0; m < 12; m++) {
const list = byMonth[m];
if (!list || !list.length) continue;
any = true;
list.sort(function(a, b) { return a.start < b.start ? -1 : 1; });
const header = document.createElement('h6');
header.className = 'cal-list-month-header mt-4';
header.textContent = new Date(year, m, 1).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
container.appendChild(header);
const table = document.createElement('table');
table.className = 'table table-dark table-hover table-sm cal-list-table';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['Title', 'Time', 'Description', 'Location'].forEach(function(h) {
const th = document.createElement('th');
th.textContent = h;
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
list.forEach(function(e) {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.addEventListener('click', function() { openEditModal(e.id); });
const tdTitle = document.createElement('td');
const swatch = document.createElement('span');
swatch.className = 'cal-list-swatch';
swatch.style.backgroundColor = e.color;
tdTitle.appendChild(swatch);
tdTitle.append(e.summary);
const tdTime = document.createElement('td');
tdTime.textContent = fmtDateShort(e.start) + ' ' + (e.all_day ? 'All day' : fmtTime(e.start));
const tdDesc = document.createElement('td');
tdDesc.textContent = e.description || '';
tdDesc.style.maxWidth = '280px';
tdDesc.className = 'text-truncate';
const tdLoc = document.createElement('td');
tdLoc.textContent = e.location || '';
tr.append(tdTitle, tdTime, tdDesc, tdLoc);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
}
if (!any) {
const p = document.createElement('p');
p.className = 'text-muted text-center py-5';
p.textContent = 'No events in ' + year + '.';
container.appendChild(p);
}
}
// ---- view range/label/navigation ----
function rangeForView() {
if (viewMode === 'month') {
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const start = startOfWeek(first);
return { start: start, end: addDays(start, 42) };
}
if (viewMode === 'week') {
const start = startOfWeek(cursor);
return { start: start, end: addDays(start, 7) };
}
if (viewMode === 'day') {
const start = startOfDay(cursor);
return { start: start, end: addDays(start, 1) };
}
const start = new Date(cursor.getFullYear(), 0, 1);
return { start: start, end: new Date(cursor.getFullYear() + 1, 0, 1) };
}
function labelForView(range) {
if (viewMode === 'month') return cursor.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
if (viewMode === 'week') {
// Always includes month on both ends — Intl.DateTimeFormat produces
// oddly-worded output for some locales when asked for day+year with no
// month (the "same month" trimmed-down form), so this doesn't bother.
const end = addDays(range.start, 6);
return range.start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
end.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ', ' + end.getFullYear();
}
if (viewMode === 'day') return cursor.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
return String(cursor.getFullYear());
}
function stepCursor(dir) {
if (viewMode === 'month') cursor.setMonth(cursor.getMonth() + dir);
else if (viewMode === 'week') cursor.setDate(cursor.getDate() + dir * 7);
else if (viewMode === 'day') cursor.setDate(cursor.getDate() + dir);
else cursor.setFullYear(cursor.getFullYear() + dir);
}
async function loadView() {
const range = rangeForView();
viewLabel.textContent = labelForView(range);
const resp = await fetch('/webmail/calendar/events?start=' + fmtDate(range.start) + '&end=' + fmtDate(range.end));
const events = resp.ok ? await resp.json() : [];
container.innerHTML = '';
if (viewMode === 'month') renderGrid(range.start, range.end, events, cursor.getMonth(), 3);
else if (viewMode === 'week') renderGrid(range.start, range.end, events, null, 8);
else if (viewMode === 'day') renderDayAgenda(range.start, events);
else renderMonthlyList(events, cursor.getFullYear());
}
document.getElementById('calPrevBtn').addEventListener('click', function() { stepCursor(-1); loadView(); });
document.getElementById('calNextBtn').addEventListener('click', function() { stepCursor(1); loadView(); });
document.getElementById('calTodayBtn').addEventListener('click', function() { cursor = new Date(); loadView(); });
document.getElementById('calView' + viewMode.charAt(0).toUpperCase() + viewMode.slice(1)).checked = true;
document.querySelectorAll('input[name="calView"]').forEach(function(radio) {
radio.addEventListener('change', function() {
if (!radio.checked) return;
viewMode = radio.value;
localStorage.setItem('webmail_calendar_view', viewMode);
loadView();
});
});
loadView();
})();
</script>
</body>
</html>
{{end}}