diff --git a/frontend/src/components/Resizer.tsx b/frontend/src/components/Resizer.tsx index 9e9eb60..505cf82 100644 --- a/frontend/src/components/Resizer.tsx +++ b/frontend/src/components/Resizer.tsx @@ -27,6 +27,8 @@ export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) { aria-orientation="vertical" >
+ {/* Small pill grab handle in the middle for easier grabbing. */} + ); } diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 137fbf1..310b27a 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -19,6 +19,13 @@ const esc = (s) => String(s == null ? "" : s).replace(/[&<>]/g, (c) => ({ "&": " 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 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 = ``; @@ -214,6 +221,8 @@ const CSS = ` .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 */ @@ -286,8 +295,12 @@ const CSS = ` .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: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)} @@ -305,6 +318,9 @@ const CSS = ` .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} .cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4} @@ -348,6 +364,22 @@ const CSS = ` .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 + 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} @@ -472,6 +504,115 @@ export default function mount(el, ctx, props) { } 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 += ""; + 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 += ``; + } + pop.innerHTML = ` +