← back to Dw Activation Calendar
Add two-tab DW Calendar UI: editable Google Calendar modal (create/edit/delete via modal-rig) + preserved SKU Activation grid; graceful not-connected state, event chips show created date+time
549858278a05d9c2037df6f0431c517bc4b39e17 · 2026-06-24 13:42:47 -0700 · Steve
Files touched
M public/index.htmlA public/modal-rig.js
Diff
commit 549858278a05d9c2037df6f0431c517bc4b39e17
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jun 24 13:42:47 2026 -0700
Add two-tab DW Calendar UI: editable Google Calendar modal (create/edit/delete via modal-rig) + preserved SKU Activation grid; graceful not-connected state, event chips show created date+time
---
public/index.html | 255 +++++++++++++++++++++++++++++++++++++++++++++++-----
public/modal-rig.js | 155 ++++++++++++++++++++++++++++++++
2 files changed, 387 insertions(+), 23 deletions(-)
diff --git a/public/index.html b/public/index.html
index 6ab7e10..d65886f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,11 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>DW Activation Calendar</title>
+<title>DW Calendar</title>
<style>
:root{
--bg:#0e0f12; --panel:#15171c; --panel2:#1b1e25; --line:#2a2e38;
--ink:#ece8df; --ink-2:#b8b3a6; --ink-3:#7c7768; --gold:#c2a064; --gold-dim:#8a7240;
+ --card:#16181d; --border:#2a2e38;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
@@ -23,6 +24,20 @@
button:hover{background:var(--gold)}
button.ghost{background:none;border:1px solid var(--line);color:var(--ink-2)}
button.ghost:hover{color:var(--ink);border-color:var(--gold-dim)}
+ button.danger{background:#7a3a3a;color:#f3e6e6}
+ button.danger:hover{background:#9b4848}
+ /* tabs */
+ .tabs{display:flex;gap:4px;padding:0 22px;border-bottom:1px solid var(--line);background:var(--bg);position:sticky;top:53px;z-index:4}
+ .tab{background:none;border:none;border-bottom:2px solid transparent;color:var(--ink-3);font-weight:600;padding:11px 16px;cursor:pointer;font-size:13px;border-radius:0}
+ .tab:hover{color:var(--ink-2);background:none}
+ .tab.active{color:var(--gold);border-bottom-color:var(--gold)}
+ .view{display:none}
+ .view.active{display:block}
+ /* connection banner */
+ .banner{margin:14px 22px;padding:11px 16px;border-radius:9px;border:1px solid var(--gold-dim);background:rgba(194,160,100,.08);color:var(--ink-2);font-size:13px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+ .banner.ok{border-color:#3c6d3c;background:rgba(109,179,109,.08)}
+ .banner b{color:var(--ink)}
+ /* SKU view (existing) */
.summary{display:flex;gap:22px;padding:10px 22px;border-bottom:1px solid var(--line);font-size:12px;color:var(--ink-2);flex-wrap:wrap}
.summary b{color:var(--ink);font-size:15px}
.summary .k{display:flex;flex-direction:column;gap:1px}
@@ -49,53 +64,243 @@
.legend .sw{width:10px;height:10px;border-radius:3px;display:inline-block}
.note{padding:0 22px 10px;font-size:11px;color:var(--ink-3)}
.loading{padding:60px;text-align:center;color:var(--ink-3)}
+ /* Google cal events */
+ .gcell .ev{display:block;width:100%;text-align:left;background:var(--panel2);border:1px solid var(--gold-dim);border-radius:5px;color:var(--ink);font-size:10px;line-height:1.3;padding:3px 5px;margin:0 0 4px;cursor:pointer;overflow:hidden}
+ .gcell .ev:hover{border-color:var(--gold);background:#23262e}
+ .gcell .ev .et{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .gcell .ev .ewhen{color:var(--gold)}
+ .gcell .ev .ecreated{color:var(--ink-3);font-size:8px;margin-top:1px}
+ .gcell .addbtn{margin:4px 0 0;font-size:10px;padding:2px 8px;background:none;border:1px dashed var(--line);color:var(--ink-3)}
+ .gcell .addbtn:hover{border-color:var(--gold-dim);color:var(--ink-2);background:none}
+ /* modal form */
+ .mform label{display:block;font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.5px;margin:12px 0 4px}
+ .mform input[type=text],.mform textarea,.mform input[type=datetime-local],.mform input[type=date]{
+ width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:6px;color:var(--ink);font:inherit;font-size:13px;padding:8px 10px}
+ .mform textarea{min-height:64px;resize:vertical}
+ .mform .row{display:flex;gap:12px}.mform .row>div{flex:1}
+ .mform .chk{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:13px;color:var(--ink-2)}
+ .mform .actions{display:flex;justify-content:space-between;align-items:center;margin-top:18px;gap:10px}
+ .mform .msg{font-size:12px;min-height:16px}
+ .mform .msg.err{color:#e08a8a}.mform .msg.ok{color:#8fd08f}
</style>
</head>
<body>
<header>
<div>
<div class="sub">Designer Wallcoverings</div>
- <div class="wordmark">Activation Calendar<span class="dot">.</span></div>
+ <div class="wordmark">DW Calendar<span class="dot">.</span></div>
</div>
<div class="spacer"></div>
- <div class="ctrl"><label>Per day</label><input id="perDay" type="number" value="500" min="1"></div>
- <div class="ctrl"><label>Per vendor/day</label><input id="perVendorDay" type="number" value="500" min="1"></div>
- <div class="ctrl"><label>Start</label><input id="start" type="date"></div>
- <button id="apply">Apply</button>
+ <!-- SKU-view controls (only shown on the SKU tab) -->
+ <div id="skuCtrls" style="display:none;gap:18px">
+ <div class="ctrl"><label>Per day</label><input id="perDay" type="number" value="500" min="1"></div>
+ <div class="ctrl"><label>Per vendor/day</label><input id="perVendorDay" type="number" value="500" min="1"></div>
+ <div class="ctrl"><label>Start</label><input id="start" type="date"></div>
+ <button id="apply">Apply</button>
+ </div>
</header>
-<div class="summary" id="summary"></div>
-<div class="note">Projected go-active schedule for every SKU staged for activation — published at <b>500 variants/day</b> (the 5-field backlog half of the 1,000/day cap), round-robin breadth-first across all vendors, in date order, until the list is cleared. Each chip is a SKU: product image + vendor. <span style="color:#6db36d">●</span> sample-add · <span style="color:#c2a064">●</span> roll/price build.</div>
+<div class="tabs">
+ <button class="tab" id="tab-gcal" data-view="gcal">Google Calendar</button>
+ <button class="tab" id="tab-sku" data-view="sku">SKU Activation</button>
+</div>
-<div class="monthbar">
- <button class="ghost" id="prev">‹ Prev</button>
- <div class="ml" id="mlabel">—</div>
- <button class="ghost" id="next">Next ›</button>
+<!-- ====================== GOOGLE CALENDAR VIEW ====================== -->
+<div class="view" id="view-gcal">
+ <div class="banner" id="gbanner">Checking calendar connection…</div>
+ <div class="monthbar">
+ <button class="ghost" id="gprev">‹ Prev</button>
+ <div class="ml" id="gmlabel">—</div>
+ <button class="ghost" id="gnext">Next ›</button>
+ <button id="gnew" style="margin-left:18px">+ New Event</button>
+ </div>
+ <div id="gcal"><div class="loading">Loading events…</div></div>
+ <div class="note">Live, editable view of the shared DW Google Calendar. Click any day or an event to create / edit. Events are written straight to Google Calendar (events.insert / patch / delete).</div>
</div>
-<div id="cal"><div class="loading">Loading schedule…</div></div>
-<div class="legend" id="legend"></div>
+<!-- ====================== SKU ACTIVATION VIEW (existing) ====================== -->
+<div class="view" id="view-sku">
+ <div class="summary" id="summary"></div>
+ <div class="note">Projected go-active schedule for every SKU staged for activation — published at <b>500 variants/day</b> (the 5-field backlog half of the 1,000/day cap), round-robin breadth-first across all vendors, in date order, until the list is cleared. Each chip is a SKU: product image + vendor. <span style="color:#6db36d">●</span> sample-add · <span style="color:#c2a064">●</span> roll/price build.</div>
+ <div class="monthbar">
+ <button class="ghost" id="prev">‹ Prev</button>
+ <div class="ml" id="mlabel">—</div>
+ <button class="ghost" id="next">Next ›</button>
+ </div>
+ <div id="cal"><div class="loading">Loading schedule…</div></div>
+ <div class="legend" id="legend"></div>
+</div>
+<script src="/modal-rig.js"></script>
<script>
-let DATA=null, viewMonth=null; // viewMonth = Date of 1st of shown month
+/* ======================= tabs ======================= */
+function setTab(v){
+ document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.dataset.view===v));
+ document.getElementById('view-gcal').classList.toggle('active',v==='gcal');
+ document.getElementById('view-sku').classList.toggle('active',v==='sku');
+ document.getElementById('skuCtrls').style.display = v==='sku' ? 'flex':'none';
+ localStorage.setItem('dwcal_tab',v);
+ if(v==='gcal' && !GDATA) loadGcal();
+ if(v==='sku' && !DATA) load();
+}
+document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>setTab(t.dataset.view));
+
+/* ======================= GOOGLE CALENDAR ======================= */
+let GDATA=null, gMonth=null, GSTATUS={connected:false,oauth_configured:false};
+function fmtMonth(d){return d.toLocaleString(undefined,{month:'long',year:'numeric'});}
+function fmtCreated(iso){
+ if(!iso) return '';
+ try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
+ catch(e){return iso;}
+}
+function fmtWhen(ev){
+ if(ev.allDay) return 'All day';
+ try{return new Date(ev.start).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'});}
+ catch(e){return '';}
+}
+async function refreshStatus(){
+ try{ GSTATUS = await (await fetch('/api/calendar/status')).json(); }
+ catch(e){ GSTATUS={connected:false,oauth_configured:false,error:e.message}; }
+ const b=document.getElementById('gbanner');
+ if(GSTATUS.connected){
+ b.className='banner ok';
+ b.innerHTML='<b>● Connected</b> — editing calendar <code>'+(GSTATUS.calendar_id||'primary')+'</code>. '+
+ '<button class="ghost" id="gdisc">Disconnect</button>';
+ document.getElementById('gdisc').onclick=async()=>{await fetch('/api/oauth/revoke',{method:'POST'});refreshStatus();loadGcal();};
+ }else{
+ b.className='banner';
+ b.innerHTML='<b>Calendar not connected — Steve must authorize.</b> '+
+ (GSTATUS.oauth_configured
+ ? 'OAuth is configured — click to grant access. '
+ : 'The Google OAuth credential has not been added yet (queued in pending-approval). ')+
+ '<a href="/api/oauth/start"><button>Connect</button></a>';
+ }
+}
+async function loadGcal(){
+ await refreshStatus();
+ if(!gMonth){const n=new Date();gMonth=new Date(n.getFullYear(),n.getMonth(),1);}
+ document.getElementById('gmlabel').textContent=fmtMonth(gMonth);
+ document.getElementById('gcal').innerHTML='<div class="loading">Loading events…</div>';
+ const y=gMonth.getFullYear(), m=gMonth.getMonth();
+ const timeMin=new Date(y,m,1).toISOString(), timeMax=new Date(y,m+1,0,23,59,59).toISOString();
+ let resp={connected:false,events:[]};
+ try{ resp=await (await fetch('/api/events?timeMin='+encodeURIComponent(timeMin)+'&timeMax='+encodeURIComponent(timeMax))).json(); }
+ catch(e){ resp={connected:false,events:[],error:e.message}; }
+ GDATA=resp;
+ renderGcal();
+}
+function renderGcal(){
+ const byDate={};
+ (GDATA.events||[]).forEach(ev=>{ const d=(ev.start||'').slice(0,10); (byDate[d]=byDate[d]||[]).push(ev); });
+ const y=gMonth.getFullYear(), m=gMonth.getMonth();
+ const cal=document.getElementById('gcal'); cal.className='cal'; cal.innerHTML='';
+ ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dow';h.textContent=d;cal.appendChild(h);});
+ const first=new Date(y,m,1), startDow=first.getDay(), days=new Date(y,m+1,0).getDate();
+ for(let i=0;i<startDow;i++){const c=document.createElement('div');c.className='cell empty';cal.appendChild(c);}
+ for(let dn=1;dn<=days;dn++){
+ const iso=`${y}-${String(m+1).padStart(2,'0')}-${String(dn).padStart(2,'0')}`;
+ const cell=document.createElement('div'); cell.className='cell gcell';
+ const evs=byDate[iso]||[];
+ cell.innerHTML=`<div class="dh"><span class="dn">${dn}</span>${evs.length?`<span class="cnt">${evs.length}</span>`:''}</div>`;
+ const body=document.createElement('div'); body.className='chips'; body.style.gridTemplateColumns='1fr'; body.style.display='block';
+ evs.forEach(ev=>{
+ const b=document.createElement('button'); b.className='ev';
+ b.innerHTML=`<div class="et">${esc(ev.summary)}</div>`+
+ `<div class="ewhen">${fmtWhen(ev)}</div>`+
+ `<div class="ecreated" title="${ev.created||''}">🕓 ${fmtCreated(ev.created)}</div>`;
+ b.onclick=(e)=>{e.stopPropagation();openEventModal(ev,iso);};
+ body.appendChild(b);
+ });
+ const add=document.createElement('button'); add.className='addbtn'; add.textContent='+ add';
+ add.onclick=(e)=>{e.stopPropagation();openEventModal(null,iso);};
+ body.appendChild(add);
+ cell.appendChild(body);
+ cal.appendChild(cell);
+ }
+}
+function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
+
+/* ---- the editable modal (create + edit) ---- */
+function toLocalInput(iso){ // ISO -> value for datetime-local (local tz)
+ if(!iso) return '';
+ const d=new Date(iso); if(isNaN(d)) return '';
+ const p=n=>String(n).padStart(2,'0');
+ return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+function openEventModal(ev, dayISO){
+ const isEdit=!!ev;
+ const startVal = ev ? (ev.allDay ? '' : toLocalInput(ev.start)) : (dayISO+'T09:00');
+ const endVal = ev ? (ev.allDay ? '' : toLocalInput(ev.end)) : (dayISO+'T10:00');
+ const allDay = ev ? !!ev.allDay : false;
+ const bodyHtml=`<form class="mform" id="evForm" onsubmit="return false">
+ <label>Title *</label><input type="text" id="f_summary" value="${esc(ev?ev.summary:'')}" placeholder="Event title" required>
+ <label>Description</label><textarea id="f_desc" placeholder="Notes">${esc(ev?ev.description:'')}</textarea>
+ <label>Location</label><input type="text" id="f_loc" value="${esc(ev?ev.location:'')}" placeholder="Optional">
+ <label class="chk"><input type="checkbox" id="f_allday" ${allDay?'checked':''}> All-day event</label>
+ <div class="row" id="timedRow" style="${allDay?'display:none':''}">
+ <div><label>Start</label><input type="datetime-local" id="f_start" value="${startVal}"></div>
+ <div><label>End</label><input type="datetime-local" id="f_end" value="${endVal}"></div>
+ </div>
+ <div class="row" id="dayRow" style="${allDay?'':'display:none'}">
+ <div><label>Date</label><input type="date" id="f_date" value="${(ev?ev.start:dayISO||'').slice(0,10)}"></div>
+ </div>
+ <div class="msg" id="f_msg"></div>
+ <div class="actions">
+ <div>${isEdit?'<button type="button" class="danger" id="f_del">Delete</button>':''}</div>
+ <div><button type="button" class="ghost" id="f_cancel">Cancel</button> <button type="button" id="f_save">${isEdit?'Save':'Create'}</button></div>
+ </div>
+ </form>`;
+ const M=ModalRig.open({key:'dwcal-event',title:isEdit?'Edit Event':'New Event',bodyHtml,width:440,height:520,bg:'var(--card)'});
+ const $=id=>M.body.querySelector('#'+id);
+ $('f_allday').onchange=()=>{const on=$('f_allday').checked;$('timedRow').style.display=on?'none':'';$('dayRow').style.display=on?'':'none';};
+ $('f_cancel').onclick=M.close;
+ if(isEdit) $('f_del').onclick=async()=>{
+ if(!confirm('Delete this event?')) return;
+ const r=await fetch('/api/events/'+encodeURIComponent(ev.id),{method:'DELETE'});
+ const j=await r.json().catch(()=>({}));
+ if(r.ok && j.ok){M.close();loadGcal();} else showMsg($,'Delete failed'+(j.error?': '+j.error:''),true);
+ };
+ $('f_save').onclick=async()=>{
+ const allday=$('f_allday').checked;
+ const payload={summary:$('f_summary').value.trim(),description:$('f_desc').value,location:$('f_loc').value,allDay:allday};
+ if(!payload.summary){showMsg($,'Title is required',true);return;}
+ if(allday){const d=$('f_date').value;if(!d){showMsg($,'Date required',true);return;}payload.start=d;payload.end=d;}
+ else{
+ const s=$('f_start').value,e=$('f_end').value;
+ if(!s||!e){showMsg($,'Start and end required',true);return;}
+ payload.start=new Date(s).toISOString();payload.end=new Date(e).toISOString();
+ }
+ showMsg($,'Saving…',false);
+ const url=isEdit?'/api/events/'+encodeURIComponent(ev.id):'/api/events';
+ const r=await fetch(url,{method:isEdit?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+ const j=await r.json().catch(()=>({}));
+ if(r.status===409){showMsg($,'Calendar not connected — Steve must authorize.',true);return;}
+ if(r.ok && j.ok){M.close();loadGcal();}
+ else showMsg($,'Save failed'+(j.error?': '+j.error:'')+(j.detail?' — '+j.detail:''),true);
+ };
+}
+function showMsg($,t,err){const m=$('f_msg');m.textContent=t;m.className='msg '+(err?'err':'ok');}
+
+document.getElementById('gprev').onclick=()=>{gMonth=new Date(gMonth.getFullYear(),gMonth.getMonth()-1,1);loadGcal();};
+document.getElementById('gnext').onclick=()=>{gMonth=new Date(gMonth.getFullYear(),gMonth.getMonth()+1,1);loadGcal();};
+document.getElementById('gnew').onclick=()=>{const n=new Date();openEventModal(null,n.toISOString().slice(0,10));};
+
+/* ======================= SKU ACTIVATION (existing, unchanged logic) ======================= */
+let DATA=null, viewMonth=null;
const VCOLORS={};
function vcolor(v){
if(VCOLORS[v]) return VCOLORS[v];
let h=0; for(const c of v) h=(h*31+c.charCodeAt(0))%360;
return VCOLORS[v]=`hsl(${h},42%,58%)`;
}
-const PROXY=u=>u; // shopify CDN loads direct
-function fmtMonth(d){return d.toLocaleString(undefined,{month:'long',year:'numeric'});}
-
async function load(){
const perDay=document.getElementById('perDay').value||500;
const perVendorDay=document.getElementById('perVendorDay').value||500;
const start=document.getElementById('start').value;
const qs=new URLSearchParams({perDay,perVendorDay}); if(start) qs.set('start',start);
- document.getElementById('cal').innerHTML='<div class="loading">Scheduling '+'…</div>';
+ document.getElementById('cal').innerHTML='<div class="loading">Scheduling …</div>';
const r=await fetch('/api/schedule?'+qs); DATA=await r.json();
renderSummary(); renderLegend();
- // default view = month of first scheduled day
const first=DATA.days[0]?new Date(DATA.days[0].date+'T00:00:00'):new Date();
viewMonth=new Date(first.getFullYear(),first.getMonth(),1);
renderMonth();
@@ -119,8 +324,7 @@ function renderMonth(){
const byDate={}; DATA.days.forEach(d=>byDate[d.date]=d);
const y=viewMonth.getFullYear(), m=viewMonth.getMonth();
document.getElementById('mlabel').textContent=fmtMonth(viewMonth);
- const cal=document.getElementById('cal'); cal.className=''; cal.innerHTML='';
- cal.style.display='grid'; cal.style.gridTemplateColumns='repeat(7,1fr)'; cal.style.gap='8px'; cal.style.padding='0 22px 30px';
+ const cal=document.getElementById('cal'); cal.className='cal'; cal.innerHTML='';
['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dow';h.textContent=d;cal.appendChild(h);});
const first=new Date(y,m,1), startDow=first.getDay(), days=new Date(y,m+1,0).getDate();
for(let i=0;i<startDow;i++){const c=document.createElement('div');c.className='cell empty';cal.appendChild(c);}
@@ -150,7 +354,12 @@ function renderMonth(){
document.getElementById('prev').onclick=()=>{viewMonth=new Date(viewMonth.getFullYear(),viewMonth.getMonth()-1,1);renderMonth();};
document.getElementById('next').onclick=()=>{viewMonth=new Date(viewMonth.getFullYear(),viewMonth.getMonth()+1,1);renderMonth();};
document.getElementById('apply').onclick=load;
-load();
+
+/* ======================= boot ======================= */
+// if landing via OAuth callback (?connected=1), open the gcal tab
+const initialTab = (new URLSearchParams(location.search).has('connected')) ? 'gcal'
+ : (localStorage.getItem('dwcal_tab') || 'gcal');
+setTab(initialTab);
</script>
</body>
</html>
diff --git a/public/modal-rig.js b/public/modal-rig.js
new file mode 100644
index 0000000..a62c5f9
--- /dev/null
+++ b/public/modal-rig.js
@@ -0,0 +1,155 @@
+/*!
+ * modal-rig.js — make any modal/panel "fully done": drag-to-rearrange,
+ * stretch/resize, collapsible sections, maximize, all persisted to localStorage.
+ * Vanilla JS, zero deps, framework-agnostic. Exposes window.ModalRig.
+ *
+ * ModalRig.rig(panelEl, { key:'mynote' }) // enhance an existing panel
+ * ModalRig.section(headerEl, bodyEl, 'mynote:links') // collapsible section
+ * var m = ModalRig.open({ key:'mynote', title:'Note', bodyHtml:'<p>…</p>' })
+ *
+ * Conventions:
+ * - The panel is absolutely positioned; first child (or opts.handle) is the drag handle.
+ * - resize:both gives a native corner handle; size + position persist under opts.key.
+ * - A ⤢ maximize button is injected into the handle unless maximizable:false.
+ */
+(function () {
+ function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (e) { return null; } }
+ function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
+ function px(n) { return Math.round(n) + 'px'; }
+
+ function applyRect(panel, key, opts) {
+ var w = opts.width || 760;
+ var h = opts.height || Math.min(620, Math.round(window.innerHeight * 0.8));
+ var r = lsGet('modalrig:' + key);
+ if (r && r.w) {
+ panel.style.left = px(r.left || 0); panel.style.top = px(r.top || 0);
+ panel.style.width = px(r.w); panel.style.height = px(r.h);
+ } else {
+ panel.style.left = px(Math.max(0, (window.innerWidth - w) / 2));
+ panel.style.top = px(Math.max(0, (window.innerHeight - h) / 2));
+ panel.style.width = px(w); panel.style.height = px(h);
+ }
+ }
+
+ function saveRect(panel, key) {
+ if (panel.getAttribute('data-rig-max') === '1') return; // don't persist the maximized state
+ lsSet('modalrig:' + key, {
+ left: parseInt(panel.style.left) || 0, top: parseInt(panel.style.top) || 0,
+ w: panel.offsetWidth, h: panel.offsetHeight
+ });
+ }
+
+ function startDrag(e, panel, key) {
+ if (e.target.closest && e.target.closest('[data-rig-btn]')) return; // not when clicking header buttons
+ if (panel.getAttribute('data-rig-max') === '1') unmaximize(panel, key); // dragging exits maximize
+ var box = panel.getBoundingClientRect();
+ var dx = e.clientX - box.left, dy = e.clientY - box.top;
+ function move(ev) {
+ panel.style.left = px(Math.max(0, Math.min(window.innerWidth - 80, ev.clientX - dx)));
+ panel.style.top = px(Math.max(0, Math.min(window.innerHeight - 40, ev.clientY - dy)));
+ }
+ function up() { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); saveRect(panel, key); }
+ document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
+ e.preventDefault();
+ }
+
+ function maximize(panel) {
+ panel.setAttribute('data-rig-pre', JSON.stringify({ left: panel.style.left, top: panel.style.top, w: panel.style.width, h: panel.style.height }));
+ panel.setAttribute('data-rig-max', '1');
+ panel.style.left = '2vw'; panel.style.top = '2vh'; panel.style.width = '96vw'; panel.style.height = '96vh';
+ }
+ function unmaximize(panel, key) {
+ var pre = null;
+ try { pre = JSON.parse(panel.getAttribute('data-rig-pre') || 'null'); } catch (e) { pre = null; }
+ panel.removeAttribute('data-rig-max');
+ if (pre) { panel.style.left = pre.left; panel.style.top = pre.top; panel.style.width = pre.w; panel.style.height = pre.h; }
+ }
+ function toggleMax(panel, key, btn) {
+ if (panel.getAttribute('data-rig-max') === '1') { unmaximize(panel, key); if (btn) btn.textContent = '⤢'; }
+ else { maximize(panel); if (btn) btn.textContent = '⤡'; }
+ }
+
+ function injectMaxBtn(handle, panel, key) {
+ if (handle.querySelector('[data-rig-max-btn]')) return;
+ var b = document.createElement('span');
+ b.setAttribute('data-rig-btn', '1'); b.setAttribute('data-rig-max-btn', '1');
+ b.title = 'maximize / restore'; b.textContent = '⤢';
+ b.style.cssText = 'cursor:pointer; opacity:.7; font-size:14px; line-height:1; margin-left:8px;';
+ b.addEventListener('click', function (e) { e.stopPropagation(); toggleMax(panel, key, b); });
+ handle.appendChild(b);
+ }
+
+ // Enhance an already-in-DOM panel.
+ function rig(panel, opts) {
+ if (!panel) return panel;
+ opts = opts || {};
+ var key = opts.key || 'default';
+ panel.style.position = 'absolute';
+ if (opts.resizable !== false) { panel.style.resize = 'both'; if (!panel.style.overflow) panel.style.overflow = 'hidden'; }
+ applyRect(panel, key, opts);
+ var handle = typeof opts.handle === 'string' ? panel.querySelector(opts.handle) : (opts.handle || panel.firstElementChild);
+ if (handle) {
+ handle.style.cursor = 'move'; handle.style.userSelect = 'none';
+ handle.addEventListener('mousedown', function (e) { startDrag(e, panel, key); });
+ if (opts.maximizable !== false) injectMaxBtn(handle, panel, key);
+ }
+ panel.addEventListener('mouseup', function () { saveRect(panel, key); });
+ // re-fit any registered onResize (e.g. a canvas/graph) when resized
+ if (opts.onResize && window.ResizeObserver) {
+ var ro = new ResizeObserver(function () { try { opts.onResize(panel); } catch (e) {} });
+ ro.observe(panel);
+ }
+ return panel;
+ }
+
+ // Collapsible section: clicking headerEl toggles bodyEl; state persists under key.
+ function section(headerEl, bodyEl, key) {
+ if (!headerEl || !bodyEl) return;
+ var collapsed = lsGet('modalrig-sec:' + key) === true;
+ var caret = document.createElement('span');
+ caret.textContent = collapsed ? '▸ ' : '▾ ';
+ headerEl.insertBefore(caret, headerEl.firstChild);
+ headerEl.style.cursor = 'pointer'; headerEl.style.userSelect = 'none';
+ bodyEl.style.display = collapsed ? 'none' : '';
+ headerEl.addEventListener('click', function () {
+ var nowOpen = bodyEl.style.display === 'none';
+ bodyEl.style.display = nowOpen ? '' : 'none';
+ caret.textContent = nowOpen ? '▾ ' : '▸ ';
+ lsSet('modalrig-sec:' + key, !nowOpen);
+ });
+ }
+
+ // Build a complete modal (backdrop + panel + header) and rig it. Returns
+ // { backdrop, panel, body, close }. Closes on backdrop click, ×, and Esc.
+ function open(o) {
+ o = o || {};
+ var key = o.key || 'modal';
+ var old = document.getElementById('modalrig-bd-' + key); if (old) old.remove();
+ var bd = document.createElement('div');
+ bd.id = 'modalrig-bd-' + key;
+ bd.style.cssText = 'position:fixed; inset:0; z-index:' + (o.z || 10000) + '; background:rgba(0,0,0,.5);';
+ var panel = document.createElement('div');
+ panel.style.cssText = 'display:flex; flex-direction:column; min-width:340px; min-height:180px; background:' + (o.bg || 'var(--card,#16181d)') + '; border:1px solid var(--border,#333); border-radius:10px; box-shadow:0 16px 56px rgba(0,0,0,.6);';
+ panel.addEventListener('click', function (e) { e.stopPropagation(); });
+ var head = document.createElement('div');
+ head.style.cssText = 'flex:0 0 auto; display:flex; justify-content:space-between; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--border,#333); background:var(--bg,#0a0a0f); border-radius:10px 10px 0 0;';
+ var title = document.createElement('span');
+ title.style.cssText = 'font-weight:600; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
+ title.textContent = o.title || '';
+ var btns = document.createElement('span'); btns.style.cssText = 'flex:0 0 auto; display:flex; align-items:center;';
+ var x = document.createElement('span'); x.setAttribute('data-rig-btn', '1'); x.textContent = '×';
+ x.title = 'close (Esc)'; x.style.cssText = 'cursor:pointer; opacity:.7; font-size:18px; line-height:1; margin-left:10px;';
+ var body = document.createElement('div');
+ body.style.cssText = 'flex:1 1 auto; overflow:auto; padding:14px 18px;';
+ if (o.bodyHtml != null) body.innerHTML = o.bodyHtml; else if (o.bodyEl) body.appendChild(o.bodyEl);
+ btns.appendChild(x); head.appendChild(title); head.appendChild(btns);
+ panel.appendChild(head); panel.appendChild(body); bd.appendChild(panel); document.body.appendChild(bd);
+ function close() { bd.remove(); if (o.onClose) o.onClose(); }
+ bd.addEventListener('click', close); x.addEventListener('click', close);
+ if (!window.__modalRigEsc) { window.__modalRigEsc = true; document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var t = document.querySelector('[id^="modalrig-bd-"]'); if (t) t.remove(); } }); }
+ rig(panel, { key: key, handle: head, resizable: o.resizable, maximizable: o.maximizable, width: o.width, height: o.height, onResize: o.onResize });
+ return { backdrop: bd, panel: panel, body: body, close: close };
+ }
+
+ window.ModalRig = { rig: rig, section: section, open: open };
+})();
← b9843c6 Add Google Calendar read + editable CRUD backend (OAuth + ev
·
back to Dw Activation Calendar
·
Document Google Calendar layer: README + updated server head c3c31ac →