590 lines
34 KiB
HTML
590 lines
34 KiB
HTML
{{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…</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 → 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}}
|