M4 Calendar: publish public .ics links, subscriptions, multi-calendar settings
Calendar (builtin plugin):
- Publish read-only public .ics feed per calendar via vault-sealed token
(full-detail or free/busy-only); unauthenticated GET /public/{token},
404 on bad/absent token. Migration calendar0004 adds publish_token,
publish_busy, subscription_url + partial unique index.
- Subscribe to external iCal (.ics) URLs (SSRF-guarded) as read-only
calendars; CSRF-guarded POST /subscriptions.
- Settings > Calendars: create/edit/delete, publish toggles with live
shareable link + copy, Add-account stubs (Google/iCloud/CalDAV, deferred),
Subscriptions section; default calendar + default reminders; auto-detected
time zone; color-dropdown contrast fix; 3x-larger edit arrow.
- Left panel: shared Mail-parity sidebar template (.wm-side-*), calendar
search panel, font/spacing tuning, per-section "+" create shortcut.
Frontend: CalendarOut publish fields, api publish/subscribe, settings keys.
Backend: settings default_calendar + reminder lists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -20,7 +20,12 @@ 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");
|
||||
const pad2 = (n) => String(n).padStart(2, "0");
|
||||
const SIDEBAR_ICON = `<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="M9 4v16"/></svg>`;
|
||||
// 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).
|
||||
@@ -186,61 +191,82 @@ function tzEntries(current) {
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
.cal-app{display:flex;flex-direction:column;height:100vh;position:relative;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font:14px system-ui,-apple-system,sans-serif}
|
||||
.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}
|
||||
.cal-body{display:flex;flex:1;min-height:0}
|
||||
.cal-left{flex:0 0 auto;border-right:1px solid rgb(var(--app-border));display:flex;flex-direction:column;overflow:auto}
|
||||
.cal-lefthead{display:flex;align-items:center;gap:4px;padding:8px 8px 2px}
|
||||
/* 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))}
|
||||
.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-themebtn{font-size:15px;line-height:1}
|
||||
.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}
|
||||
.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:6px;display:inline-flex}
|
||||
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex}
|
||||
.cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||
.cal-switch{position:relative}
|
||||
.cal-switch>button{display:flex;align-items:center;gap:6px;border:0;background:none;color:rgb(var(--app-fg));font-weight:600;font-size:15px;cursor:pointer;padding:4px 6px;border-radius:6px}
|
||||
.cal-switch>button:hover{background:rgb(var(--app-hover))}
|
||||
.cal-menu{position:absolute;left:0;top:100%;margin-top:4px;min-width:160px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:30;box-shadow:0 6px 20px rgba(0,0,0,.3)}
|
||||
.cal-menu button{display:block;width:100%;text-align:left;border:0;background:none;color:rgb(var(--app-muted));padding:6px 10px;border-radius:6px;cursor:pointer;font:inherit}
|
||||
.cal-menu button:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||
.cal-search{flex:1;max-width:520px;margin:0 auto;padding:7px 12px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));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 16px;border-radius:7px;cursor:pointer;font:inherit;font-size:13px}
|
||||
.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:10px}
|
||||
.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{border:0;background:none;color:rgb(var(--app-fg));padding:4px 0;border-radius:50%;cursor:pointer;font:inherit}
|
||||
.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-section{padding:10px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;color:rgb(var(--app-faint))}
|
||||
.cal-callist label{display:flex;align-items:center;gap:8px;padding:5px 12px;cursor:pointer}
|
||||
.cal-foot{margin-top:auto;border-top:1px solid rgb(var(--app-border));padding:8px}
|
||||
.cal-foot button{display:flex;align-items:center;gap:10px;width:100%;border:0;background:none;color:rgb(var(--app-muted));padding:8px 12px;border-radius:8px;cursor:pointer;font:inherit;font-size:15px;text-align:left}
|
||||
.cal-foot button:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||
/* month */
|
||||
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);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:11px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))}
|
||||
.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{font-size:15px;color:rgb(var(--app-fg));font-weight:500}
|
||||
.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(255,255,255,.03)}
|
||||
.cal-cell.today{background:rgba(59,130,246,.06)}
|
||||
.cal-cell.today .n{background:#3b82f6;color:#fff;border-radius:50%;width:25px;height:25px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.cal-ev{font-size:12px;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-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:12px;color:rgb(var(--app-muted))}
|
||||
.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}
|
||||
@@ -251,7 +277,7 @@ const CSS = `
|
||||
.cal-col{border-left:1px solid rgb(var(--app-border));position:relative}
|
||||
.cal-col.today{background:rgba(59,130,246,.06)}
|
||||
.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:12px;overflow:hidden;cursor:pointer;box-sizing:border-box}
|
||||
.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}
|
||||
.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))}
|
||||
@@ -356,9 +382,13 @@ export default function mount(el, ctx) {
|
||||
composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
|
||||
eventPopover: null, // null | { ev, rect }
|
||||
confirm: null, // null | { ev } — delete confirmation modal
|
||||
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
|
||||
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 }],
|
||||
};
|
||||
|
||||
@@ -406,10 +436,32 @@ export default function mount(el, ctx) {
|
||||
};
|
||||
render();
|
||||
if (editEv) prefillForm(editEv);
|
||||
else applyDefaultReminders(state.defaultRemTimed); // new events start with the user's defaults
|
||||
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 || "";
|
||||
@@ -547,6 +599,131 @@ export default function mount(el, ctx) {
|
||||
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;
|
||||
@@ -636,11 +813,13 @@ export default function mount(el, ctx) {
|
||||
// ---- render ----------------------------------------------------------------
|
||||
function render() {
|
||||
el.innerHTML = `<div class="cal-app">
|
||||
${topbar()}
|
||||
<div class="cal-body">
|
||||
${state.leftCollapsed ? "" : `<div class="cal-left" style="width:${state.leftW}px">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
|
||||
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
|
||||
${state.rightOpen ? `<div class="cal-right">${agenda()}</div>` : ""}
|
||||
${state.leftCollapsed ? "" : `<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>
|
||||
${state.search ? `<div class="cal-right">${searchPanelHtml()}</div>` : (state.rightOpen ? `<div class="cal-right">${agenda()}</div>` : "")}
|
||||
</div>
|
||||
</div>
|
||||
${state.composing ? formHtml() : ""}
|
||||
${state.eventPopover ? eventPopoverHtml() : ""}
|
||||
@@ -649,6 +828,16 @@ export default function mount(el, ctx) {
|
||||
wire();
|
||||
if (state.view !== "month") scrollTimegrid();
|
||||
if (state.eventPopover) positionPopover();
|
||||
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
|
||||
}
|
||||
|
||||
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
|
||||
@@ -668,36 +857,45 @@ export default function mount(el, ctx) {
|
||||
}
|
||||
|
||||
function topbar() {
|
||||
const title = MONTHS[state.anchor.getMonth()] + " " + state.anchor.getFullYear();
|
||||
return `<div class="cal-topbar">
|
||||
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-expand" title="Show sidebar">${SIDEBAR_ICON}</button>` : ""}
|
||||
<div class="cal-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="cal-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
|
||||
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-toggle" title="Show sidebar">${SHOW_ICON}</button>` : ""}
|
||||
<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>
|
||||
<input class="cal-search" placeholder="Search calendar"/>
|
||||
</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="cal-lefthead">
|
||||
<div style="flex:1"></div>
|
||||
<button class="cal-iconbtn" data-act="left-collapse" title="Collapse sidebar">${SIDEBAR_ICON}</button>
|
||||
<button class="cal-iconbtn cal-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
|
||||
<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 style="padding:4px 10px 10px;display:flex;gap:6px">
|
||||
<button class="cal-btn cal-btn-primary" data-act="new">+ New event</button>
|
||||
<div class="wm-side-primary">
|
||||
<button data-act="new">${COMPOSE_ICON}New event</button>
|
||||
</div>
|
||||
${miniMonth()}
|
||||
<div class="cal-section">Calendars</div>
|
||||
<div class="cal-callist">${state.calendars.map((c) => `
|
||||
<label><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 class="cal-foot">
|
||||
<button data-act="settings"><svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button>
|
||||
<button data-act="signout"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Sign out</button>
|
||||
${state.quota ? `<div style="border-top:1px solid rgb(var(--app-border));margin-top:4px;padding:8px 12px 2px;font-size:13px;color:rgb(var(--app-faint))">${esc(state.quota)}</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>`;
|
||||
}
|
||||
|
||||
@@ -713,7 +911,7 @@ export default function mount(el, ctx) {
|
||||
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="mininext">›</button></span>
|
||||
<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>`;
|
||||
@@ -733,9 +931,9 @@ export default function mount(el, ctx) {
|
||||
<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>
|
||||
<button class="cal-iconbtn" data-act="panel" title="Toggle side panel">
|
||||
${(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>
|
||||
</button>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -746,11 +944,13 @@ export default function mount(el, ctx) {
|
||||
function formHtml() {
|
||||
const c = state.composing;
|
||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => `<option value="${x.id}">${esc(x.name)}</option>`).join("");
|
||||
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;
|
||||
return `<div class="cal-form">
|
||||
const formLeft = 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"/>
|
||||
@@ -912,9 +1112,9 @@ export default function mount(el, ctx) {
|
||||
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 label = d.getDate() === 1 ? `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getDate()}` : d.getDate();
|
||||
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">${label}</div>
|
||||
<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>`;
|
||||
@@ -976,12 +1176,31 @@ export default function mount(el, ctx) {
|
||||
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
||||
|
||||
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
|
||||
// --- 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();
|
||||
});
|
||||
act("settings", () => ctx.navigate("/settings"));
|
||||
act("add-calendar", () => ctx.navigate("/settings/calendars?create=1"));
|
||||
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
||||
act("new", () => openForm(null));
|
||||
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
|
||||
@@ -990,10 +1209,13 @@ export default function mount(el, ctx) {
|
||||
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); reload(); });
|
||||
// --- left panel: collapse, theme, resize (like Mail) ---
|
||||
const setLeft = (v) => { state.leftCollapsed = v; try { localStorage.setItem("cal.leftCollapsed", v ? "1" : "0"); } catch { /* ignore */ } render(); };
|
||||
act("left-collapse", () => setLeft(true));
|
||||
act("left-expand", () => setLeft(false));
|
||||
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 */ }
|
||||
@@ -1150,6 +1372,9 @@ export default function mount(el, ctx) {
|
||||
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) || "";
|
||||
state.defaultRemTimed = (s && s.default_reminders_timed) || [];
|
||||
state.defaultRemAllday = (s && s.default_reminders_allday) || [];
|
||||
} catch { /* keep defaults (24h, public Jitsi) */ }
|
||||
try {
|
||||
const q = await api.fetch("/settings/quota");
|
||||
|
||||
Reference in New Issue
Block a user