A job every minute finds the event reminders that fell due — repeating events and moved occurrences expanded as the calendar view does — and pushes each once to every device with notifications on (sdk.push, new: plugins can notify through the new-mail push delivery). A reminder missed while the worker was down is still sent within 10 minutes; cancelled events and declined invitations stay quiet. Title, then "In 15 minutes, 14:00 · Room 2" in the user's zone and clock format; it stays until dismissed and shows even with the app open; clicking it opens that day (/p/calendar?date=…, new deep link). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1901 lines
110 KiB
JavaScript
1901 lines
110 KiB
JavaScript
// calendar · app.page bundle (SPEC §A.6). A full-bleed calendar app: product
|
||
// switcher, left panel (new event, mini-month, calendars), main view
|
||
// (month/week/day/3-days) and a toggleable right agenda panel. Framework-agnostic
|
||
// vanilla DOM; theme-aware via rgb(var(--app-*)); events come from the plugin API.
|
||
|
||
const HOUR_PX = 48;
|
||
const DAYNAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||
|
||
// ---- date helpers (browser-local rendering) --------------------------------
|
||
const startOfDay = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; };
|
||
const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
|
||
const startOfWeek = (d) => addDays(startOfDay(d), -((new Date(d).getDay() + 6) % 7)); // Monday
|
||
const startOfMonth = (d) => new Date(d.getFullYear(), d.getMonth(), 1);
|
||
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||
const iso = (d) => d.toISOString();
|
||
const esc = (s) => String(s == null ? "" : s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
||
// 12h/24h time preference (from core user settings `time_format`; 24h is default).
|
||
let HOUR12 = false;
|
||
// Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US.
|
||
const dateLocale = () => (HOUR12 ? "en-US" : "en-GB");
|
||
// Format a native date input's ISO value (yyyy-mm-dd) for display in the preferred
|
||
// order — the native <input type=date> text is fixed to the browser locale, so the
|
||
// form overlays this label instead.
|
||
const fmtDateInput = (iso) => {
|
||
const [y, m, d] = String(iso || "").split("-").map(Number);
|
||
return y && m && d ? new Date(y, m - 1, d).toLocaleDateString(dateLocale()) : "";
|
||
};
|
||
const pad2 = (n) => String(n).padStart(2, "0");
|
||
// Icons copied from Mail (frontend/icons/*.svg) so the two apps match exactly.
|
||
const HIDE_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m4 4l16 16m-3.5-3.244C15.147 17.485 13.618 18 12 18c-3.53 0-6.634-2.452-8.413-4.221c-.47-.467-.705-.7-.854-1.159c-.107-.327-.107-.913 0-1.24c.15-.459.385-.693.855-1.16c.897-.892 2.13-1.956 3.584-2.793M19.5 14.634c.333-.293.638-.582.912-.854l.003-.003c.468-.466.703-.7.852-1.156c.107-.327.107-.914 0-1.241c-.15-.458-.384-.692-.854-1.159C18.633 8.452 15.531 6 12 6q-.507 0-1 .064m2.323 7.436a2 2 0 0 1-2.762-2.889"/></svg>`;
|
||
const SHOW_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3.587 13.779c1.78 1.769 4.883 4.22 8.413 4.22s6.634-2.451 8.413-4.22c.47-.467.705-.7.854-1.159c.107-.327.107-.913 0-1.24c-.15-.458-.385-.692-.854-1.159C18.633 8.452 15.531 6 12 6c-3.53 0-6.634 2.452-8.413 4.221c-.47.467-.705.7-.854 1.159c-.107.327-.107.913 0 1.24c.15.458.384.692.854 1.159"/><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/></svg>`;
|
||
const SETTINGS_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 7.5h-7.628a2.251 2.251 0 0 0-4.244 0H5V9h2.128a2.25 2.25 0 0 0 4.244 0H19zm0 7.5h-2.128a2.251 2.251 0 0 0-4.244 0H5v1.5h7.628a2.251 2.251 0 0 0 4.244 0H19z"/></svg>`;
|
||
const LOGOUT_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>`;
|
||
const COMPOSE_ICON = `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="m14.515.456l.965.965a1.555 1.555 0 0 1 0 2.2L9.745 9.355a1.55 1.55 0 0 1-.672.396l-2.89.826a.67.67 0 0 1-.828-.474a.66.66 0 0 1 .004-.35l.825-2.89c.073-.254.209-.486.396-.673L12.315.456c.144-.145.316-.259.505-.337a1.54 1.54 0 0 1 1.19 0c.189.078.361.192.505.337m-3.322 3.008l-3.67 3.669a.2.2 0 0 0-.057.096L6.97 8.965l1.736-.496a.2.2 0 0 0 .096-.056l3.67-3.67Zm2.065-2.066L12.135 2.52l1.28 1.28l1.122-1.122a.22.22 0 0 0 .065-.157a.22.22 0 0 0-.065-.157l-.965-.966a.22.22 0 0 0-.157-.065a.23.23 0 0 0-.157.065"/><path d="M0 14.25V2.75A1.75 1.75 0 0 1 1.75 1H7a.75.75 0 0 1 0 1.5H1.75a.25.25 0 0 0-.25.25v11.5a.25.25 0 0 0 .25.25h11.5a.25.25 0 0 0 .25-.25V8.5a.75.75 0 0 1 1.5 0v5.75c0 .464-.184.909-.513 1.237A1.75 1.75 0 0 1 13.25 16H1.75A1.75 1.75 0 0 1 0 14.25"/></svg>`;
|
||
const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
|
||
const hhmm = (d) => d.toLocaleTimeString([], { hour: HOUR12 ? "numeric" : "2-digit", minute: "2-digit", hour12: HOUR12 });
|
||
// A clock label for "HH"/"MM" honouring the 12h/24h preference (form time dropdown).
|
||
const fmtHM = (h, m) => (HOUR12 ? `${h % 12 || 12}:${pad2(m)} ${h < 12 ? "AM" : "PM"}` : `${pad2(h)}:${pad2(m)}`);
|
||
// <option>s at 15-min steps (native <input type=time> can't be forced to 24h).
|
||
function timeOptionsHtml(selected) {
|
||
const vals = [];
|
||
for (let h = 0; h < 24; h++) for (let m = 0; m < 60; m += 15) vals.push(`${pad2(h)}:${pad2(m)}`);
|
||
if (selected && !vals.includes(selected)) vals.push(selected); // keep off-grid seed value
|
||
vals.sort();
|
||
return vals.map((v) => {
|
||
const [h, m] = v.split(":").map(Number);
|
||
return `<option value="${v}"${v === selected ? " selected" : ""}>${fmtHM(h, m)}</option>`;
|
||
}).join("");
|
||
}
|
||
// A random Jitsi Meet room slug for generated video-call links.
|
||
const jitsiRoom = () => (Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 8));
|
||
const jitsiLink = (server) => `${(server || "https://meet.jit.si/").replace(/\/?$/, "/")}${jitsiRoom()}`;
|
||
// Assign overlapping timed events to side-by-side columns (like Google Calendar):
|
||
// split into clusters of mutually-overlapping events, greedily pack each into the
|
||
// first free column, and return a Map ev -> { col, total } for width/left maths.
|
||
function layoutDay(evs) {
|
||
const layout = new Map();
|
||
const sorted = [...evs].sort((a, b) => a._s - b._s || a._e - b._e);
|
||
let cluster = [];
|
||
let clusterEnd = -Infinity;
|
||
const flush = () => {
|
||
if (!cluster.length) return;
|
||
const colEnds = [];
|
||
cluster.forEach((ev) => {
|
||
let ci = colEnds.findIndex((end) => end <= ev._s.getTime());
|
||
if (ci === -1) { ci = colEnds.length; colEnds.push(0); }
|
||
colEnds[ci] = ev._e.getTime();
|
||
layout.set(ev, { col: ci });
|
||
});
|
||
cluster.forEach((ev) => { layout.get(ev).total = colEnds.length; });
|
||
cluster = [];
|
||
clusterEnd = -Infinity;
|
||
};
|
||
sorted.forEach((ev) => {
|
||
if (cluster.length && ev._s.getTime() >= clusterEnd) flush();
|
||
cluster.push(ev);
|
||
clusterEnd = Math.max(clusterEnd, ev._e.getTime());
|
||
});
|
||
flush();
|
||
return layout;
|
||
}
|
||
|
||
// Parse an RRULE string back into the repeat-form fields (FREQ/INTERVAL/COUNT/UNTIL).
|
||
function parseRrule(rrule) {
|
||
const out = { interval: 1 };
|
||
String(rrule).split(";").forEach((part) => {
|
||
const [k, v] = part.split("=");
|
||
if (k === "FREQ") out.freq = v;
|
||
else if (k === "INTERVAL") out.interval = Number(v) || 1;
|
||
else if (k === "COUNT") out.count = Number(v);
|
||
else if (k === "UNTIL" && v && v.length >= 8) out.until = `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// RSVP status → a small badge on the guest's initials circle.
|
||
const normStat = (p) => {
|
||
const v = String(p || "needs-action").toLowerCase();
|
||
return ["accepted", "declined", "tentative"].includes(v) ? v : "needs-action";
|
||
};
|
||
function statusBadge(status) {
|
||
const check = `<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>`;
|
||
const cross = `<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>`;
|
||
const clock = `<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>`;
|
||
const glyph = status === "accepted" ? check : status === "declined" ? cross : status === "tentative" ? "?" : clock;
|
||
const title = status === "accepted" ? "Going" : status === "declined" ? "Declined" : status === "tentative" ? "Maybe" : "Awaiting response";
|
||
return `<span class="cal-badge ${status}" title="${title}">${glyph}</span>`;
|
||
}
|
||
|
||
// Guest list for the event popover: organizer first (badged + always confirmed),
|
||
// then attendees with their RSVP status, de-duplicating the organizer.
|
||
function guestListHtml(ev) {
|
||
const org = ev.organizer && ev.organizer.email ? ev.organizer : null;
|
||
const rows = [];
|
||
if (org) rows.push({ email: org.email, name: org.name || "", role: "Organizer", status: "accepted" });
|
||
(ev.attendees || []).forEach((a) => {
|
||
if (!a || !a.email) return;
|
||
if (org && a.email.toLowerCase() === org.email.toLowerCase()) return;
|
||
rows.push({ email: a.email, name: a.name || "", role: "", status: normStat(a.partstat) });
|
||
});
|
||
if (!rows.length) return "";
|
||
const count = rows.length === 1 ? "1 guest" : `${rows.length} guests`;
|
||
return `<hr/><div class="cal-guestlist"><div class="cap">${count}</div>${rows.map((g) => {
|
||
const display = g.name || g.email;
|
||
return `<div class="g"><span class="av">${esc((display[0] || "?").toUpperCase())}${statusBadge(g.status)}</span>
|
||
<span class="gm"><span class="gn">${esc(display)}${g.role ? ` <span class="gr">${g.role}</span>` : ""}</span>${g.name ? `<span class="ge">${esc(g.email)}</span>` : ""}</span></div>`;
|
||
}).join("")}</div>`;
|
||
}
|
||
|
||
const videoLinkHtml = (link) => `<div class="cal-videolink">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
|
||
<a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(link)}</a>
|
||
<button class="cal-iconbtn" type="button" data-act="copy-jitsi" title="Copy link"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||
<button class="cal-iconbtn" type="button" data-act="remove-video" title="Remove video call" aria-label="Remove video call"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||
</div>`;
|
||
// The Video-call section without a link: the button that adds one.
|
||
const ADD_VIDEO_HTML = `<button class="cal-btn" data-act="add-jitsi" style="display:inline-flex;align-items:center;gap:6px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:0 0 auto"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>Add a video call in Jitsi Meet</button>`;
|
||
|
||
const COLORS = [
|
||
["Blue", "#3b82f6"], ["Green", "#22c55e"], ["Red", "#ef4444"], ["Orange", "#f97316"],
|
||
["Purple", "#a855f7"], ["Teal", "#14b8a6"], ["Pink", "#ec4899"], ["Gray", "#6b7280"],
|
||
];
|
||
|
||
function tzList() {
|
||
try {
|
||
return Intl.supportedValuesOf("timeZone");
|
||
} catch {
|
||
return ["UTC", "Europe/London", "Europe/Warsaw", "Europe/Berlin", "America/New_York",
|
||
"America/Chicago", "America/Los_Angeles", "Asia/Tokyo", "Asia/Shanghai", "Australia/Sydney"];
|
||
}
|
||
}
|
||
|
||
// The zone's short code/abbreviation (e.g. "CEST", "GMT+2") for the timegrid label.
|
||
function tzAbbr(tz) {
|
||
if (!tz || tz === "floating") return "";
|
||
try {
|
||
const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "short" }).formatToParts(new Date());
|
||
const p = parts.find((x) => x.type === "timeZoneName");
|
||
return p ? p.value : tz;
|
||
} catch {
|
||
return tz;
|
||
}
|
||
}
|
||
|
||
function tzOffset(tz) {
|
||
try {
|
||
const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date());
|
||
const p = parts.find((x) => x.type === "timeZoneName");
|
||
return p ? p.value : "GMT";
|
||
} catch {
|
||
return "GMT";
|
||
}
|
||
}
|
||
|
||
// Long descriptive label + offset, e.g. "Central European Summer Time (GMT+2)".
|
||
function tzLongLabel(tz) {
|
||
if (!tz || tz === "floating") return "Floating time";
|
||
let name = tz;
|
||
try {
|
||
const parts = new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "long" }).formatToParts(new Date());
|
||
const p = parts.find((x) => x.type === "timeZoneName");
|
||
if (p) name = p.value;
|
||
} catch { /* keep tz */ }
|
||
return `${name} (${tzOffset(tz)})`;
|
||
}
|
||
|
||
const tzCity = (tz) => tz.split("/").pop().replace(/_/g, " ");
|
||
|
||
// Full searchable list: current (checked) + Floating + all IANA cities (sorted by
|
||
// offset then name) + fixed GMT offsets + UTC.
|
||
function tzEntries(current) {
|
||
const zones = tzList();
|
||
const withOffset = zones.map((z) => ({ tz: z, city: tzCity(z), abbr: tzAbbr(z), off: tzOffset(z) }));
|
||
const offNum = (o) => { const m = /GMT([+-]\d+)(?::(\d+))?/.exec(o); return m ? Number(m[1]) * 60 + (m[1][0] === "-" ? -1 : 1) * Number(m[2] || 0) : 0; };
|
||
withOffset.sort((a, b) => offNum(a.off) - offNum(b.off) || a.city.localeCompare(b.city));
|
||
const gmt = [];
|
||
for (let n = -12; n <= 14; n++) if (n !== 0) gmt.push({ tz: `Etc/GMT${n > 0 ? "-" : "+"}${Math.abs(n)}`, city: `GMT${n > 0 ? "+" : "-"}${Math.abs(n)}`, abbr: `GMT${n > 0 ? "+" : "-"}${Math.abs(n)}`, off: "" });
|
||
const out = [{ tz: current, city: tzCity(current), abbr: tzAbbr(current), off: "", cur: true }, { tz: "floating", city: "Floating time", abbr: "", off: "" }];
|
||
return out.concat(withOffset, gmt, [{ tz: "UTC", city: "UTC", abbr: "UTC", off: "" }]);
|
||
}
|
||
|
||
const CSS = `
|
||
.cal-app{display:flex;height:100vh;position:relative;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px}
|
||
.cal-maincol{flex:1;min-width:0;display:flex;flex-direction:column}
|
||
.cal-mainarea{flex:1;min-height:0;display:flex}
|
||
.cal-topbar{display:flex;align-items:center;gap:10px;height:48px;padding:0 12px;flex:0 0 auto}
|
||
/* Left panel styling lives in the shared sidebar shell (.wm-side-*, plugin-ui.css),
|
||
which @applies Mail's exact Tailwind utilities + pins the 16px/Lato base context. */
|
||
.cal-secrow{display:flex;align-items:center;justify-content:space-between}
|
||
.cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px}
|
||
.cal-secrow:hover .cal-secadd{opacity:1}
|
||
.cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||
/* collapsed icon-only rail (matches Mail's 60px rail) */
|
||
.cal-rail{width:60px;align-items:center}
|
||
.cal-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
|
||
.cal-railprimary{padding:2px 0 8px}
|
||
.cal-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
|
||
.cal-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
|
||
.cal-railbtn{display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:9px;color:rgb(var(--app-muted));cursor:pointer;background:none;border:0}
|
||
.cal-railbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||
.cal-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
|
||
.cal-railnew:hover{opacity:.9}
|
||
.cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
|
||
.cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
|
||
.cal-resizer:hover::after{background:#3b82f6}
|
||
.cal-resizer::before{content:"";position:absolute;top:50%;left:50%;width:5px;height:28px;transform:translate(-50%,-50%);border-radius:999px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));box-sizing:border-box;z-index:1}
|
||
.cal-resizer:hover::before{background:#3b82f6;border-color:#3b82f6}
|
||
.cal-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||
.cal-right{width:320px;flex:0 0 auto;border-left:1px solid rgb(var(--app-border));overflow:auto}
|
||
/* search results panel */
|
||
.cal-srch{font-size:13px;padding-bottom:24px}
|
||
.cal-srch-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px 6px;color:rgb(var(--app-muted))}
|
||
.cal-srch-more{padding:4px 14px 14px;border-bottom:1px solid rgb(var(--app-border))}
|
||
.cal-srch-more .cal-btn{background:rgb(var(--app-surface))}
|
||
.cal-srch-month{padding:16px 14px 8px;font-size:19px;font-weight:600}
|
||
.cal-srch-month span{color:rgb(var(--app-muted));font-weight:400}
|
||
.cal-srch-day{display:flex;gap:12px;padding:8px 14px}
|
||
.cal-srch-dt{width:40px;flex:0 0 auto;text-align:left;position:relative}
|
||
.cal-srch-dt .dow{display:block;font-size:11px;color:rgb(var(--app-faint));letter-spacing:.03em}
|
||
.cal-srch-dt .dnum{display:block;font-size:18px;font-weight:500}
|
||
.cal-srch-dt.today .dnum,.cal-srch-dt.today .dow{color:#3b82f6}
|
||
.cal-srch-evs{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;justify-content:center}
|
||
.cal-srch-ev{background:rgb(var(--app-hover));border-left:3px solid #3b82f6;border-radius:5px;padding:6px 9px;cursor:pointer}
|
||
.cal-srch-ev:hover{background:rgb(var(--app-active))}
|
||
.cal-srch-ev .t{display:flex;justify-content:space-between;gap:8px}
|
||
.cal-srch-ev .t b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.cal-srch-ev .t span{color:rgb(var(--app-muted));flex:0 0 auto}
|
||
.cal-srch-ev .loc{color:rgb(var(--app-muted));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.cal-srch-none{color:rgb(var(--app-faint));padding:6px 0}
|
||
.cal-srch-today{color:#3b82f6;font-size:11px;font-weight:600;letter-spacing:.04em;text-align:right;padding:0 14px}
|
||
.cal-mainhead{display:flex;align-items:center;gap:14px;padding:10px 16px;flex:0 0 auto}
|
||
/* embed (Mail right panel) compact layout */
|
||
.cal-embed{flex-direction:column;height:100%}
|
||
.cal-panelbar{display:flex;align-items:center;gap:2px;padding:6px 10px 2px;flex:0 0 auto}
|
||
.cal-panelsearch{padding:4px 10px}
|
||
.cal-embed-body{flex:1;min-height:0;overflow:auto}
|
||
.cal-embed-mini{padding:6px 14px 0}
|
||
.cal-agenda-today{background:none;border:0;color:rgb(var(--app-accent));font-weight:600;font-size:13px;cursor:pointer}
|
||
.cal-panelfoot{flex:0 0 auto;border-top:1px solid rgb(var(--app-border));padding:10px;text-align:center}
|
||
.cal-panelfoot button{display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;font:inherit;font-size:13px}
|
||
.cal-panelfoot button:hover{color:rgb(var(--app-fg))}
|
||
.cal-panelcal{position:relative}
|
||
.cal-panelmenu{position:relative}
|
||
.cal-panelcalbtn{display:flex;align-items:center;gap:6px;background:none;border:0;outline:none;color:rgb(var(--app-fg));cursor:pointer;font:inherit;font-size:13px;padding:6px 4px}
|
||
.cal-panelcalbtn:hover{color:rgb(var(--app-muted))}
|
||
.cal-ddsec{padding:5px 10px 2px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint))}
|
||
.cal-ddsep{border-top:1px solid rgb(var(--app-border));margin:4px 0}
|
||
.cal-embed .cal-searchbox{margin:0}
|
||
.cal-embed .cal-form{right:8px;width:auto;top:52px;max-height:calc(100% - 64px)}
|
||
.cal-embed .cal-pop{position:fixed}
|
||
.cal-view{flex:1;min-height:0;overflow:auto;position:relative}
|
||
.cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||
.cal-btn-primary{background:#3b82f6;color:#fff;flex:1}
|
||
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex;outline:none}
|
||
.cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||
.cal-searchbox{flex:1;max-width:520px;margin:0 auto;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
|
||
.cal-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
|
||
.cal-search{flex:1;min-width:0;padding:7px 0;border:0;background:none;color:rgb(var(--app-fg));outline:none;font:inherit}
|
||
.cal-seg{display:inline-flex;background:rgb(var(--app-surface));border-radius:9px;padding:3px;gap:2px}
|
||
.cal-seg button{border:0;background:none;color:rgb(var(--app-muted));padding:5px 26px;border-radius:7px;cursor:pointer;font:inherit;font-size:13px}
|
||
.cal-seg button.on{background:rgb(var(--app-elevated));color:rgb(var(--app-fg));box-shadow:0 1px 3px rgba(0,0,0,.35)}
|
||
.cal-title{font-size:18px;font-weight:600}
|
||
.cal-title span{color:rgb(var(--app-muted));font-weight:400}
|
||
.cal-mini{padding:6px 4px 12px}
|
||
.cal-mini-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;font-size:12px;text-align:center}
|
||
.cal-mini-grid .h{color:rgb(var(--app-faint));padding:3px 0}
|
||
.cal-mini-grid button{width:26px;height:26px;margin:1px auto;display:flex;align-items:center;justify-content:center;border:0;background:none;color:rgb(var(--app-fg));border-radius:50%;cursor:pointer;font:inherit;font-size:14px}
|
||
.cal-mini-grid button:hover{background:rgb(var(--app-hover))}
|
||
.cal-mini-grid button.out{color:rgb(var(--app-faint))}
|
||
.cal-mini-grid button.today{background:#3b82f6;color:#fff}
|
||
.cal-mini-grid button.sel{background:rgb(var(--app-active));font-weight:600}
|
||
/* month */
|
||
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%}
|
||
.cal-month .dow{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px 8px;font-size:13px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))}
|
||
.cal-cell{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px;min-height:0;overflow:hidden;cursor:pointer}
|
||
.cal-cell .n{display:flex;align-items:center;gap:4px;font-size:15px;color:rgb(var(--app-fg));font-weight:500}
|
||
.cal-cell .num{display:inline-flex;align-items:center;justify-content:center;min-width:25px;height:25px;border-radius:50%;box-sizing:border-box}
|
||
.cal-cell .mtag{color:rgb(var(--app-muted));font-weight:400;font-size:14px}
|
||
.cal-cell.out .n{color:rgb(var(--app-faint))}
|
||
.cal-cell.out .mtag{color:rgb(var(--app-faint))}
|
||
.cal-cell:not(.out){background:rgba(0,0,0,.02)}
|
||
.dark .cal-cell:not(.out){background:rgba(255,255,255,.03)}
|
||
.cal-cell.today{background:rgba(59,130,246,.06)}
|
||
.cal-cell:hover{background:rgba(0,0,0,.055)}
|
||
.dark .cal-cell:hover{background:rgba(255,255,255,.06)}
|
||
.cal-cell.today:hover{background:rgba(59,130,246,.11)}
|
||
.cal-cell.today .num{background:#3b82f6;color:#fff}
|
||
.cal-ev{font-size:14px;padding:1px 4px;border-radius:4px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-left:3px solid #3b82f6;background:rgb(var(--app-hover))}
|
||
.cal-ev.srch-hl,.cal-tev.srch-hl{outline:2px solid #3b82f6;outline-offset:-1px;background:rgba(59,130,246,.28)}
|
||
/* timegrid */
|
||
.cal-tg{display:grid;grid-template-columns:56px 1fr}
|
||
.cal-tg-head{position:sticky;top:0;background:rgb(var(--app-bg));z-index:5;display:grid;grid-template-columns:56px 1fr;border-bottom:1px solid rgb(var(--app-border))}
|
||
.cal-tg-days{display:grid}
|
||
.cal-tg-days .d{padding:6px 8px;border-left:1px solid rgb(var(--app-border));font-size:14px;color:rgb(var(--app-muted))}
|
||
.cal-tg-days .d b{font-size:18px;color:rgb(var(--app-fg));display:block;font-weight:600}
|
||
.cal-tg-days .d.today{color:#3b82f6;border-bottom:2px solid #3b82f6;margin-bottom:-1px}
|
||
.cal-tg-days .d.today .num{background:#3b82f6;color:#fff;border-radius:50%;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:16px}
|
||
.cal-tz{font-size:11px;color:rgb(var(--app-faint));display:flex;align-items:flex-end;justify-content:center;padding-bottom:4px;text-transform:uppercase}
|
||
.cal-hours{color:rgb(var(--app-faint));font-size:11px}
|
||
.cal-hours div{height:${HOUR_PX}px;text-align:right;padding-right:6px;transform:translateY(-6px)}
|
||
.cal-cols{display:grid;position:relative}
|
||
.cal-col{border-left:1px solid rgb(var(--app-border));position:relative}
|
||
.cal-col.today{background:rgba(59,130,246,.06)}
|
||
.cal-col:hover{background:rgba(0,0,0,.045)}
|
||
.dark .cal-col:hover{background:rgba(255,255,255,.05)}
|
||
.cal-col.today:hover{background:rgba(59,130,246,.11)}
|
||
.cal-col .line{height:${HOUR_PX}px;border-top:1px solid rgb(var(--app-border))}
|
||
.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box;z-index:2}
|
||
/* all-day event in the timegrid: a full-height band filling the day column. The band
|
||
itself is click-through (so click-to-create and timed events still work); only its
|
||
label chip is interactive. */
|
||
.cal-aday{position:absolute;top:0;box-sizing:border-box;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;border-radius:5px;pointer-events:none;z-index:1}
|
||
.cal-aday .lbl{position:sticky;top:var(--cal-head-h,46px);z-index:3;pointer-events:auto;cursor:pointer;display:block;font-size:13px;font-weight:600;line-height:1.3;margin:1px;padding:3px 6px;border-radius:4px;color:rgb(var(--app-fg));background:rgb(var(--app-elevated));box-shadow:0 1px 2px rgba(0,0,0,.25);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4}
|
||
/* right agenda */
|
||
.cal-agenda .day{display:flex;gap:14px;padding:10px 14px;border-bottom:1px solid rgb(var(--app-border))}
|
||
.cal-agenda .day .dt{width:44px;flex:0 0 auto;font-size:12px;color:rgb(var(--app-muted))}
|
||
.cal-agenda .day .dt b{display:block;font-size:18px;color:rgb(var(--app-fg))}
|
||
.cal-agenda .none{color:rgb(var(--app-faint));font-size:13px}
|
||
.cal-agenda .day.empty{cursor:pointer}
|
||
.cal-agenda .day.empty:hover{background:rgb(var(--app-hover))}
|
||
.cal-agenda .day.empty:hover .none{color:rgb(var(--app-accent))}
|
||
/* new-event form (floating box) */
|
||
/* Content-sized box: grows in height as sections are added, scrolls only past the
|
||
viewport cap; the Save/Cancel footer sticks to the bottom while scrolling. */
|
||
.cal-form{position:absolute;left:10px;top:58px;width:364px;max-height:calc(100vh - 74px);background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 14px 44px rgba(0,0,0,.5);z-index:40;overflow:auto}
|
||
.cal-form-body{padding:16px}
|
||
.cal-form-foot{position:sticky;bottom:0;background:rgb(var(--app-elevated));border-top:1px solid rgb(var(--app-border));padding:12px 16px;display:flex;gap:8px}
|
||
.cal-acpop{position:absolute;left:0;right:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:55;max-height:220px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.4)}
|
||
.cal-acpop button{display:flex;flex-direction:column;align-items:flex-start;gap:1px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:6px 8px;border-radius:6px;cursor:pointer;font:inherit;text-align:left}
|
||
.cal-acpop button:hover{background:rgb(var(--app-hover))}
|
||
.cal-acpop button span{color:rgb(var(--app-muted));font-size:12px}
|
||
.cal-guestlist .cap{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint));margin-bottom:8px}
|
||
.cal-guestlist{max-height:180px;overflow:auto}
|
||
.cal-guestlist .g{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||
.cal-guestlist .av{position:relative;width:26px;height:26px;border-radius:50%;background:rgb(var(--app-hover));display:inline-flex;align-items:center;justify-content:center;font-size:12px;flex:0 0 auto}
|
||
.cal-badge{position:absolute;right:-3px;bottom:-3px;width:14px;height:14px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;border:2px solid rgb(var(--app-elevated));color:#fff;font-size:9px;font-weight:700;box-sizing:content-box}
|
||
.cal-badge svg{width:9px;height:9px;display:block}
|
||
.cal-badge.accepted{background:#22c55e}
|
||
.cal-badge.declined{background:#ef4444}
|
||
.cal-badge.tentative{background:#6b7280}
|
||
.cal-badge.needs-action{background:#9ca3af}
|
||
.cal-guestlist .gm{display:flex;flex-direction:column;min-width:0}
|
||
.cal-guestlist .gn{font-size:13px}
|
||
.cal-guestlist .gr{color:rgb(var(--app-faint));font-size:11px}
|
||
.cal-guestlist .ge{font-size:11px;color:rgb(var(--app-faint));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.cal-videolink{display:flex;align-items:center;gap:8px;font-size:13px;word-break:break-all}
|
||
.cal-videolink a{color:#3b82f6;text-decoration:none}
|
||
.cal-videolink a:hover{text-decoration:underline}
|
||
.cal-videolink .cal-iconbtn{flex:0 0 auto}
|
||
.cal-iconbtn.copied{color:#22c55e}
|
||
.cal-form h4{margin:14px 0 5px;font-size:13px;font-weight:600}
|
||
.cal-swatches{display:flex;gap:7px;flex-wrap:wrap;align-items:center;height:34px}
|
||
.cal-sw{width:20px;height:20px;border-radius:50%;border:2px solid transparent;cursor:pointer;padding:0}
|
||
.cal-sw.sel{border-color:rgb(var(--app-fg))}
|
||
.cal-form input[type=text],.cal-form input[type=email],.cal-form textarea,.cal-form select,.cal-form input[type=date],.cal-form input[type=time],.cal-form input[type=number]{width:100%;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));font:inherit;box-sizing:border-box}
|
||
.cal-form .row{display:flex;gap:8px;align-items:center}
|
||
/* Custom date field: a formatted read-only text box + popup month picker. Native
|
||
<input type=date> renders its text in the browser locale's order, which we can't
|
||
control — this drives the display order entirely from the 24h/12h preference. */
|
||
.cal-datewrap{position:relative;display:block;width:100%;min-width:0}
|
||
.cal-datewrap input[data-datedisplay]{cursor:text}
|
||
.cal-datepop{position:absolute;left:0;top:100%;margin-top:4px;z-index:60;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.4);padding:8px;width:236px}
|
||
.cal-datepop-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
|
||
.cal-datepop-head span{font-size:13px;font-weight:600;color:rgb(var(--app-fg))}
|
||
.cal-datepop-head button{border:0;background:none;color:rgb(var(--app-fg));cursor:pointer;width:26px;height:26px;border-radius:6px;font-size:17px;line-height:1}
|
||
.cal-datepop-head button:hover{background:rgb(var(--app-hover))}
|
||
.cal-datepop-dow,.cal-datepop-grid{display:grid;grid-template-columns:repeat(7,1fr)}
|
||
.cal-datepop-dow span{text-align:center;font-size:11px;color:rgb(var(--app-faint));padding:2px 0}
|
||
.cal-datepop-grid button{border:0;background:none;color:rgb(var(--app-fg));cursor:pointer;height:28px;border-radius:50%;font:inherit;font-size:13px}
|
||
.cal-datepop-grid button:hover{background:rgb(var(--app-hover))}
|
||
.cal-datepop-grid button.today{outline:1px solid rgb(var(--app-border))}
|
||
.cal-datepop-grid button.sel{background:#3b82f6;color:#fff}
|
||
.cal-form .muted{color:rgb(var(--app-muted));font-size:13px}
|
||
.cal-formbtns{display:flex;gap:8px;margin-top:16px}
|
||
.cal-guest{display:inline-flex;align-items:center;gap:4px;background:rgb(var(--app-hover));border-radius:10px;padding:2px 8px;font-size:12px;margin:2px 4px 2px 0}
|
||
.cal-guest button{border:0;background:none;color:inherit;cursor:pointer}
|
||
.cal-tog{width:34px;height:20px;border-radius:10px;background:rgb(var(--app-hover));border:0;cursor:pointer;position:relative;flex:0 0 auto}
|
||
.cal-tog.on{background:#3b82f6}
|
||
.cal-tog::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left .1s}
|
||
.cal-tog.on::after{left:16px}
|
||
.cal-dd{position:relative}
|
||
.cal-dd>button{display:flex;align-items:center;gap:6px;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));cursor:pointer;font:inherit;justify-content:space-between}
|
||
.cal-dot{width:14px;height:14px;border-radius:50%;display:inline-block;flex:0 0 auto}
|
||
.cal-ddpop{position:absolute;left:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:50;min-width:150px;max-height:280px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.4)}
|
||
.cal-ddpop button{display:flex;align-items:center;gap:8px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:6px 8px;border-radius:6px;cursor:pointer;font:inherit;text-align:left}
|
||
.cal-ddpop button:hover{background:rgb(var(--app-hover))}
|
||
.cal-tzfield{position:relative}
|
||
.cal-tzfield>button{display:flex;align-items:center;justify-content:space-between;gap:6px;width:100%;padding:7px 9px;border:1px solid rgb(var(--app-border));border-radius:7px;background:rgb(var(--app-elevated));color:rgb(var(--app-fg));cursor:pointer;font:inherit;box-sizing:border-box}
|
||
.cal-tzfield .off{color:rgb(var(--app-faint))}
|
||
.cal-tzpop{position:absolute;left:0;right:0;top:100%;margin-top:4px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;z-index:50;box-shadow:0 10px 30px rgba(0,0,0,.5);overflow:hidden}
|
||
.cal-tzpop .search{margin:8px;width:calc(100% - 16px);box-sizing:border-box}
|
||
.cal-tzlist{max-height:280px;overflow:auto;padding-bottom:4px}
|
||
.cal-tzlist button{display:flex;justify-content:space-between;align-items:center;gap:10px;width:100%;border:0;background:none;color:rgb(var(--app-fg));padding:7px 12px;cursor:pointer;font:inherit;text-align:left}
|
||
.cal-tzlist button:hover{background:rgb(var(--app-hover))}
|
||
.cal-tzlist .abbr{color:rgb(var(--app-faint));font-size:12px;flex:0 0 auto}
|
||
.cal-pop{position:absolute;width:290px;max-height:calc(100vh - 16px);display:flex;flex-direction:column;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55);z-index:60;overflow:hidden}
|
||
.cal-pop .body{padding:14px 16px;overflow:auto}
|
||
.cal-pop .cap{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint))}
|
||
.cal-pop .ttl{font-size:17px;font-weight:600;margin:2px 0}
|
||
.cal-pop .loc{color:rgb(var(--app-muted))}
|
||
.cal-pop hr{border:0;border-top:1px solid rgb(var(--app-border));margin:12px 0}
|
||
.cal-pop .foot{flex:0 0 auto;border-top:1px solid rgb(var(--app-border));padding:10px 16px;display:flex;justify-content:space-between}
|
||
.cal-modal-ov{position:absolute;inset:0;z-index:70;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center}
|
||
.cal-modal{width:380px;max-width:calc(100vw - 32px);background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.6);overflow:hidden}
|
||
.cal-modal .body{padding:20px 22px}
|
||
.cal-modal h3{margin:0 0 8px;font-size:17px;font-weight:600}
|
||
.cal-modal p{margin:0;color:rgb(var(--app-muted));font-size:14px}
|
||
.cal-modal .opts{margin-top:14px;display:flex;flex-direction:column;gap:8px}
|
||
.cal-modal .opts button{display:block;width:100%;text-align:left;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));color:rgb(var(--app-fg));padding:10px 12px;border-radius:8px;cursor:pointer;font:inherit}
|
||
.cal-modal .opts button:hover{background:rgb(var(--app-hover));border-color:#3b82f6}
|
||
.cal-modal .cal-modal-sub{margin-top:6px;font-size:12px;color:rgb(var(--app-faint))}
|
||
.cal-modal-stack{margin-top:16px;display:flex;flex-direction:column;gap:8px}
|
||
.cal-modal-stack button{width:100%;padding:10px 14px;border-radius:8px;font:inherit;font-weight:600;cursor:pointer;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));color:rgb(var(--app-fg))}
|
||
.cal-modal-stack button:hover{background:rgb(var(--app-hover))}
|
||
.cal-modal-stack button.primary{background:#3b82f6;border-color:#3b82f6;color:#fff}
|
||
.cal-modal-stack button.primary:hover{background:#2563eb}
|
||
.cal-modal .foot{border-top:1px solid rgb(var(--app-border));padding:12px 16px;display:flex;justify-content:flex-end;gap:8px}
|
||
`;
|
||
|
||
export default function mount(el, ctx, props) {
|
||
// Always (re)write the stylesheet so a newer bundle version's CSS wins — the
|
||
// singleton element would otherwise keep a stale copy from an earlier mount,
|
||
// leaving new classes (e.g. .cal-panelcalbtn) unstyled = default button frame.
|
||
let calStyle = document.getElementById("cal-styles");
|
||
if (!calStyle) {
|
||
calStyle = document.createElement("style");
|
||
calStyle.id = "cal-styles";
|
||
document.head.appendChild(calStyle);
|
||
}
|
||
calStyle.textContent = CSS;
|
||
|
||
// Embed mode: the SAME app runs compactly inside Mail's right panel — reusing the
|
||
// agenda, mini-month, event popover, event form and search results verbatim, so
|
||
// any change here propagates to both places.
|
||
const embed = !!(props && props.embed === "panel");
|
||
const newEventGuests = (props && props.newEventGuests) || null;
|
||
// Embed: the host can drop the "Open calendar in new tab" footer (Mail on a
|
||
// phone, where a new tab is no use).
|
||
const newTabLink = !(props && props.newTabLink === false);
|
||
|
||
const state = {
|
||
embed,
|
||
view: "month",
|
||
anchor: startOfDay(new Date()),
|
||
mini: startOfMonth(new Date()),
|
||
calendars: [],
|
||
hidden: new Set(),
|
||
events: [],
|
||
rightOpen: false,
|
||
menuOpen: false,
|
||
leftW: Number(localStorage.getItem("cal.leftW")) || 240,
|
||
rightW: Number(localStorage.getItem("cal.rightW")) || 320,
|
||
leftCollapsed: localStorage.getItem("cal.leftCollapsed") === "1",
|
||
composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
|
||
eventPopover: null, // null | { ev, rect }
|
||
confirm: null, // null | { ev, scope? } — delete confirmation; scope set → the "inform guests?" step
|
||
myEmails: new Set(), // the user's own addresses (who is "me" among guests)
|
||
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
|
||
searchOpen: false, // embed: search box revealed
|
||
panelCalOpen: false, // embed: "All calendars" dropdown open
|
||
panelMenuOpen: false, // embed: "..." view-options menu open
|
||
panelMini: "month", // embed mini calendar: month | week
|
||
panelView: "agenda", // embed agenda: day | 3days | agenda
|
||
scrollToAnchor: false, // embed: bring the anchor day to the top of the agenda once
|
||
me: "",
|
||
quota: "",
|
||
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
|
||
defaultCalendar: "", // preferred calendar for new events (from settings)
|
||
defaultRemTimed: [], // default reminder minutes for timed events
|
||
defaultRemAllday: [], // default reminder minutes for all-day events
|
||
apps: [{ label: "Mail", to: "/mail" }, { label: "Calendar", to: null }],
|
||
};
|
||
|
||
const api = ctx.api;
|
||
|
||
function rangeFor() {
|
||
if (state.embed) {
|
||
const gs = startOfMonth(state.anchor);
|
||
return [gs, addDays(gs, 42)]; // covers the panel's ~30-day agenda
|
||
}
|
||
if (state.view === "month") {
|
||
const gs = startOfWeek(startOfMonth(state.anchor));
|
||
return [gs, addDays(gs, 42)];
|
||
}
|
||
const span = state.view === "day" ? 1 : state.view === "3days" ? 3 : 7;
|
||
const s = state.view === "week" ? startOfWeek(state.anchor) : startOfDay(state.anchor);
|
||
return [s, addDays(s, span)];
|
||
}
|
||
|
||
async function loadCalendars() {
|
||
state.calendars = await api.pluginFetch("/calendars");
|
||
}
|
||
async function loadEvents() {
|
||
const [s, e] = rangeFor();
|
||
const res = await api.pluginFetch(`/events?start=${encodeURIComponent(iso(s))}&end=${encodeURIComponent(iso(e))}`);
|
||
state.events = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) }));
|
||
}
|
||
function visibleEvents() {
|
||
return state.events.filter((ev) => !state.hidden.has(ev.calendar_id));
|
||
}
|
||
|
||
async function reload() {
|
||
await loadEvents();
|
||
render();
|
||
}
|
||
|
||
function pad(n) { return String(n).padStart(2, "0"); }
|
||
// Wire every custom date field in the form: an editable text box showing the date in
|
||
// the preferred order (typing is parsed in that order), backed by a hidden ISO input,
|
||
// plus a popup month picker.
|
||
function initDatePickers() {
|
||
el.querySelectorAll("[data-datepick]").forEach(setupDatePicker);
|
||
}
|
||
|
||
// Parse a hand-typed date honouring the preferred order (24h → d/m/y, 12h → m/d/y).
|
||
function parseTypedDate(str) {
|
||
const parts = String(str).split(/[^0-9]+/).filter(Boolean).map(Number);
|
||
if (parts.length < 3) return null;
|
||
let d, m, y;
|
||
if (HOUR12) [m, d, y] = parts; else [d, m, y] = parts;
|
||
if (y < 100) y += 2000;
|
||
if (!(y && m >= 1 && m <= 12 && d >= 1 && d <= 31)) return null;
|
||
const dt = new Date(y, m - 1, d);
|
||
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
|
||
return `${y}-${pad2(m)}-${pad2(d)}`;
|
||
}
|
||
|
||
function setupDatePicker(wrap) {
|
||
if (wrap.dataset.wired) return; // idempotent across re-renders
|
||
wrap.dataset.wired = "1";
|
||
const hidden = wrap.querySelector("input[type=hidden]");
|
||
const display = wrap.querySelector("[data-datedisplay]");
|
||
display.setAttribute("placeholder", HOUR12 ? "mm/dd/yyyy" : "dd/mm/yyyy");
|
||
const parse = (iso) => {
|
||
const [y, m, d] = String(iso || "").split("-").map(Number);
|
||
return y && m && d ? { y, m, d } : null;
|
||
};
|
||
const refresh = () => { display.value = fmtDateInput(hidden.value); };
|
||
// Accept the typed text into the hidden ISO value, then normalise the display.
|
||
const commit = () => {
|
||
const isoTyped = parseTypedDate(display.value);
|
||
if (isoTyped) hidden.value = isoTyped;
|
||
refresh();
|
||
};
|
||
let pop = null;
|
||
let viewY = 0;
|
||
let viewM = 0; // 1-12
|
||
|
||
const onDoc = (e) => { if (pop && !wrap.contains(e.target)) { commit(); close(); } };
|
||
function close() {
|
||
if (pop) { pop.remove(); pop = null; }
|
||
document.removeEventListener("mousedown", onDoc);
|
||
}
|
||
function open() {
|
||
if (pop) return;
|
||
const cur = parseTypedDate(display.value) ? parse(parseTypedDate(display.value))
|
||
: parse(hidden.value) || (() => {
|
||
const n = new Date();
|
||
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate() };
|
||
})();
|
||
viewY = cur.y; viewM = cur.m;
|
||
pop = document.createElement("div");
|
||
pop.className = "cal-datepop";
|
||
// Keep focus in the text box so clicking the picker never blurs mid-type.
|
||
pop.addEventListener("mousedown", (e) => e.preventDefault());
|
||
wrap.appendChild(pop);
|
||
renderPop();
|
||
setTimeout(() => document.addEventListener("mousedown", onDoc), 0);
|
||
}
|
||
function renderPop() {
|
||
const sel = parse(hidden.value);
|
||
const today = new Date();
|
||
const startDow = (new Date(viewY, viewM - 1, 1).getDay() + 6) % 7; // Monday-first
|
||
const daysIn = new Date(viewY, viewM, 0).getDate();
|
||
let cells = "";
|
||
for (let i = 0; i < startDow; i++) cells += "<span></span>";
|
||
for (let d = 1; d <= daysIn; d++) {
|
||
const isSel = sel && sel.y === viewY && sel.m === viewM && sel.d === d;
|
||
const isToday = today.getFullYear() === viewY && today.getMonth() + 1 === viewM && today.getDate() === d;
|
||
cells += `<button type="button" data-day="${d}" class="${isSel ? "sel" : ""}${isToday ? " today" : ""}">${d}</button>`;
|
||
}
|
||
pop.innerHTML = `
|
||
<div class="cal-datepop-head">
|
||
<button type="button" data-nav="-1">‹</button>
|
||
<span>${MONTHS[viewM - 1]} ${viewY}</span>
|
||
<button type="button" data-nav="1">›</button>
|
||
</div>
|
||
<div class="cal-datepop-dow">${DAYNAMES.map((d) => `<span>${d[0]}</span>`).join("")}</div>
|
||
<div class="cal-datepop-grid">${cells}</div>`;
|
||
pop.querySelectorAll("[data-nav]").forEach((b) => b.addEventListener("click", () => {
|
||
viewM += Number(b.getAttribute("data-nav"));
|
||
if (viewM < 1) { viewM = 12; viewY--; }
|
||
if (viewM > 12) { viewM = 1; viewY++; }
|
||
renderPop();
|
||
}));
|
||
pop.querySelectorAll("[data-day]").forEach((b) => b.addEventListener("click", () => {
|
||
hidden.value = `${viewY}-${pad2(viewM)}-${pad2(Number(b.getAttribute("data-day")))}`;
|
||
refresh();
|
||
close();
|
||
display.blur();
|
||
}));
|
||
}
|
||
display.addEventListener("focus", open);
|
||
// Live-sync the picker's shown month as a valid date is typed.
|
||
display.addEventListener("input", () => {
|
||
const isoTyped = parseTypedDate(display.value);
|
||
if (isoTyped) { hidden.value = isoTyped; const p = parse(isoTyped); viewY = p.y; viewM = p.m; if (pop) renderPop(); }
|
||
});
|
||
display.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); commit(); close(); display.blur(); }
|
||
else if (e.key === "Escape") { refresh(); close(); display.blur(); }
|
||
});
|
||
display.addEventListener("blur", () => { if (!pop) commit(); });
|
||
refresh();
|
||
}
|
||
|
||
function openForm(when, editEv) {
|
||
const d = when || new Date(); // default to the current time
|
||
state.eventPopover = null;
|
||
state.composing = {
|
||
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
|
||
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
||
tz: editEv ? editEv.tz : null,
|
||
conference: editEv ? (editEv.conference_url || "") : "",
|
||
editId: editEv ? editEv.id : null,
|
||
recurring: editEv ? !!editEv.recurring : false,
|
||
recurrenceStart: editEv ? editEv.start : null, // the clicked occurrence's start
|
||
};
|
||
render();
|
||
if (editEv) prefillForm(editEv);
|
||
else applyDefaultReminders(state.defaultRemTimed); // new events start with the user's defaults
|
||
initDatePickers();
|
||
const first = el.querySelector("[data-f=summary]");
|
||
if (first) first.focus();
|
||
}
|
||
|
||
// Populate the reminders section with a list of minutes-before values.
|
||
function applyDefaultReminders(mins) {
|
||
const box = el.querySelector("[data-reminders]");
|
||
if (!box || !mins || !mins.length) return;
|
||
mins.forEach((m) => {
|
||
const wrap = document.createElement("div");
|
||
wrap.innerHTML = reminderRow();
|
||
const row = wrap.firstElementChild;
|
||
const sel = row.querySelector("[data-rem]");
|
||
if (![...sel.options].some((o) => o.value === String(m))) {
|
||
const o = document.createElement("option");
|
||
o.value = String(m);
|
||
o.textContent = m === 0 ? "At time of event" : `${m} minutes before`;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.value = String(m);
|
||
row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove());
|
||
box.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function prefillForm(ev) {
|
||
const q = (s) => el.querySelector(s);
|
||
q("[data-f=summary]").value = ev.summary || "";
|
||
q("[data-f=location]").value = ev.location || "";
|
||
if (q(`[data-f=calendar] option[value="${ev.calendar_id}"]`)) q("[data-f=calendar]").value = ev.calendar_id;
|
||
q("[data-f=showas]").value = ev.show_as || "busy";
|
||
q("[data-f=notes]").value = ev.description || "";
|
||
if (ev.all_day) {
|
||
q("[data-f=allday]").checked = true;
|
||
q("[data-f=time]").disabled = true;
|
||
q("[data-f=duration]").disabled = true;
|
||
}
|
||
const durMin = Math.round((ev._e - ev._s) / 60000);
|
||
if (q(`[data-f=duration] option[value="${durMin}"]`)) q("[data-f=duration]").value = String(durMin);
|
||
if (ev.color) {
|
||
const dd = q(".cal-dd");
|
||
dd.setAttribute("data-color", ev.color);
|
||
dd.querySelector(".cal-dot").style.background = ev.color;
|
||
}
|
||
// Reminders — rebuild a row per stored minute value.
|
||
if (ev.reminders && ev.reminders.length) {
|
||
const box = q("[data-reminders]");
|
||
box.innerHTML = "";
|
||
ev.reminders.forEach((m) => {
|
||
const wrap = document.createElement("div");
|
||
wrap.innerHTML = reminderRow();
|
||
const row = wrap.firstElementChild;
|
||
const sel = row.querySelector("[data-rem]");
|
||
if (![...sel.options].some((o) => o.value === String(m))) {
|
||
const o = document.createElement("option");
|
||
o.value = String(m);
|
||
o.textContent = `${m} minutes before`;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.value = String(m);
|
||
row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove());
|
||
box.appendChild(row);
|
||
});
|
||
}
|
||
// Repeat — restore the RRULE into the toggle + fields.
|
||
if (ev.rrule) {
|
||
const r = parseRrule(ev.rrule);
|
||
q("[data-act=repeat-toggle]").classList.add("on");
|
||
q("[data-repeat]").style.display = "";
|
||
if (r.freq) q("[data-f=freq]").value = r.freq;
|
||
q("[data-f=interval]").value = String(r.interval || 1);
|
||
if (r.count) {
|
||
q("[data-f=endmode]").value = "count";
|
||
q("[data-f=count]").value = String(r.count);
|
||
q("[data-f=count]").style.display = "";
|
||
} else if (r.until) {
|
||
q("[data-f=endmode]").value = "until";
|
||
q("[data-f=until]").value = r.until;
|
||
q("[data-untilwrap]").style.display = "";
|
||
}
|
||
}
|
||
// Guests — expand the Who section and restore chips (no auto video-call).
|
||
if (ev.attendees && ev.attendees.length) {
|
||
const addChip = expandGuests();
|
||
ev.attendees.forEach((a) => addChip(a.email, a.name || "", false));
|
||
}
|
||
}
|
||
|
||
async function saveForm() {
|
||
const q = (sel) => el.querySelector(sel);
|
||
const calId = q("[data-f=calendar]").value;
|
||
if (!calId) return;
|
||
const allday = q("[data-f=allday]").checked;
|
||
const date = q("[data-f=date]").value;
|
||
const time = allday ? "00:00" : q("[data-f=time]").value;
|
||
const start = new Date(`${date}T${time}:00`);
|
||
const durMin = allday ? 1440 : Number(q("[data-f=duration]").value);
|
||
const end = new Date(start.getTime() + durMin * 60000);
|
||
let rrule = null;
|
||
if (q("[data-act=repeat-toggle]").classList.contains("on")) {
|
||
const freq = q("[data-f=freq]").value;
|
||
const interval = Number(q("[data-f=interval]").value) || 1;
|
||
let s = `FREQ=${freq}`;
|
||
if (interval > 1) s += `;INTERVAL=${interval}`;
|
||
if (freq === "WEEKLY") s += `;BYDAY=${["MO", "TU", "WE", "TH", "FR", "SA", "SU"][(start.getDay() + 6) % 7]}`;
|
||
const mode = q("[data-f=endmode]").value;
|
||
if (mode === "count") s += `;COUNT=${Number(q("[data-f=count]").value) || 1}`;
|
||
else if (mode === "until" && q("[data-f=until]").value) s += `;UNTIL=${q("[data-f=until]").value.replace(/-/g, "")}T000000Z`;
|
||
rrule = s;
|
||
}
|
||
const reminders = [...el.querySelectorAll("[data-rem]")].map((x) => Number(x.value));
|
||
const attendees = [...el.querySelectorAll("[data-guest]")].map((x) => ({ email: x.getAttribute("data-guest"), name: x.getAttribute("data-name") || "" }));
|
||
let tz = el.querySelector(".cal-tzfield").getAttribute("data-tz");
|
||
if (tz === "floating") tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||
const color = el.querySelector(".cal-dd").getAttribute("data-color");
|
||
const summary = q("[data-f=summary]").value.trim();
|
||
const location = q("[data-f=location]").value.trim();
|
||
const description = q("[data-f=notes]").value;
|
||
const showAs = q("[data-f=showas]").value;
|
||
const conferenceUrl = (state.composing && state.composing.conference) || "";
|
||
const editId = state.composing && state.composing.editId;
|
||
const organizer = state.me ? { email: state.me } : null;
|
||
const fields = {
|
||
summary, location, description, dtstart: start.toISOString(), dtend: end.toISOString(),
|
||
all_day: allday, tz, rrule, reminders, attendees, color, show_as: showAs, conference_url: conferenceUrl,
|
||
};
|
||
if (editId && state.composing.recurring) {
|
||
// Recurring edit → "this and following": occurrences before the edited one
|
||
// keep the original series; this one onward becomes a new series with the edits.
|
||
await api.pluginFetch(`/events/${editId}/edit-following`, {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
recurrence_start: state.composing.recurrenceStart,
|
||
event: { calendar_id: calId, ...fields, organizer },
|
||
}),
|
||
});
|
||
} else if (editId) {
|
||
await api.pluginFetch(`/events/${editId}`, { method: "PATCH", body: JSON.stringify(fields) });
|
||
} else {
|
||
await api.pluginFetch("/events", {
|
||
method: "POST",
|
||
body: JSON.stringify({ calendar_id: calId, ...fields, organizer }),
|
||
});
|
||
}
|
||
state.composing = null;
|
||
await reload();
|
||
}
|
||
|
||
function openEvent(ev, target) {
|
||
state.eventPopover = { ev, rect: target.getBoundingClientRect() };
|
||
render();
|
||
}
|
||
|
||
// Guests besides the user, on an event the user organizes: the people a
|
||
// deletion can be announced to. Someone else's event is not ours to cancel.
|
||
function guestsToInform(ev) {
|
||
const mine = state.myEmails;
|
||
const org = ev.organizer && ev.organizer.email ? String(ev.organizer.email).toLowerCase() : "";
|
||
if (org && !mine.has(org)) return [];
|
||
return (ev.attendees || []).filter((a) => a.email && !mine.has(String(a.email).toLowerCase()));
|
||
}
|
||
|
||
// Step past "which occurrences?": ask about telling the guests when there are
|
||
// any, else delete straight away.
|
||
function chooseScope(scope) {
|
||
if (!state.confirm) return;
|
||
if (guestsToInform(state.confirm.ev).length) {
|
||
state.confirm = { ...state.confirm, scope };
|
||
render();
|
||
} else {
|
||
doDelete(scope, false);
|
||
}
|
||
}
|
||
|
||
async function doDelete(scope, notify) {
|
||
if (!state.confirm) return;
|
||
const ev = state.confirm.ev;
|
||
state.confirm = null;
|
||
const params = new URLSearchParams();
|
||
if (scope !== "all") {
|
||
params.set("scope", scope);
|
||
params.set("occurrence", ev.start);
|
||
}
|
||
if (notify) params.set("notify", "1");
|
||
const qs = params.toString() ? `?${params}` : "";
|
||
await api.pluginFetch(`/events/${ev.id}${qs}`, { method: "DELETE" });
|
||
await reload();
|
||
}
|
||
|
||
// ---- search ----------------------------------------------------------------
|
||
const SEARCH_WINDOW_DAYS = 540; // ± window; "further back/forward" extends by this
|
||
let searchTimer = null;
|
||
|
||
async function fetchSearch() {
|
||
const s = state.search;
|
||
if (!s || !s.q.trim()) return;
|
||
s.loading = true;
|
||
try {
|
||
const res = await api.pluginFetch(
|
||
`/events?start=${encodeURIComponent(iso(s.from))}&end=${encodeURIComponent(iso(s.to))}&q=${encodeURIComponent(s.q.trim())}`);
|
||
s.results = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) }));
|
||
} catch { s.results = []; }
|
||
s.loading = false;
|
||
renderSearchPanel();
|
||
}
|
||
|
||
function renderSearchPanel() {
|
||
const panel = el.querySelector(".cal-right");
|
||
if (!panel) { render(); return; } // panel not mounted yet → full render
|
||
panel.innerHTML = searchPanelHtml();
|
||
wireSearchPanel();
|
||
}
|
||
|
||
function closeSearch() {
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
state.search = null;
|
||
render();
|
||
}
|
||
|
||
// Toggle a highlight on the calendar block(s) matching a hovered search result,
|
||
// only if that occurrence is drawn in the currently displayed view.
|
||
function highlightCalendarEvent(id, start, on) {
|
||
el.querySelectorAll(`.cal-view [data-ev="${id}"][data-start="${start}"]`)
|
||
.forEach((n) => n.classList.toggle("srch-hl", on));
|
||
}
|
||
|
||
async function openSearchResult(ev) {
|
||
const gs = startOfWeek(startOfMonth(state.anchor));
|
||
const visibleNow = state.view === "month" && ev._s >= gs && ev._s < addDays(gs, 42);
|
||
if (!visibleNow) {
|
||
state.view = "month";
|
||
state.anchor = startOfMonth(ev._s);
|
||
await reload();
|
||
}
|
||
const block = el.querySelector(`.cal-view [data-ev="${ev.id}"][data-start="${ev.start}"]`);
|
||
if (block) { openEvent(ev, block); return; }
|
||
// Not drawn (e.g. its calendar is hidden) — still show details near the view.
|
||
const view = el.querySelector(".cal-view");
|
||
const r = view ? view.getBoundingClientRect() : { top: 100, left: 300 };
|
||
state.eventPopover = { ev, rect: { top: r.top + 24, left: r.left + 40, right: r.left + 90, bottom: r.top + 44 } };
|
||
render();
|
||
}
|
||
|
||
function srchEventHtml(ev) {
|
||
return `<div class="cal-srch-ev" data-srch-ev="${ev.id}" data-srch-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">
|
||
<div class="t"><b>${esc(ev.summary || "(no title)")}</b><span>${ev.all_day ? "All day" : hhmm(ev._s)}</span></div>
|
||
${ev.location ? `<div class="loc">${esc(ev.location)}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
function searchPanelHtml() {
|
||
const s = state.search;
|
||
const fromStr = s.from.toLocaleDateString(dateLocale());
|
||
const toStr = s.to.toLocaleDateString(dateLocale());
|
||
if (s.loading && !s.results.length) {
|
||
return `<div class="cal-srch"><div class="cal-srch-hd"><span>Searching…</span><button class="cal-iconbtn" data-act="search-clear" title="Close search">✕</button></div></div>`;
|
||
}
|
||
// Group results by day; always include today's row (marked TODAY), like the mockup.
|
||
const byDay = new Map();
|
||
s.results.forEach((ev) => {
|
||
const key = iso(startOfDay(ev._s));
|
||
if (!byDay.has(key)) byDay.set(key, { date: startOfDay(ev._s), events: [] });
|
||
byDay.get(key).events.push(ev);
|
||
});
|
||
const todayKey = iso(startOfDay(new Date()));
|
||
if (!byDay.has(todayKey)) byDay.set(todayKey, { date: startOfDay(new Date()), events: [] });
|
||
const days = [...byDay.values()].sort((a, b) => a.date - b.date);
|
||
let rows = "";
|
||
let curMonth = null;
|
||
days.forEach((day) => {
|
||
const mk = day.date.getFullYear() * 12 + day.date.getMonth();
|
||
if (mk !== curMonth) {
|
||
curMonth = mk;
|
||
rows += `<div class="cal-srch-month">${MONTHS[day.date.getMonth()]} <span>${day.date.getFullYear()}</span></div>`;
|
||
}
|
||
const isToday = sameDay(day.date, new Date());
|
||
const evs = day.events.slice().sort((a, b) => a._s - b._s);
|
||
rows += `${isToday ? `<div class="cal-srch-today">TODAY</div>` : ""}
|
||
<div class="cal-srch-day">
|
||
<div class="cal-srch-dt${isToday ? " today" : ""}"><span class="dow">${DAYNAMES[(day.date.getDay() + 6) % 7].toUpperCase()}</span><span class="dnum">${day.date.getDate()}</span></div>
|
||
<div class="cal-srch-evs">${evs.length ? evs.map(srchEventHtml).join("") : `<div class="cal-srch-none">No events</div>`}</div>
|
||
</div>`;
|
||
});
|
||
return `<div class="cal-srch">
|
||
<div class="cal-srch-hd"><span>Showing results from ${esc(fromStr)}</span><button class="cal-iconbtn" data-act="search-clear" title="Close search">✕</button></div>
|
||
<div class="cal-srch-more"><button class="cal-btn" data-act="search-back">Search further back</button></div>
|
||
${rows}
|
||
<div class="cal-srch-hd" style="border:0"><span>Showing results up to ${esc(toStr)}</span></div>
|
||
<div class="cal-srch-more" style="border:0"><button class="cal-btn" data-act="search-fwd">Search further forward</button></div>
|
||
</div>`;
|
||
}
|
||
|
||
function wireSearchPanel() {
|
||
const panel = el.querySelector(".cal-right");
|
||
if (!panel || !state.search) return;
|
||
panel.querySelectorAll("[data-act=search-clear]").forEach((b) => b.addEventListener("click", closeSearch));
|
||
panel.querySelector("[data-act=search-back]")?.addEventListener("click", () => {
|
||
state.search.from = addDays(state.search.from, -SEARCH_WINDOW_DAYS); fetchSearch();
|
||
});
|
||
panel.querySelector("[data-act=search-fwd]")?.addEventListener("click", () => {
|
||
state.search.to = addDays(state.search.to, SEARCH_WINDOW_DAYS); fetchSearch();
|
||
});
|
||
panel.querySelectorAll("[data-srch-ev]").forEach((node) => {
|
||
const id = node.getAttribute("data-srch-ev");
|
||
const start = node.getAttribute("data-srch-start");
|
||
node.addEventListener("mouseenter", () => highlightCalendarEvent(id, start, true));
|
||
node.addEventListener("mouseleave", () => highlightCalendarEvent(id, start, false));
|
||
node.addEventListener("click", () => {
|
||
const ev = (state.search.results || []).find((x) => x.id === id && x.start === start);
|
||
if (ev) openSearchResult(ev);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Replace the Video-call button/link in place (no full re-render → keeps form input).
|
||
function fillVideo(link) {
|
||
if (!state.composing) return;
|
||
state.composing.conference = link;
|
||
const box = el.querySelector("[data-video]");
|
||
if (!box) return;
|
||
box.innerHTML = videoLinkHtml(link);
|
||
const copyBtn = box.querySelector("[data-act=copy-jitsi]");
|
||
if (copyBtn) copyBtn.addEventListener("click", () => copyLink(link, copyBtn));
|
||
const removeBtn = box.querySelector("[data-act=remove-video]");
|
||
if (removeBtn) removeBtn.addEventListener("click", clearVideo);
|
||
}
|
||
// ✕ on the link: no video call — and none re-added when another guest is invited.
|
||
function clearVideo() {
|
||
if (!state.composing) return;
|
||
state.composing.conference = "";
|
||
state.composing.videoRemoved = true;
|
||
const box = el.querySelector("[data-video]");
|
||
if (!box) return;
|
||
box.innerHTML = ADD_VIDEO_HTML;
|
||
const add = box.querySelector("[data-act=add-jitsi]");
|
||
if (add) add.addEventListener("click", () => fillVideo(jitsiLink(state.jitsi)));
|
||
}
|
||
function copyLink(link, btn) {
|
||
try { navigator.clipboard.writeText(link); } catch { /* ignore */ }
|
||
btn.classList.add("copied");
|
||
setTimeout(() => btn.classList.remove("copied"), 1200);
|
||
}
|
||
|
||
// Expand the collapsed "Who" section into the organizer + guest-input + chips UI,
|
||
// wire the invite button / autocomplete, and return an addGuestChip(email, name,
|
||
// autoVideo) helper (shared by the Add-guest button and edit prefill).
|
||
function expandGuests() {
|
||
const who = el.querySelector("[data-who]");
|
||
who.innerHTML = whoExpandedHtml();
|
||
const guests = who.querySelector("[data-guests]");
|
||
const inp = who.querySelector("[data-f=guest]");
|
||
const acpop = who.querySelector("[data-guest-ac]");
|
||
|
||
function addGuestChip(email, name, autoVideo = true) {
|
||
email = (email || "").trim();
|
||
if (!email) return;
|
||
const dup = [...guests.querySelectorAll("[data-guest]")].some(
|
||
(c) => c.getAttribute("data-guest").toLowerCase() === email.toLowerCase());
|
||
if (!dup) {
|
||
const label = name && name !== email ? `${name} <${email}>` : email;
|
||
const span = document.createElement("span");
|
||
span.className = "cal-guest";
|
||
span.setAttribute("data-guest", email);
|
||
span.setAttribute("data-name", name || "");
|
||
span.innerHTML = `${esc(label)} <button>×</button>`;
|
||
span.querySelector("button").addEventListener("click", () => span.remove());
|
||
guests.appendChild(span);
|
||
// Adding a NEW guest auto-provisions a video call if none exists yet
|
||
// (skipped when restoring an existing event's guests during edit).
|
||
if (autoVideo && !state.composing.conference && !state.composing.videoRemoved) {
|
||
fillVideo(jitsiLink(state.jitsi));
|
||
}
|
||
}
|
||
inp.value = "";
|
||
acpop.style.display = "none";
|
||
}
|
||
|
||
who.querySelector("[data-act=invite]").addEventListener("click", () => { addGuestChip(inp.value); inp.focus(); });
|
||
inp.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); addGuestChip(inp.value); }
|
||
else if (e.key === "Escape") acpop.style.display = "none";
|
||
});
|
||
inp.addEventListener("blur", () => setTimeout(() => { acpop.style.display = "none"; }, 150));
|
||
|
||
// Recipient autocomplete (same source as the mail composer's To field).
|
||
let acTimer;
|
||
inp.addEventListener("input", () => {
|
||
const query = inp.value.trim();
|
||
clearTimeout(acTimer);
|
||
if (!query) { acpop.style.display = "none"; return; }
|
||
acTimer = setTimeout(async () => {
|
||
let results = [];
|
||
try { results = await api.fetch(`/recipients/suggest?q=${encodeURIComponent(query)}`); } catch { /* ignore */ }
|
||
if (!results || !results.length) { acpop.style.display = "none"; return; }
|
||
acpop.innerHTML = results.map((r) =>
|
||
`<button type="button" data-ac-email="${esc(r.email)}" data-ac-name="${esc(r.name || "")}"><b>${esc(r.name || r.email)}</b>${r.name ? `<span>${esc(r.email)}</span>` : ""}</button>`).join("");
|
||
acpop.style.display = "";
|
||
acpop.querySelectorAll("[data-ac-email]").forEach((b) => b.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
addGuestChip(b.getAttribute("data-ac-email"), b.getAttribute("data-ac-name"));
|
||
inp.focus();
|
||
}));
|
||
}, 160);
|
||
});
|
||
return addGuestChip;
|
||
}
|
||
|
||
function go(delta) {
|
||
const a = state.anchor;
|
||
if (state.view === "month") state.anchor = new Date(a.getFullYear(), a.getMonth() + delta, 1);
|
||
else state.anchor = addDays(a, delta * (state.view === "day" ? 1 : state.view === "3days" ? 3 : 7));
|
||
state.mini = startOfMonth(state.anchor);
|
||
reload();
|
||
}
|
||
|
||
// ---- render ----------------------------------------------------------------
|
||
function render() {
|
||
if (state.embed) { renderEmbed(); return; }
|
||
el.innerHTML = `<div class="cal-app">
|
||
${state.leftCollapsed ? railPanel() : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
|
||
<div class="cal-maincol">
|
||
${topbar()}
|
||
<div class="cal-mainarea">
|
||
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
|
||
${(() => {
|
||
const inner = state.search ? searchPanelHtml() : (state.rightOpen ? agenda() : null);
|
||
return inner === null ? "" : `<div class="cal-resizer" data-rresizer></div><div class="cal-right" style="width:${state.rightW}px">${inner}</div>`;
|
||
})()}
|
||
</div>
|
||
</div>
|
||
${state.composing ? formHtml() : ""}
|
||
${state.eventPopover ? eventPopoverHtml() : ""}
|
||
${state.confirm ? confirmHtml() : ""}
|
||
</div>`;
|
||
wire();
|
||
if (state.view !== "month") scrollTimegrid();
|
||
if (state.eventPopover) positionPopover();
|
||
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
|
||
}
|
||
|
||
// Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/
|
||
// searchPanelHtml + the form/popover/confirm overlays exactly as the full app.
|
||
function renderEmbed() {
|
||
el.innerHTML = `<div class="cal-app cal-embed">
|
||
${panelBar()}
|
||
${state.search ? "" : `<div class="cal-embed-mini">${miniMonth()}</div>`}
|
||
<div class="cal-embed-body">
|
||
${state.search ? searchPanelHtml() : agenda()}
|
||
</div>
|
||
${panelFoot()}
|
||
${state.composing ? formHtml() : ""}
|
||
${state.eventPopover ? eventPopoverHtml() : ""}
|
||
${state.confirm ? confirmHtml() : ""}
|
||
</div>`;
|
||
wire();
|
||
if (state.scrollToAnchor) {
|
||
state.scrollToAnchor = false;
|
||
const a = state.anchor;
|
||
const row = el.querySelector(`[data-agday="${a.getFullYear()}-${pad2(a.getMonth() + 1)}-${pad2(a.getDate())}"]`);
|
||
const body = el.querySelector(".cal-embed-body");
|
||
if (row && body) body.scrollTop += row.getBoundingClientRect().top - body.getBoundingClientRect().top;
|
||
}
|
||
if (state.eventPopover) positionPopover();
|
||
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
|
||
}
|
||
|
||
function panelBar() {
|
||
const cur = state.hidden.size && state.calendars.length - state.hidden.size === 1
|
||
? state.calendars.find((c) => !state.hidden.has(c.id))
|
||
: null;
|
||
// Inline resets guarantee no default-button frame/background/focus-ring even if
|
||
// an older injected stylesheet is missing these classes.
|
||
const plain = "border:0;background:none;outline:none;box-shadow:none";
|
||
return `<div class="cal-panelbar">
|
||
<button class="cal-iconbtn" style="${plain}" data-act="new" title="New event"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
|
||
<button class="cal-iconbtn${state.searchOpen ? " on" : ""}" style="${plain}" data-act="panel-search" title="Search"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></button>
|
||
<div class="cal-dd cal-panelmenu">
|
||
<button class="cal-iconbtn" style="${plain}" data-act="panel-menu" title="Options"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></button>
|
||
${state.panelMenuOpen ? `<div class="cal-ddpop" style="left:0;min-width:170px">
|
||
<div class="cal-ddsec">Mini calendar</div>
|
||
<button data-panelmini="week">${state.panelMini === "week" ? "✓ " : " "}Week</button>
|
||
<button data-panelmini="month">${state.panelMini === "month" ? "✓ " : " "}Month</button>
|
||
<div class="cal-ddsep"></div>
|
||
<div class="cal-ddsec">View</div>
|
||
<button data-panelview="day">${state.panelView === "day" ? "✓ " : " "}Day</button>
|
||
<button data-panelview="3days">${state.panelView === "3days" ? "✓ " : " "}3 days</button>
|
||
<button data-panelview="agenda">${state.panelView === "agenda" ? "✓ " : " "}Agenda</button>
|
||
</div>` : ""}
|
||
</div>
|
||
<div style="flex:1"></div>
|
||
<div class="cal-dd cal-panelcal">
|
||
<button class="cal-panelcalbtn" style="display:flex;align-items:center;gap:6px;${plain};color:rgb(var(--app-fg));cursor:pointer;font:inherit;font-size:13px;padding:6px 4px" data-act="panelcal"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>${cur ? esc(cur.name) : "All calendars"} <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
|
||
${state.panelCalOpen ? `<div class="cal-ddpop" style="right:0;left:auto">
|
||
<button data-panelcal="all">${!cur ? "✓ " : ""}All calendars</button>
|
||
${state.calendars.map((c) => `<button data-panelcal="${c.id}">${cur && cur.id === c.id ? "✓ " : ""}<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:${esc(c.color)};margin-right:4px"></span>${esc(c.name)}</button>`).join("")}
|
||
</div>` : ""}
|
||
</div>
|
||
</div>
|
||
${state.searchOpen || state.search ? `<div class="cal-panelsearch"><div class="cal-searchbox"><svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg><input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>${state.search ? `<button class="cal-iconbtn" data-act="search-clear" title="Clear">✕</button>` : ""}</div></div>` : ""}`;
|
||
}
|
||
|
||
function panelFoot() {
|
||
if (!newTabLink) return "";
|
||
return `<div class="cal-panelfoot"><button data-act="open-newtab">Open calendar in new tab <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg></button></div>`;
|
||
}
|
||
|
||
function focusSearch() {
|
||
const si = el.querySelector(".cal-search");
|
||
if (si && document.activeElement !== si) {
|
||
si.focus();
|
||
const v = si.value;
|
||
si.setSelectionRange(v.length, v.length);
|
||
}
|
||
}
|
||
|
||
// Clamp the details popover fully inside the viewport (measure actual size after
|
||
// paint) so a long guest list never pushes it off-screen / makes the page scroll.
|
||
function positionPopover() {
|
||
const pop = el.querySelector(".cal-pop");
|
||
if (!pop) return;
|
||
const r = state.eventPopover.rect;
|
||
const w = pop.offsetWidth;
|
||
const h = pop.offsetHeight;
|
||
let left = r.right + 8;
|
||
if (left + w > window.innerWidth - 8) left = Math.max(8, r.left - w - 8);
|
||
if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8;
|
||
const top = Math.max(8, Math.min(r.top, window.innerHeight - h - 8));
|
||
pop.style.left = `${Math.max(8, left)}px`;
|
||
pop.style.top = `${top}px`;
|
||
}
|
||
|
||
function topbar() {
|
||
return `<div class="cal-topbar">
|
||
<div class="cal-searchbox">
|
||
<svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||
<input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>
|
||
${state.search ? `<button class="cal-iconbtn" data-act="search-clear" title="Clear search">✕</button>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
|
||
// toggle, so it can always be re-opened.
|
||
function railPanel() {
|
||
const TODAY_IC = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><circle cx="12" cy="15.5" r="2.3" fill="currentColor" stroke="none"/></svg>`;
|
||
return `<div class="wm-side cal-rail" style="flex:0 0 auto">
|
||
<div class="cal-railtop">
|
||
<button class="cal-railbtn" data-act="left-toggle" title="Expand sidebar">${SHOW_ICON}</button>
|
||
<button class="cal-railbtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
|
||
</div>
|
||
<div class="cal-railprimary"><button class="cal-railnew" data-act="new" title="New event">${COMPOSE_ICON}</button></div>
|
||
<div class="cal-railnav">
|
||
<button class="cal-railbtn" data-act="today" title="Today">${TODAY_IC}</button>
|
||
</div>
|
||
<div class="cal-railfoot">
|
||
<button class="cal-railbtn" data-act="settings" title="Settings">${SETTINGS_ICON}</button>
|
||
<button class="cal-railbtn" data-act="signout" title="Sign out">${LOGOUT_ICON}</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css):
|
||
// switcher + collapse/theme, primary button, an app-specific scrollable body
|
||
// (mini-month + calendars here), and the shared foot. Identical to Mail.
|
||
function leftPanel() {
|
||
return `
|
||
<div class="wm-side-head">
|
||
<div class="wm-side-switch">
|
||
<button data-act="menu">Calendar <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg></button>
|
||
${state.menuOpen ? `<div class="wm-side-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
|
||
</div>
|
||
<div class="wm-side-headicons">
|
||
<button class="wm-side-iconbtn" data-act="left-toggle" title="Collapse sidebar">${HIDE_ICON}</button>
|
||
<button class="wm-side-iconbtn wm-side-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
|
||
</div>
|
||
</div>
|
||
<div class="wm-side-primary">
|
||
<button data-act="new">${COMPOSE_ICON}New event</button>
|
||
</div>
|
||
<div class="wm-side-body">
|
||
${miniMonth()}
|
||
<div class="wm-side-section cal-secrow"><span>Calendars</span><button class="cal-secadd" data-act="add-calendar" title="Create calendar">+</button></div>
|
||
<div class="wm-side-nav">${state.calendars.map((c) => `
|
||
<label class="wm-side-item"><input type="checkbox" data-cal="${c.id}" ${state.hidden.has(c.id) ? "" : "checked"}/>
|
||
<span style="width:10px;height:10px;border-radius:2px;background:${esc(c.color)};display:inline-block"></span>${esc(c.name)}</label>`).join("")}</div>
|
||
</div>
|
||
<div class="wm-side-foot">
|
||
<button data-act="settings">${SETTINGS_ICON}Settings</button>
|
||
<button data-act="signout">${LOGOUT_ICON}Sign out</button>
|
||
${state.quota ? `<div class="wm-side-quota">${esc(state.quota)}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
function miniMonth() {
|
||
const m = state.mini;
|
||
// Embed "Mini calendar" option: Week shows just the anchor's week row.
|
||
const week = state.embed && state.panelMini === "week";
|
||
const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m));
|
||
const cellCount = week ? 7 : 42;
|
||
let cells = "";
|
||
for (let i = 0; i < cellCount; i++) {
|
||
const d = addDays(gs, i);
|
||
const today = sameDay(d, new Date());
|
||
// Embed: the agenda has no other sign of which day was picked.
|
||
const sel = state.embed && !today && sameDay(d, state.anchor);
|
||
const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", today ? "today" : "", sel ? "sel" : ""].join(" ");
|
||
cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`;
|
||
}
|
||
return `<div class="cal-mini">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
||
<b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b>
|
||
<span><button class="cal-iconbtn" data-act="miniprev">‹</button><button class="cal-iconbtn" data-act="mini-today" title="Go to today"><svg width="11" height="11" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button><button class="cal-iconbtn" data-act="mininext">›</button></span>
|
||
</div>
|
||
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function mainHead() {
|
||
const title = `<span class="cal-title">${MONTHS[state.anchor.getMonth()]} <span>${state.anchor.getFullYear()}</span></span>`;
|
||
const seg = ["day:Day", "3days:3 days", "week:Week", "month:Month"].map((v) => {
|
||
const [key, label] = v.split(":");
|
||
return `<button class="${state.view === key ? "on" : ""}" data-view="${key}">${label}</button>`;
|
||
}).join("");
|
||
return `<div class="cal-mainhead">
|
||
${title}
|
||
<div style="flex:1"></div>
|
||
<div class="cal-seg">${seg}</div>
|
||
<div style="flex:1"></div>
|
||
<button class="cal-iconbtn" data-act="prev">←</button>
|
||
<button class="cal-btn" data-act="today">Today</button>
|
||
<button class="cal-iconbtn" data-act="next">→</button>
|
||
${(state.rightOpen || state.search) ? "" : `<button class="cal-iconbtn" data-act="panel" title="Open side panel">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg>
|
||
</button>`}
|
||
</div>`;
|
||
}
|
||
|
||
function viewBody() {
|
||
return state.view === "month" ? monthView() : timegridView();
|
||
}
|
||
|
||
function formHtml() {
|
||
const c = state.composing;
|
||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||
const defCal = c.editId ? null : state.defaultCalendar; // new events honor the default calendar
|
||
const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => `<option value="${x.id}"${x.id === defCal ? " selected" : ""}>${esc(x.name)}</option>`).join("");
|
||
const chev = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg>`;
|
||
const colorItems = COLORS.map((cc) => `<button type="button" data-color="${cc[1]}"><span class="cal-dot" style="background:${cc[1]}"></span>${cc[0]}</button>`).join("");
|
||
const tz0 = c.tz || browserTz;
|
||
const formLeft = state.embed ? 8 : (state.leftCollapsed ? 12 : state.leftW + 12); // clear the full-height sidebar
|
||
return `<div class="cal-form" style="left:${formLeft}px">
|
||
<div class="cal-form-body">
|
||
<h4 style="margin-top:0">What</h4><input type="text" data-f="summary"/>
|
||
<h4>Where</h4><input type="text" data-f="location"/>
|
||
<div class="row" style="align-items:flex-start;gap:12px">
|
||
<div style="flex:1"><h4>Calendar</h4><select data-f="calendar">${calOpts}</select></div>
|
||
<div><h4>Color</h4>
|
||
<div class="cal-dd" data-color="${COLORS[0][1]}">
|
||
<button type="button" data-act="color-open"><span class="cal-dot" style="background:${COLORS[0][1]}"></span>${chev}</button>
|
||
<div class="cal-ddpop" style="display:none">${colorItems}</div>
|
||
</div>
|
||
</div>
|
||
<div><h4>Show as</h4><select data-f="showas"><option value="busy">Busy</option><option value="free">Free</option></select></div>
|
||
</div>
|
||
<h4>When</h4>
|
||
<label class="row" style="margin-bottom:6px"><input type="checkbox" data-f="allday" style="width:auto"/> All day</label>
|
||
<div class="row"><span class="cal-datewrap" data-datepick><input type="text" data-datedisplay autocomplete="off"/><input type="hidden" data-f="date" value="${c.date}"/></span><select data-f="time">${timeOptionsHtml(c.time)}</select></div>
|
||
<div style="margin-top:8px"><select data-f="duration">
|
||
<option value="15">15 minutes</option><option value="30">30 minutes</option>
|
||
<option value="60" selected>1 hour</option><option value="90">1.5 hours</option><option value="120">2 hours</option>
|
||
</select></div>
|
||
<div style="margin-top:8px" class="cal-tzfield" data-tz="${tz0}">
|
||
<button type="button" data-act="tz-open"><span data-tz-label>${esc(tzLongLabel(tz0))}</span> ${chev}</button>
|
||
<div class="cal-tzpop" style="display:none">
|
||
<input class="search" data-tz-search placeholder="Type to search by city…"/>
|
||
<div class="cal-tzlist"></div>
|
||
</div>
|
||
</div>
|
||
<h4>Repeat <button class="cal-tog" data-act="repeat-toggle" style="float:right"></button></h4>
|
||
<div data-repeat style="display:none">
|
||
<div class="row">
|
||
<span class="muted">every</span>
|
||
<input type="number" data-f="interval" value="1" min="1" style="width:60px"/>
|
||
<select data-f="freq"><option value="DAILY">day</option><option value="WEEKLY" selected>week</option><option value="MONTHLY">month</option><option value="YEARLY">year</option></select>
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<select data-f="endmode"><option value="forever">Repeats forever</option><option value="count">Ends after N</option><option value="until">Ends on date</option></select>
|
||
<input type="number" data-f="count" value="10" min="1" style="width:64px;display:none"/>
|
||
<span class="cal-datewrap" data-datepick data-untilwrap style="display:none;width:150px"><input type="text" data-datedisplay autocomplete="off"/><input type="hidden" data-f="until"/></span>
|
||
</div>
|
||
</div>
|
||
<h4>Who</h4>
|
||
<div class="muted" style="margin-bottom:6px">Guests will be emailed an invitation to the event.</div>
|
||
<div data-who>
|
||
<button class="cal-btn" data-act="add-guest">+ Add guest</button>
|
||
</div>
|
||
<h4>Video call</h4>
|
||
<div data-video>${c.conference
|
||
? videoLinkHtml(c.conference)
|
||
: ADD_VIDEO_HTML}</div>
|
||
<h4>Reminders</h4>
|
||
<div data-reminders></div>
|
||
<button class="cal-btn" data-act="add-reminder" style="margin-top:6px">+ Add reminder</button>
|
||
<h4>Description</h4><textarea data-f="notes" rows="3"></textarea>
|
||
</div>
|
||
<div class="cal-form-foot">
|
||
<button class="cal-btn cal-btn-primary" style="flex:none;width:auto" data-act="save">Save</button>
|
||
<button class="cal-btn" data-act="cancel">Cancel</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function eventPopoverHtml() {
|
||
const { ev, rect } = state.eventPopover;
|
||
const cal = state.calendars.find((c) => c.id === ev.calendar_id);
|
||
const dateStr = ev._s.toLocaleDateString(dateLocale(), { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||
const durMin = Math.round((ev._e - ev._s) / 60000);
|
||
const durLabel = durMin % 60 === 0 ? `${durMin / 60} hour${durMin / 60 > 1 ? "s" : ""}` : `${durMin} min`;
|
||
const when = ev.all_day ? "All day" : `${hhmm(ev._s)} – ${hhmm(ev._e)} ${tzAbbr(ev.tz)} (${durLabel})`;
|
||
const left = Math.min(rect.right + 8, window.innerWidth - 302);
|
||
const top = Math.min(Math.max(rect.top, 8), window.innerHeight - 230);
|
||
return `<div class="cal-popoverlay" style="position:fixed;inset:0;z-index:59" data-act="pop-close"></div>
|
||
<div class="cal-pop" style="left:${left}px;top:${top}px">
|
||
<div class="body">
|
||
<div class="cap">${esc(cal ? cal.name : "")}</div>
|
||
<div class="ttl">${esc(ev.summary || "(no title)")}</div>
|
||
${ev.location ? `<div class="loc">${esc(ev.location)}</div>` : ""}
|
||
<hr/>
|
||
<div><b>${esc(dateStr)}</b><br/>${esc(when)}</div>
|
||
${ev.conference_url ? `<div class="cal-videolink" style="margin-top:12px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg><a href="${esc(ev.conference_url)}" target="_blank" rel="noopener noreferrer">Join video call</a></div>` : ""}
|
||
${guestListHtml(ev)}
|
||
</div>
|
||
<div class="foot">
|
||
<button class="cal-btn" data-act="ev-edit">Edit</button>
|
||
<button class="cal-btn" data-act="ev-delete" style="color:#ef4444">Delete</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function confirmHtml() {
|
||
const ev = state.confirm.ev;
|
||
const title = esc(ev.summary || "(no title)");
|
||
// An event with guests: before deleting, ask whether to tell them.
|
||
if (state.confirm.scope || (!ev.recurring && guestsToInform(ev).length)) {
|
||
const n = guestsToInform(ev).length;
|
||
return `<div class="cal-modal-ov" data-act="confirm-cancel"><div class="cal-modal" data-stop>
|
||
<div class="body">
|
||
<h3>Delete “${title}”</h3>
|
||
<p>Would you like to inform the participants that this event is canceled?</p>
|
||
<p class="cal-modal-sub">${n === 1 ? "1 guest" : `${n} guests`} will get an email their calendar understands.</p>
|
||
<div class="cal-modal-stack">
|
||
<button class="primary" data-act="del-inform">Yes, inform them and delete</button>
|
||
<button data-act="del-quiet">No, just delete</button>
|
||
<button data-act="confirm-cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div></div>`;
|
||
}
|
||
if (ev.recurring) {
|
||
return `<div class="cal-modal-ov" data-act="confirm-cancel"><div class="cal-modal" data-stop>
|
||
<div class="body">
|
||
<h3>Delete recurring event</h3>
|
||
<p>“${title}” is a repeating event. What would you like to delete?</p>
|
||
<div class="opts">
|
||
<button data-act="del-this">This event only</button>
|
||
<button data-act="del-following">This and all following events</button>
|
||
</div>
|
||
</div>
|
||
<div class="foot"><button class="cal-btn" data-act="confirm-cancel">Cancel</button></div>
|
||
</div></div>`;
|
||
}
|
||
return `<div class="cal-modal-ov" data-act="confirm-cancel"><div class="cal-modal" data-stop>
|
||
<div class="body"><h3>Delete event</h3><p>Delete “${title}”? This can’t be undone.</p></div>
|
||
<div class="foot">
|
||
<button class="cal-btn" data-act="confirm-cancel">Cancel</button>
|
||
<button class="cal-btn" style="background:#ef4444;color:#fff" data-act="del-all">Delete</button>
|
||
</div>
|
||
</div></div>`;
|
||
}
|
||
|
||
function whoExpandedHtml() {
|
||
return `
|
||
<div class="row" style="justify-content:space-between;align-items:flex-start">
|
||
<div><div>${esc(state.me || "You")}</div><div class="muted">${esc(state.me || "")}</div></div>
|
||
<div class="muted" style="font-weight:600">Organizer</div>
|
||
</div>
|
||
<div class="row" style="margin-top:8px;align-items:stretch">
|
||
<div style="position:relative;flex:1">
|
||
<input type="email" data-f="guest" placeholder="email@example.com" autocomplete="off"/>
|
||
<div class="cal-acpop" data-guest-ac style="display:none"></div>
|
||
</div>
|
||
<button class="cal-btn" data-act="invite">Invite</button>
|
||
</div>
|
||
<div data-guests style="margin-top:6px"></div>`;
|
||
}
|
||
|
||
function reminderRow() {
|
||
return `<div class="row" data-reminder style="margin-bottom:6px">
|
||
<select data-rem><option value="5">5 minutes before</option><option value="10">10 minutes before</option><option value="15">15 minutes before</option><option value="30">30 minutes before</option><option value="60" selected>1 hour before</option><option value="1440">1 day before</option></select>
|
||
<button class="cal-iconbtn" data-act="rm-reminder">✕</button>
|
||
</div>`;
|
||
}
|
||
|
||
function renderTzList(field, filter) {
|
||
const cur = field.getAttribute("data-tz");
|
||
const list = field.querySelector(".cal-tzlist");
|
||
const entries = tzEntries(cur).filter(
|
||
(e) => !filter || e.city.toLowerCase().includes(filter) || (e.abbr && e.abbr.toLowerCase().includes(filter)),
|
||
);
|
||
list.innerHTML = entries.slice(0, 300).map(
|
||
(e) => `<button data-tzpick="${e.tz}">${e.cur ? "✓ " : ""}${esc(e.city)}<span class="abbr">${esc(e.abbr || "")}</span></button>`,
|
||
).join("");
|
||
list.querySelectorAll("[data-tzpick]").forEach((b) => b.addEventListener("click", () => {
|
||
const tz = b.getAttribute("data-tzpick");
|
||
field.setAttribute("data-tz", tz);
|
||
field.querySelector("[data-tz-label]").textContent = tzLongLabel(tz);
|
||
field.querySelector(".cal-tzpop").style.display = "none";
|
||
}));
|
||
}
|
||
|
||
function monthView() {
|
||
const gs = startOfWeek(startOfMonth(state.anchor));
|
||
const evs = visibleEvents();
|
||
let html = DAYNAMES.map((d) => `<div class="dow">${d}</div>`).join("");
|
||
for (let i = 0; i < 42; i++) {
|
||
const d = addDays(gs, i);
|
||
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
|
||
const cls = [d.getMonth() !== state.anchor.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" ");
|
||
const mtag = d.getDate() === 1 ? `<span class="mtag">${MONTHS[d.getMonth()].slice(0, 3)} ${d.getFullYear()}</span>` : "";
|
||
html += `<div class="cal-cell ${cls}" data-day="${iso(d)}">
|
||
<div class="n"><span class="num">${d.getDate()}</span>${mtag}</div>
|
||
${dayEvs.slice(0, 4).map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("")}
|
||
${dayEvs.length > 4 ? `<div class="cal-ev" style="border:0;background:none;color:rgb(var(--app-muted))">+${dayEvs.length - 4} more</div>` : ""}
|
||
</div>`;
|
||
}
|
||
return `<div class="cal-month">${html}</div>`;
|
||
}
|
||
|
||
function timegridView() {
|
||
const span = state.view === "day" ? 1 : state.view === "3days" ? 3 : 7;
|
||
const s0 = state.view === "week" ? startOfWeek(state.anchor) : startOfDay(state.anchor);
|
||
const days = Array.from({ length: span }, (_, i) => addDays(s0, i));
|
||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "Local";
|
||
const evs = visibleEvents();
|
||
const hours = Array.from({ length: 24 }, (_, h) => `<div>${h === 0 ? "" : HOUR12 ? (h % 12 || 12) + (h < 12 ? " AM" : " PM") : pad(h) + ":00"}</div>`).join("");
|
||
|
||
const headDays = days.map((d) => {
|
||
const today = sameDay(d, new Date());
|
||
const num = `${d.getDate()}${d.getDate() === 1 ? " " + MONTHS[d.getMonth()].slice(0, 3) : ""}`;
|
||
return `<div class="d${today ? " today" : ""}">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${today ? `<span class="num">${d.getDate()}</span>` : num}</b></div>`;
|
||
}).join("");
|
||
const gridH = 24 * HOUR_PX;
|
||
const cols = days.map((d) => {
|
||
const today = sameDay(d, new Date());
|
||
const dayStart = startOfDay(d), dayEnd = addDays(dayStart, 1);
|
||
const lines = Array.from({ length: 24 }, () => `<div class="line"></div>`).join("");
|
||
// Match all-day events by their start day (like month view) so a CalDAV event
|
||
// stored as UTC-midnight..midnight doesn't spill into the next local day.
|
||
// All-day and timed events share ONE column layout: all-day events are treated
|
||
// as spanning the whole day so overlapping events sit next to each other.
|
||
const items = [
|
||
...evs.filter((ev) => ev.all_day && sameDay(ev._s, d))
|
||
.map((ev) => ({ ev, _s: dayStart, _e: dayEnd, allday: true })),
|
||
...evs.filter((ev) => !ev.all_day && sameDay(ev._s, d))
|
||
.map((ev) => ({ ev, _s: ev._s, _e: ev._e, allday: false })),
|
||
];
|
||
const lay = layoutDay(items);
|
||
const blocks = items.map((it) => {
|
||
const ev = it.ev;
|
||
const pos = lay.get(it) || { col: 0, total: 1 };
|
||
const w = 100 / pos.total;
|
||
const xy = `left:calc(${pos.col * w}% + 1px);width:calc(${w}% - 3px)`;
|
||
if (it.allday) {
|
||
return `<div class="cal-aday" style="${xy};height:${gridH}px;border-color:${esc(ev.color)};background:${esc(ev.color)}22"><span class="lbl" data-ev="${ev.id}" data-start="${esc(ev.start)}">${esc(ev.summary || "(no title)")}</span></div>`;
|
||
}
|
||
const top = (ev._s.getHours() + ev._s.getMinutes() / 60) * HOUR_PX;
|
||
const h = Math.max(18, ((ev._e - ev._s) / 3600000) * HOUR_PX);
|
||
return `<div class="cal-tev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="top:${top}px;height:${h}px;${xy};border-color:${esc(ev.color)}"><b>${esc(ev.summary || "(no title)")}</b><div style="color:rgb(var(--app-muted))">${hhmm(ev._s)}</div></div>`;
|
||
}).join("");
|
||
const now = today ? `<div class="cal-now" style="top:${(new Date().getHours() + new Date().getMinutes() / 60) * HOUR_PX}px"></div>` : "";
|
||
return `<div class="cal-col${today ? " today" : ""}" data-col="${iso(d)}">${lines}${blocks}${now}</div>`;
|
||
}).join("");
|
||
|
||
return `<div class="cal-tg-head"><div class="cal-tz">${esc(tzAbbr(tz))}</div><div class="cal-tg-days" style="grid-template-columns:repeat(${span},1fr)">${headDays}</div></div>
|
||
<div class="cal-tg"><div class="cal-hours">${hours}</div><div class="cal-cols" style="grid-template-columns:repeat(${span},1fr)">${cols}</div></div>`;
|
||
}
|
||
|
||
function agenda() {
|
||
// Embed "View" option: Agenda = the month; Day/3 days = a short run from the anchor.
|
||
const short = state.embed && state.panelView !== "agenda";
|
||
const count = short ? (state.panelView === "day" ? 1 : 3) : 30;
|
||
const start = short ? startOfDay(state.anchor) : startOfMonth(state.anchor);
|
||
const evs = visibleEvents();
|
||
const rightAction = state.embed
|
||
? `<button class="cal-agenda-today" data-act="mini-today">TODAY</button>`
|
||
: `<button class="cal-iconbtn" data-act="panel">✕</button>`;
|
||
let html = `<div class="cal-mainhead"><span class="cal-title">${MONTHS[start.getMonth()]} <span>${start.getFullYear()}</span></span><div style="flex:1"></div>${rightAction}</div><div class="cal-agenda">`;
|
||
for (let i = 0; i < count; i++) {
|
||
const d = addDays(start, i);
|
||
if (!short && d.getMonth() !== start.getMonth()) break;
|
||
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
|
||
const key = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||
// An empty day is a place to add one: click → new event on that date.
|
||
const empty = dayEvs.length ? "" : ` empty" data-newday="${key}" role="button" title="New event on this day`;
|
||
html += `<div class="day${empty}" data-agday="${key}"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
|
||
dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>`
|
||
}</div></div>`;
|
||
}
|
||
return html + "</div>";
|
||
}
|
||
|
||
// ---- events ----------------------------------------------------------------
|
||
function wire() {
|
||
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
|
||
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
||
|
||
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
|
||
// --- embed (Mail right panel) toolbar ---
|
||
act("panel-search", () => { state.searchOpen = !state.searchOpen; if (!state.searchOpen && state.search) closeSearch(); else render(); });
|
||
act("panelcal", (e) => { e.stopPropagation(); state.panelCalOpen = !state.panelCalOpen; render(); });
|
||
on("[data-panelcal]", "click", (e) => {
|
||
e.stopPropagation();
|
||
const id = e.currentTarget.getAttribute("data-panelcal");
|
||
if (id === "all") state.hidden.clear();
|
||
else state.hidden = new Set(state.calendars.map((c) => c.id).filter((x) => x !== id));
|
||
state.panelCalOpen = false; render();
|
||
});
|
||
act("open-newtab", () => window.open("/p/calendar", "_blank", "noopener"));
|
||
act("panel-menu", (e) => { e.stopPropagation(); state.panelMenuOpen = !state.panelMenuOpen; render(); });
|
||
on("[data-panelmini]", "click", (e) => { e.stopPropagation(); state.panelMini = e.currentTarget.getAttribute("data-panelmini"); state.panelMenuOpen = false; render(); });
|
||
on("[data-panelview]", "click", (e) => { e.stopPropagation(); state.panelView = e.currentTarget.getAttribute("data-panelview"); state.panelMenuOpen = false; render(); });
|
||
if (state.panelCalOpen || state.panelMenuOpen) {
|
||
setTimeout(() => { const close = () => { state.panelCalOpen = false; state.panelMenuOpen = false; document.removeEventListener("click", close); render(); }; document.addEventListener("click", close); }, 0);
|
||
}
|
||
// --- search ---
|
||
on(".cal-search", "input", (e) => {
|
||
const q = e.currentTarget.value;
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
if (!q.trim()) { closeSearch(); return; }
|
||
if (!state.search) {
|
||
const today = startOfDay(new Date());
|
||
state.search = { q, from: addDays(today, -SEARCH_WINDOW_DAYS), to: addDays(today, SEARCH_WINDOW_DAYS), results: [], loading: true };
|
||
render(); // mounts the results panel (input re-focused in render)
|
||
} else {
|
||
state.search.q = q;
|
||
}
|
||
searchTimer = setTimeout(fetchSearch, 250);
|
||
});
|
||
on(".cal-search", "keydown", (e) => { if (e.key === "Escape") closeSearch(); });
|
||
// Topbar clear (the ✕ in the search box); the panel's own ✕ is wired in wireSearchPanel.
|
||
el.querySelectorAll(".cal-searchbox [data-act=search-clear]").forEach((b) => b.addEventListener("click", closeSearch));
|
||
if (state.search) wireSearchPanel();
|
||
on("[data-app]", "click", (e) => {
|
||
const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))];
|
||
state.menuOpen = false;
|
||
if (a.to) ctx.navigate(a.to); else render();
|
||
});
|
||
// Pass ?from=/p/calendar so Settings' back button returns here, not to Mail.
|
||
act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcalendar"));
|
||
act("add-calendar", () => ctx.navigate("/settings/calendars?from=%2Fp%2Fcalendar&create=1"));
|
||
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
||
act("new", () => openForm(null));
|
||
on("[data-newday]", "click", (e) => {
|
||
// That day, at the current time of day (as clicking a day in Month view).
|
||
const [y, m, dd] = e.currentTarget.dataset.newday.split("-").map(Number);
|
||
const now = new Date();
|
||
openForm(new Date(y, m - 1, dd, now.getHours(), now.getMinutes()));
|
||
});
|
||
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
|
||
act("prev", () => go(-1));
|
||
act("next", () => go(1));
|
||
act("panel", () => { state.rightOpen = !state.rightOpen; render(); });
|
||
act("miniprev", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() - 1, 1); render(); });
|
||
act("mininext", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() + 1, 1); render(); });
|
||
act("mini-today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); state.scrollToAnchor = state.embed; reload(); });
|
||
// --- left panel: collapse, theme, resize (like Mail) ---
|
||
act("left-toggle", () => {
|
||
state.leftCollapsed = !state.leftCollapsed;
|
||
try { localStorage.setItem("cal.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ }
|
||
render();
|
||
});
|
||
act("theme", () => {
|
||
const dark = document.documentElement.classList.toggle("dark");
|
||
try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ }
|
||
render();
|
||
});
|
||
const rz = el.querySelector("[data-resizer]");
|
||
if (rz) rz.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
const left = el.querySelector(".cal-left");
|
||
const startX = e.clientX;
|
||
const startW = left.offsetWidth;
|
||
const onMove = (ev) => {
|
||
const w = Math.max(190, Math.min(440, startW + ev.clientX - startX));
|
||
left.style.width = `${w}px`;
|
||
state.leftW = w;
|
||
};
|
||
const onUp = () => {
|
||
document.removeEventListener("mousemove", onMove);
|
||
document.removeEventListener("mouseup", onUp);
|
||
document.body.style.cursor = "";
|
||
document.body.style.userSelect = "";
|
||
try { localStorage.setItem("cal.leftW", String(state.leftW)); } catch { /* ignore */ }
|
||
};
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
document.body.style.cursor = "col-resize";
|
||
document.body.style.userSelect = "none";
|
||
});
|
||
// Right panel (agenda / search results) — drag its left edge to resize.
|
||
const rrz = el.querySelector("[data-rresizer]");
|
||
if (rrz) rrz.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
const right = el.querySelector(".cal-right");
|
||
const startX = e.clientX;
|
||
const startW = right.offsetWidth;
|
||
const onMove = (ev) => {
|
||
const w = Math.max(240, Math.min(640, startW - (ev.clientX - startX)));
|
||
right.style.width = `${w}px`;
|
||
state.rightW = w;
|
||
};
|
||
const onUp = () => {
|
||
document.removeEventListener("mousemove", onMove);
|
||
document.removeEventListener("mouseup", onUp);
|
||
document.body.style.cursor = "";
|
||
document.body.style.userSelect = "";
|
||
try { localStorage.setItem("cal.rightW", String(state.rightW)); } catch { /* ignore */ }
|
||
};
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
document.body.style.cursor = "col-resize";
|
||
document.body.style.userSelect = "none";
|
||
});
|
||
|
||
on("[data-view]", "click", (e) => { state.view = e.currentTarget.getAttribute("data-view"); reload(); });
|
||
on("[data-mini]", "click", (e) => {
|
||
state.anchor = startOfDay(new Date(e.currentTarget.getAttribute("data-mini")));
|
||
if (state.embed) {
|
||
// The panel's agenda already shows the whole month, so without this a
|
||
// picked day changed nothing on screen.
|
||
state.mini = startOfMonth(state.anchor);
|
||
state.scrollToAnchor = true;
|
||
} else if (state.view === "month") state.view = "day";
|
||
reload();
|
||
});
|
||
on("[data-cal]", "change", (e) => {
|
||
const id = e.currentTarget.getAttribute("data-cal");
|
||
if (e.currentTarget.checked) state.hidden.delete(id); else state.hidden.add(id);
|
||
render();
|
||
});
|
||
on("[data-ev]", "click", (e) => {
|
||
e.stopPropagation();
|
||
const id = e.currentTarget.getAttribute("data-ev");
|
||
const start = e.currentTarget.getAttribute("data-start");
|
||
// Recurring events share one base id across occurrences — match the clicked
|
||
// occurrence by its start too, so the popover shows that instance's date.
|
||
const ev = state.events.find((x) => x.id === id && (!start || x.start === start))
|
||
|| state.events.find((x) => x.id === id);
|
||
if (ev) openEvent(ev, e.currentTarget);
|
||
});
|
||
on("[data-day]", "click", (e) => {
|
||
const d = new Date(e.currentTarget.getAttribute("data-day"));
|
||
const now = new Date();
|
||
d.setHours(now.getHours(), now.getMinutes(), 0, 0); // default to the current time
|
||
openForm(d);
|
||
});
|
||
// --- event popover ---
|
||
act("pop-close", () => { state.eventPopover = null; render(); });
|
||
act("ev-delete", () => {
|
||
const ev = state.eventPopover.ev;
|
||
state.eventPopover = null;
|
||
state.confirm = { ev };
|
||
render();
|
||
});
|
||
act("ev-edit", () => { const ev = state.eventPopover.ev; openForm(ev._s, ev); });
|
||
// --- delete confirmation modal ---
|
||
on("[data-stop]", "click", (e) => e.stopPropagation());
|
||
act("confirm-cancel", () => { state.confirm = null; render(); });
|
||
act("del-all", () => doDelete("all", false));
|
||
act("del-this", () => chooseScope("this"));
|
||
act("del-following", () => chooseScope("following"));
|
||
act("del-inform", () => doDelete((state.confirm && state.confirm.scope) || "all", true));
|
||
act("del-quiet", () => doDelete((state.confirm && state.confirm.scope) || "all", false));
|
||
on("[data-col]", "click", (e) => {
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const hour = Math.max(0, Math.min(23, Math.floor((e.clientY - rect.top) / HOUR_PX)));
|
||
const d = new Date(e.currentTarget.getAttribute("data-col")); d.setHours(hour, 0, 0, 0);
|
||
openForm(d);
|
||
});
|
||
|
||
// --- new-event form ---
|
||
act("repeat-toggle", (e) => {
|
||
e.currentTarget.classList.toggle("on");
|
||
const box = el.querySelector("[data-repeat]");
|
||
if (box) box.style.display = e.currentTarget.classList.contains("on") ? "" : "none";
|
||
});
|
||
on("[data-f=endmode]", "change", (e) => {
|
||
const v = e.currentTarget.value;
|
||
el.querySelector("[data-f=count]").style.display = v === "count" ? "" : "none";
|
||
el.querySelector("[data-untilwrap]").style.display = v === "until" ? "" : "none";
|
||
});
|
||
on("[data-f=allday]", "change", (e) => {
|
||
const off = e.currentTarget.checked;
|
||
el.querySelector("[data-f=time]").disabled = off;
|
||
el.querySelector("[data-f=duration]").disabled = off;
|
||
});
|
||
// color dropdown
|
||
act("color-open", (e) => {
|
||
const pop = e.currentTarget.nextElementSibling;
|
||
pop.style.display = pop.style.display === "none" ? "" : "none";
|
||
});
|
||
on(".cal-ddpop [data-color]", "click", (e) => {
|
||
const color = e.currentTarget.getAttribute("data-color");
|
||
const dd = e.currentTarget.closest(".cal-dd");
|
||
dd.setAttribute("data-color", color);
|
||
dd.querySelector(".cal-dot").style.background = color;
|
||
dd.querySelector(".cal-ddpop").style.display = "none";
|
||
});
|
||
// timezone combobox
|
||
act("tz-open", (e) => {
|
||
const field = e.currentTarget.closest(".cal-tzfield");
|
||
const pop = field.querySelector(".cal-tzpop");
|
||
const open = pop.style.display === "none";
|
||
pop.style.display = open ? "" : "none";
|
||
if (open) {
|
||
renderTzList(field, "");
|
||
field.querySelector("[data-tz-search]").focus();
|
||
}
|
||
});
|
||
on("[data-tz-search]", "input", (e) => {
|
||
renderTzList(e.currentTarget.closest(".cal-tzfield"), e.currentTarget.value.toLowerCase());
|
||
});
|
||
act("add-guest", () => {
|
||
expandGuests();
|
||
const inp = el.querySelector("[data-f=guest]");
|
||
if (inp) inp.focus();
|
||
});
|
||
act("add-jitsi", () => fillVideo(jitsiLink(state.jitsi)));
|
||
// Copy handler for a link rendered on initial form paint (edit mode).
|
||
act("copy-jitsi", (e) => copyLink(state.composing.conference, e.currentTarget));
|
||
act("remove-video", clearVideo);
|
||
act("add-reminder", () => {
|
||
const box = el.querySelector("[data-reminders]");
|
||
const wrap = document.createElement("div");
|
||
wrap.innerHTML = reminderRow();
|
||
const row = wrap.firstElementChild;
|
||
row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove());
|
||
box.appendChild(row);
|
||
});
|
||
act("save", () => saveForm());
|
||
act("cancel", () => { state.composing = null; render(); });
|
||
}
|
||
|
||
function scrollTimegrid() {
|
||
const view = el.querySelector(".cal-view");
|
||
if (!view) return;
|
||
// Pin all-day labels just below the sticky day-header (its height is dynamic).
|
||
const head = view.querySelector(".cal-tg-head");
|
||
if (head) view.style.setProperty("--cal-head-h", head.offsetHeight + "px");
|
||
view.scrollTop = 7 * HOUR_PX; // start around 7 AM like the mockups
|
||
}
|
||
|
||
async function loadChrome() {
|
||
try {
|
||
// Product switcher apps: Mail + every plugin page from the manifest.
|
||
const manifest = await api.fetch("/plugins/ui-manifest");
|
||
const pages = (manifest.plugins || []).flatMap((p) => p.pages.map((pg) => ({ label: pg.nav.label, to: pg.route === "/calendar" ? null : "/p" + pg.route })));
|
||
state.apps = [{ label: "Mail", to: "/mail" }, ...pages];
|
||
} catch { /* keep defaults */ }
|
||
try {
|
||
const accounts = await api.fetch("/accounts");
|
||
if (accounts && accounts[0]) state.me = accounts[0].email;
|
||
state.myEmails = new Set((accounts || []).map((a) => String(a.email).toLowerCase()));
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const s = await api.fetch("/settings");
|
||
HOUR12 = s && s.time_format === "12"; // 24h is the default
|
||
state.jitsi = (s && s.jitsi_server) || "";
|
||
state.defaultCalendar = (s && s.default_calendar) || "";
|
||
// Reminders are stored as {method, minutes}; event reminders only use minutes.
|
||
const remMins = (arr) =>
|
||
(arr || []).map((x) => (typeof x === "number" ? x : (x && x.minutes) || 0));
|
||
state.defaultRemTimed = remMins(s && s.default_reminders_timed);
|
||
state.defaultRemAllday = remMins(s && s.default_reminders_allday);
|
||
} catch { /* keep defaults (24h, public Jitsi) */ }
|
||
try {
|
||
const q = await api.fetch("/settings/quota");
|
||
if (q && q.quota_bytes) {
|
||
const pct = (q.used_bytes / q.quota_bytes) * 100;
|
||
const gb = q.quota_bytes / 1024 ** 3;
|
||
state.quota = `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}% of ${Number.isInteger(gb) ? gb : gb.toFixed(1)} GB`;
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
(async () => {
|
||
await loadChrome();
|
||
await loadCalendars();
|
||
// Deep link (a reminder notification): /p/calendar?date=YYYY-MM-DD opens that
|
||
// day, then strips the param so refresh is clean.
|
||
try {
|
||
const day = new URLSearchParams(window.location.search).get("date");
|
||
if (!state.embed && day && /^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
||
const [y, m, d] = day.split("-").map(Number);
|
||
state.anchor = startOfDay(new Date(y, m - 1, d));
|
||
state.mini = startOfMonth(state.anchor);
|
||
state.view = "day";
|
||
window.history.replaceState({}, "", window.location.pathname);
|
||
}
|
||
} catch { /* ignore */ }
|
||
await loadEvents();
|
||
render();
|
||
// Embed mode ("Invite all to new event" from Mail's People tab): open a new-event
|
||
// form with the given guests, using the SAME form as the calendar app.
|
||
if (state.embed && newEventGuests && newEventGuests.length) {
|
||
openForm(null);
|
||
const addChip = expandGuests();
|
||
if (addChip) newEventGuests.map((g) => String(g).trim()).filter(Boolean).forEach((g) => addChip(g, "", false));
|
||
return;
|
||
}
|
||
// Deep link (Contacts "Invite"): /p/calendar?guest=<email> opens a new-event
|
||
// form with that guest already added, then strips the param so refresh is clean.
|
||
try {
|
||
const guest = new URLSearchParams(window.location.search).get("guest");
|
||
if (guest) {
|
||
window.history.replaceState({}, "", window.location.pathname);
|
||
openForm(null);
|
||
const addChip = expandGuests();
|
||
if (addChip) guest.split(",").map((g) => g.trim()).filter(Boolean).forEach((g) => addChip(g, "", false));
|
||
}
|
||
} catch { /* ignore */ }
|
||
})();
|
||
|
||
// An invitation answered in Mail (calendar · invite.js) changed the calendar.
|
||
// Not while a form is open: re-rendering would throw away what is being typed.
|
||
const onChanged = async () => {
|
||
await loadEvents();
|
||
if (!state.composing && !state.confirm) render();
|
||
};
|
||
window.addEventListener("webmail:calendar-changed", onChanged);
|
||
|
||
return () => {
|
||
window.removeEventListener("webmail:calendar-changed", onChanged);
|
||
el.innerHTML = "";
|
||
};
|
||
}
|