← back to Wallco Ai
admin: draggable+popout modal kit (all 19 pages) + windowed /admin/workspace dashboard (add/move/resize grid panels, layout persists)
ef32cc70ed7b889c2df9ed0bee39ffc9cd5bdb25 · 2026-05-31 18:36:29 -0700 · Steve Abrams
Files touched
A public/admin/admin-modal-kit.jsM public/admin/bg-tester.htmlM public/admin/bleed-review.htmlM public/admin/botanical-wonders-curator.htmlM public/admin/cactus-curator.htmlM public/admin/defect-registry.htmlM public/admin/dogs-curator.htmlM public/admin/drunk-curator.htmlM public/admin/edges-review.htmlM public/admin/elements.htmlM public/admin/etsy-bucket.htmlM public/admin/etsy-setup.htmlM public/admin/fliepaper-bugs.htmlM public/admin/ghost-review.htmlM public/admin/inspirations.htmlM public/admin/mid-heal-preview.htmlM public/admin/rooms.htmlM public/admin/seam-debug.htmlM public/admin/status-browser.htmlM public/admin/theme-gallery.htmlA public/admin/workspace.htmlM src/admin.js
Diff
commit ef32cc70ed7b889c2df9ed0bee39ffc9cd5bdb25
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 31 18:36:29 2026 -0700
admin: draggable+popout modal kit (all 19 pages) + windowed /admin/workspace dashboard (add/move/resize grid panels, layout persists)
---
public/admin/admin-modal-kit.js | 237 ++++++++++++++++++++++++++++
public/admin/bg-tester.html | 1 +
public/admin/bleed-review.html | 1 +
public/admin/botanical-wonders-curator.html | 1 +
public/admin/cactus-curator.html | 1 +
public/admin/defect-registry.html | 1 +
public/admin/dogs-curator.html | 1 +
public/admin/drunk-curator.html | 1 +
public/admin/edges-review.html | 1 +
public/admin/elements.html | 1 +
public/admin/etsy-bucket.html | 1 +
public/admin/etsy-setup.html | 1 +
public/admin/fliepaper-bugs.html | 1 +
public/admin/ghost-review.html | 1 +
public/admin/inspirations.html | 1 +
public/admin/mid-heal-preview.html | 1 +
public/admin/rooms.html | 1 +
public/admin/seam-debug.html | 1 +
public/admin/status-browser.html | 1 +
public/admin/theme-gallery.html | 1 +
public/admin/workspace.html | 202 ++++++++++++++++++++++++
src/admin.js | 5 +
22 files changed, 463 insertions(+)
diff --git a/public/admin/admin-modal-kit.js b/public/admin/admin-modal-kit.js
new file mode 100644
index 0000000..282802c
--- /dev/null
+++ b/public/admin/admin-modal-kit.js
@@ -0,0 +1,237 @@
+/* admin-modal-kit.js — Steve 2026-05-31
+ * Makes EVERY admin modal drag-by-titlebar + popout (un-dim so you can work
+ * around it) + remember its position across opens. Drop-in: just
+ * <script src="/admin/admin-modal-kit.js" defer></script>
+ * on any admin page. No markup changes required — it auto-detects modal panels
+ * by a selector list and enhances each the first time it becomes visible.
+ *
+ * Design notes:
+ * - We enhance the modal *panel* (the content box), not the full-screen scrim.
+ * - A MutationObserver catches modals that are injected/shown after load.
+ * - Position persists in localStorage keyed by pathname + panel identity.
+ * - Defensive throughout: a bad page must never throw and break the modal.
+ * - Opt out per element with data-no-drag; mark a custom handle with
+ * data-amk-handle.
+ */
+(function () {
+ 'use strict';
+ if (window.__amkLoaded) return; window.__amkLoaded = true;
+
+ // Candidate "panel" selectors (the movable content box). Order matters:
+ // most-specific first so we don't grab a full-screen scrim by accident.
+ var PANEL_SEL = [
+ '.modal-box', '.modal-inner', '.modal-side', '.modal-card', '.modal-content',
+ '.nsm', '.dialog', '[role="dialog"]', '.popup', '.sheet', '.amk-window'
+ ];
+ // Bare ".modal" / ".overlay" are usually the SCRIM. Only treat them as the
+ // panel if they have no inner panel child (some pages style .modal as the box).
+ var MAYBE_PANEL_SEL = ['.modal', '.overlay', '.backdrop'];
+
+ var STORE = 'amk:' + location.pathname.replace(/\/+$/, '');
+
+ function load() { try { return JSON.parse(localStorage.getItem(STORE) || '{}'); } catch (e) { return {}; } }
+ function save(o) { try { localStorage.setItem(STORE, JSON.stringify(o)); } catch (e) {} }
+
+ function keyFor(el, idx) {
+ if (el.id) return '#' + el.id;
+ if (el.dataset.amkKey) return el.dataset.amkKey;
+ var k = (el.className || 'panel').toString().trim().split(/\s+/).slice(0, 2).join('.') + ':' + idx;
+ el.dataset.amkKey = k;
+ return k;
+ }
+
+ function visible(el) {
+ if (!el || !el.isConnected) return false;
+ var cs = getComputedStyle(el);
+ if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) return false;
+ var r = el.getBoundingClientRect();
+ return r.width > 120 && r.height > 80; // real panel, not a sliver
+ }
+
+ // Is this element a full-screen scrim (cover the viewport, semi-transparent)?
+ function looksLikeScrim(el) {
+ var r = el.getBoundingClientRect();
+ return r.width > innerWidth * 0.9 && r.height > innerHeight * 0.9;
+ }
+
+ function findScrim(panel) {
+ // Walk up: the first fixed/absolute full-screen ancestor is the backdrop.
+ var p = panel.parentElement, hop = 0;
+ while (p && hop++ < 6) {
+ var cs = getComputedStyle(p);
+ if ((cs.position === 'fixed' || cs.position === 'absolute') && looksLikeScrim(p)) return p;
+ p = p.parentElement;
+ }
+ return null;
+ }
+
+ function pickHandle(panel) {
+ if (panel.dataset.amkHandle) {
+ var h = panel.querySelector(panel.dataset.amkHandle);
+ if (h) return h;
+ }
+ // existing header-ish element
+ var head = panel.querySelector('.modal-head, .modal-title, .modal-header, header, h1, h2, h3');
+ if (head && head.offsetParent !== null) return head;
+ return null;
+ }
+
+ function enhance(panel, idx) {
+ if (panel.__amk) return;
+ if (panel.closest('[data-no-drag]') || panel.hasAttribute('data-no-drag')) return;
+ panel.__amk = true;
+
+ var store = load();
+ var key = keyFor(panel, idx);
+
+ // Build the title bar (grip + title + popout + close).
+ var bar = document.createElement('div');
+ bar.className = 'amk-bar';
+ var existingHandle = pickHandle(panel);
+ var titleTxt = (existingHandle && existingHandle.textContent.trim().slice(0, 60)) ||
+ panel.getAttribute('aria-label') || 'Move';
+ bar.innerHTML =
+ '<span class="amk-grip" title="Drag to move">⠿</span>' +
+ '<span class="amk-title"></span>' +
+ '<span class="amk-spacer"></span>' +
+ '<button type="button" class="amk-btn amk-pop" title="Pop out (un-dim, float free)">⤢</button>' +
+ '<button type="button" class="amk-btn amk-reset" title="Reset position">⟲</button>' +
+ '<button type="button" class="amk-btn amk-close" title="Close">✕</button>';
+ bar.querySelector('.amk-title').textContent = existingHandle ? '' : titleTxt;
+
+ // Make the panel positionable.
+ var cs = getComputedStyle(panel);
+ if (cs.position === 'static') panel.style.position = 'relative';
+ panel.classList.add('amk-panel');
+ panel.insertBefore(bar, panel.firstChild);
+
+ // Restore saved geometry.
+ var saved = store[key];
+ function applyFixed(left, top) {
+ panel.style.position = 'fixed';
+ panel.style.margin = '0';
+ panel.style.left = Math.round(left) + 'px';
+ panel.style.top = Math.round(top) + 'px';
+ panel.style.right = 'auto'; panel.style.bottom = 'auto';
+ panel.style.transform = 'none';
+ }
+ if (saved && typeof saved.left === 'number') {
+ applyFixed(saved.left, saved.top);
+ if (saved.popped) setPopped(true);
+ }
+
+ // ---- Drag ----
+ var dragging = false, sx = 0, sy = 0, ol = 0, ot = 0;
+ function onDown(e) {
+ if (e.target.closest('.amk-btn')) return; // buttons aren't drag
+ var r = panel.getBoundingClientRect();
+ applyFixed(r.left, r.top); // pin where it currently is
+ dragging = true; sx = e.clientX; sy = e.clientY; ol = r.left; ot = r.top;
+ bar.setPointerCapture && bar.setPointerCapture(e.pointerId);
+ document.body.style.userSelect = 'none';
+ e.preventDefault();
+ }
+ function onMove(e) {
+ if (!dragging) return;
+ var nl = ol + (e.clientX - sx), nt = ot + (e.clientY - sy);
+ var r = panel.getBoundingClientRect();
+ nl = Math.max(8 - r.width + 60, Math.min(nl, innerWidth - 60)); // keep grabbable
+ nt = Math.max(0, Math.min(nt, innerHeight - 36));
+ panel.style.left = nl + 'px'; panel.style.top = nt + 'px';
+ }
+ function onUp() {
+ if (!dragging) return;
+ dragging = false; document.body.style.userSelect = '';
+ persist();
+ }
+ bar.addEventListener('pointerdown', onDown);
+ addEventListener('pointermove', onMove);
+ addEventListener('pointerup', onUp);
+
+ // ---- Popout (un-dim the scrim, float free, click-through behind) ----
+ var scrim = findScrim(panel);
+ var popped = false;
+ function setPopped(on) {
+ popped = on;
+ panel.classList.toggle('amk-popped', on);
+ bar.querySelector('.amk-pop').classList.toggle('on', on);
+ if (scrim) {
+ scrim.style.background = on ? 'transparent' : '';
+ scrim.style.pointerEvents = on ? 'none' : ''; // clicks pass through to page
+ panel.style.pointerEvents = 'auto';
+ }
+ if (on && getComputedStyle(panel).position !== 'fixed') {
+ var r = panel.getBoundingClientRect(); applyFixed(r.left, r.top);
+ }
+ }
+ bar.querySelector('.amk-pop').addEventListener('click', function () { setPopped(!popped); persist(); });
+
+ // ---- Reset ----
+ bar.querySelector('.amk-reset').addEventListener('click', function () {
+ panel.style.cssText = panel.style.cssText
+ .replace(/(left|top|right|bottom|position|margin|transform)\s*:[^;]*;?/g, '');
+ panel.classList.remove('amk-popped');
+ if (scrim) { scrim.style.background = ''; scrim.style.pointerEvents = ''; }
+ popped = false; bar.querySelector('.amk-pop').classList.remove('on');
+ var s = load(); delete s[key]; save(s);
+ });
+
+ // ---- Close (delegate to whatever the page already wires) ----
+ bar.querySelector('.amk-close').addEventListener('click', function () {
+ // reset scrim so the page's own close logic isn't fighting transparency
+ if (scrim) { scrim.style.background = ''; scrim.style.pointerEvents = ''; }
+ var btn = panel.querySelector('.modal-close, [data-close], .close, .nsm-close');
+ if (btn) { btn.click(); return; }
+ if (scrim) { scrim.click(); return; } // many pages close on scrim click
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ });
+
+ function persist() {
+ var s = load(); var r = panel.getBoundingClientRect();
+ s[key] = { left: r.left, top: r.top, popped: popped };
+ save(s);
+ }
+ }
+
+ function scan() {
+ var sel = PANEL_SEL.join(',');
+ document.querySelectorAll(sel).forEach(function (el, i) {
+ if (visible(el) && !el.__amk) enhance(el, i);
+ });
+ // bare .modal/.overlay only if it has no inner panel and isn't itself a scrim wrapper of one
+ document.querySelectorAll(MAYBE_PANEL_SEL.join(',')).forEach(function (el, i) {
+ if (el.__amk) return;
+ if (!visible(el)) return;
+ if (el.querySelector(PANEL_SEL.join(','))) return; // it's a scrim around a real panel
+ if (looksLikeScrim(el) && el.children.length) return; // pure scrim
+ enhance(el, 100 + i);
+ });
+ }
+
+ // Styles
+ var css = document.createElement('style');
+ css.textContent =
+ '.amk-panel{box-shadow:0 18px 60px rgba(0,0,0,.32)!important}' +
+ '.amk-bar{display:flex;align-items:center;gap:8px;cursor:grab;user-select:none;' +
+ 'padding:6px 8px;margin:-1px -1px 6px;background:linear-gradient(#1c2030,#141826);' +
+ 'color:#e8eaf2;border-radius:10px 10px 0 0;font:600 12px -apple-system,system-ui,sans-serif}' +
+ '.amk-bar:active{cursor:grabbing}' +
+ '.amk-grip{opacity:.7;font-size:14px;letter-spacing:-2px}' +
+ '.amk-title{opacity:.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60%}' +
+ '.amk-spacer{flex:1}' +
+ '.amk-btn{border:0;background:rgba(255,255,255,.10);color:#e8eaf2;cursor:pointer;' +
+ 'width:24px;height:22px;border-radius:6px;font-size:12px;line-height:1;padding:0}' +
+ '.amk-btn:hover{background:rgba(255,255,255,.22)}' +
+ '.amk-pop.on{background:#34cbf4;color:#06202a}' +
+ '.amk-popped{z-index:2147483000!important}';
+ (document.head || document.documentElement).appendChild(css);
+
+ var mo = new MutationObserver(function () { clearTimeout(window.__amkT); window.__amkT = setTimeout(scan, 60); });
+ function start() {
+ scan();
+ mo.observe(document.body, { childList: true, subtree: true, attributes: true,
+ attributeFilter: ['style', 'class', 'hidden', 'aria-hidden'] });
+ }
+ if (document.readyState === 'loading') addEventListener('DOMContentLoaded', start);
+ else start();
+})();
diff --git a/public/admin/bg-tester.html b/public/admin/bg-tester.html
index a594fac..ed1f10a 100644
--- a/public/admin/bg-tester.html
+++ b/public/admin/bg-tester.html
@@ -592,5 +592,6 @@
});
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/bleed-review.html b/public/admin/bleed-review.html
index d22cf9a..7a8c9c8 100644
--- a/public/admin/bleed-review.html
+++ b/public/admin/bleed-review.html
@@ -441,5 +441,6 @@
load();
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/botanical-wonders-curator.html b/public/admin/botanical-wonders-curator.html
index ec437bc..f8206b0 100644
--- a/public/admin/botanical-wonders-curator.html
+++ b/public/admin/botanical-wonders-curator.html
@@ -213,5 +213,6 @@ $('#density').value=cols; $('#densval').textContent=cols;
document.documentElement.style.setProperty('--cols',cols);
load();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 0cae7a4..a43133a 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -1575,5 +1575,6 @@ load();
});
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/defect-registry.html b/public/admin/defect-registry.html
index df900c4..c23273f 100644
--- a/public/admin/defect-registry.html
+++ b/public/admin/defect-registry.html
@@ -149,5 +149,6 @@ $('#density').oninput = e => applyDensity(e.target.value);
load();
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/dogs-curator.html b/public/admin/dogs-curator.html
index 4058822..7e32688 100644
--- a/public/admin/dogs-curator.html
+++ b/public/admin/dogs-curator.html
@@ -205,5 +205,6 @@ $('#density').value=cols; $('#densval').textContent=cols;
document.documentElement.style.setProperty('--cols',cols);
load();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/drunk-curator.html b/public/admin/drunk-curator.html
index e1e7c7e..c7033b5 100644
--- a/public/admin/drunk-curator.html
+++ b/public/admin/drunk-curator.html
@@ -739,5 +739,6 @@ loadCollections();
rebuildQueue();
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/edges-review.html b/public/admin/edges-review.html
index b474027..ef32225 100644
--- a/public/admin/edges-review.html
+++ b/public/admin/edges-review.html
@@ -547,5 +547,6 @@
}).catch(() => {});
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/elements.html b/public/admin/elements.html
index cff0f03..ac71c66 100644
--- a/public/admin/elements.html
+++ b/public/admin/elements.html
@@ -550,5 +550,6 @@
});
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/etsy-bucket.html b/public/admin/etsy-bucket.html
index 25228e2..a8bcd22 100644
--- a/public/admin/etsy-bucket.html
+++ b/public/admin/etsy-bucket.html
@@ -94,5 +94,6 @@ async function load(){
}
load();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/etsy-setup.html b/public/admin/etsy-setup.html
index 1187b49..6574008 100644
--- a/public/admin/etsy-setup.html
+++ b/public/admin/etsy-setup.html
@@ -239,5 +239,6 @@ $('testBtn').onclick = async () => {
};
refresh();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/fliepaper-bugs.html b/public/admin/fliepaper-bugs.html
index 2ab7872..31b8a30 100644
--- a/public/admin/fliepaper-bugs.html
+++ b/public/admin/fliepaper-bugs.html
@@ -238,5 +238,6 @@ refresh();
// 20s auto-refresh — admin grid should reflect yolo-runner progress live.
setInterval(refresh, 20000);
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index 54e4763..9510274 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -2073,5 +2073,6 @@
boot().catch(e => { document.body.innerHTML = '<pre style="padding:40px;color:#b00">' + e.stack + '</pre>'; });
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/inspirations.html b/public/admin/inspirations.html
index 0a1d892..01a8b53 100644
--- a/public/admin/inspirations.html
+++ b/public/admin/inspirations.html
@@ -331,5 +331,6 @@
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/mid-heal-preview.html b/public/admin/mid-heal-preview.html
index f7eb322..349ca4e 100644
--- a/public/admin/mid-heal-preview.html
+++ b/public/admin/mid-heal-preview.html
@@ -210,5 +210,6 @@ fetch('/api/admin/mid-heal-preview/sample')
`<div class="loading">fetch failed: ${err.message}</div>`;
});
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/rooms.html b/public/admin/rooms.html
index 74760bf..567aaad 100644
--- a/public/admin/rooms.html
+++ b/public/admin/rooms.html
@@ -89,5 +89,6 @@ function render(){
}catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/seam-debug.html b/public/admin/seam-debug.html
index e9f5396..2c21b52 100644
--- a/public/admin/seam-debug.html
+++ b/public/admin/seam-debug.html
@@ -297,5 +297,6 @@ $('healall').addEventListener('click',()=>healSelected((CUR?.boxes||[]).map((_,i
$('regenbtn').addEventListener('click',regenerate);
if(ID0) load();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/status-browser.html b/public/admin/status-browser.html
index 8f5ee0a..942c3b4 100644
--- a/public/admin/status-browser.html
+++ b/public/admin/status-browser.html
@@ -666,5 +666,6 @@
loadCategories(); loadStats(); load();
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/theme-gallery.html b/public/admin/theme-gallery.html
index eddfe69..d0c16d3 100644
--- a/public/admin/theme-gallery.html
+++ b/public/admin/theme-gallery.html
@@ -318,5 +318,6 @@ window.addEventListener('resize', ()=>{ clearTimeout(window._rt); window._rt=set
else newest();
})();
</script>
+ <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>
diff --git a/public/admin/workspace.html b/public/admin/workspace.html
new file mode 100644
index 0000000..7067d92
--- /dev/null
+++ b/public/admin/workspace.html
@@ -0,0 +1,202 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Admin Workspace — wallco.ai</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<style>
+ :root{--bg:#0f1117;--ink:#e8eaf2;--mut:#8b93a7;--line:#262b3a;--card:#161a26;--accent:#34cbf4;}
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:
+ radial-gradient(1200px 600px at 20% -10%,#1a2030,transparent),var(--bg);
+ color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;overflow:hidden}
+ header.ws{position:fixed;top:0;left:0;right:0;height:52px;z-index:50;display:flex;align-items:center;
+ gap:12px;padding:0 16px;background:rgba(15,17,23,.92);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
+ h1{font-size:15px;margin:0;font-weight:650;letter-spacing:.2px}
+ .sub{color:var(--mut);font-size:12px}
+ button,select{font:inherit;color:var(--ink);background:#1c2030;border:1px solid var(--line);
+ border-radius:8px;padding:7px 12px;cursor:pointer}
+ button.primary{background:var(--accent);color:#06202a;border-color:var(--accent);font-weight:650}
+ button:hover{border-color:var(--accent)}
+ .spacer{flex:1}
+ main{position:fixed;inset:52px 0 0 0;overflow:hidden}
+ .empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
+ color:var(--mut);gap:14px;text-align:center;padding:24px}
+ .empty h2{color:var(--ink);font-weight:600;margin:0}
+ /* Windows */
+ .win{position:absolute;background:var(--card);border:1px solid var(--line);border-radius:12px;
+ box-shadow:0 22px 70px rgba(0,0,0,.5);display:flex;flex-direction:column;overflow:hidden;min-width:280px;min-height:180px}
+ .win.focus{border-color:var(--accent);box-shadow:0 22px 80px rgba(52,203,244,.18),0 22px 70px rgba(0,0,0,.5)}
+ .win-bar{display:flex;align-items:center;gap:8px;padding:7px 9px;cursor:grab;user-select:none;
+ background:linear-gradient(#1c2030,#141826);border-bottom:1px solid var(--line)}
+ .win-bar:active{cursor:grabbing}
+ .win-grip{opacity:.6;letter-spacing:-2px}
+ .win-title{font-weight:600;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
+ .win-btn{border:0;background:rgba(255,255,255,.10);color:var(--ink);width:24px;height:22px;border-radius:6px;
+ cursor:pointer;font-size:12px;line-height:1;padding:0}
+ .win-btn:hover{background:rgba(255,255,255,.22)}
+ .win-body{flex:1;position:relative;background:#fff}
+ .win-body iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff}
+ .win-resize{position:absolute;right:2px;bottom:2px;width:16px;height:16px;cursor:nwse-resize;
+ background:linear-gradient(135deg,transparent 50%,var(--mut) 50%);border-radius:0 0 8px 0;opacity:.7}
+ .win.min .win-body,.win.min .win-resize{display:none}
+ .win.min{height:auto!important;min-height:0}
+ .chip{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:999px;padding:3px 9px}
+</style>
+</head>
+<body>
+<header class="ws">
+ <h1>🪟 Admin Workspace</h1>
+ <span class="sub" id="count">0 panels</span>
+ <span class="spacer"></span>
+ <select id="addSel" title="Pick a grid/page to open as a panel">
+ <option value="">+ Add panel…</option>
+ </select>
+ <button id="tileBtn" title="Auto-arrange all panels in a tidy grid">▦ Tile</button>
+ <button id="cascadeBtn" title="Cascade panels diagonally">▧ Cascade</button>
+ <button id="clearBtn" title="Close all panels">Clear</button>
+</header>
+<main id="stage">
+ <div class="empty" id="empty">
+ <h2>Empty workspace</h2>
+ <div class="sub">Use <strong>+ Add panel</strong> to open any admin grid as a movable, resizable window.<br>
+ Drag the title bar to move · drag the corner to resize · layout auto-saves.</div>
+ </div>
+</main>
+
+<script>
+// ---- Catalog of openable admin grids/pages ----
+const PAGES = [
+ ['Cactus Curator', '/admin/cactus-curator'],
+ ['Drunk Curator', '/admin/drunk-curator'],
+ ['Dogs Curator', '/admin/dogs-curator'],
+ ['Botanical Curator', '/admin/botanical-wonders-curator'],
+ ['Ghost Review', '/admin/ghost-review'],
+ ['Edges Review', '/admin/edges-review'],
+ ['Bleed Review', '/admin/bleed-review'],
+ ['Defect Registry', '/admin/defect-registry'],
+ ['Rooms Gallery', '/admin/rooms'],
+ ['Theme Gallery', '/admin/theme-gallery'],
+ ['Elements', '/admin/elements'],
+ ['Inspirations', '/admin/inspirations'],
+ ['Etsy Bucket', '/admin/etsy-bucket'],
+ ['Status Browser', '/admin/status-browser'],
+ ['Designs grid (public)', '/designs'],
+];
+const STORE = 'ws-admin-layout-v1';
+const $ = s => document.querySelector(s);
+const stage = $('#stage');
+let z = 10, wins = [];
+
+PAGES.forEach(([label,url]) => {
+ const o = document.createElement('option'); o.value = url; o.textContent = label; $('#addSel').appendChild(o);
+});
+
+function save(){
+ const data = wins.map(w => ({
+ url:w.dataset.url, title:w.dataset.title,
+ left:parseInt(w.style.left), top:parseInt(w.style.top),
+ w:parseInt(w.style.width), h:parseInt(w.style.height),
+ min:w.classList.contains('min'), z:+w.style.zIndex||10
+ }));
+ try{ localStorage.setItem(STORE, JSON.stringify(data)); }catch(e){}
+ $('#count').textContent = wins.length + (wins.length===1?' panel':' panels');
+ $('#empty').style.display = wins.length ? 'none' : '';
+}
+
+function focusWin(w){ wins.forEach(x=>x.classList.remove('focus')); w.classList.add('focus'); w.style.zIndex = ++z; }
+
+function makeWin(opt){
+ const w = document.createElement('div'); w.className='win';
+ w.dataset.url = opt.url; w.dataset.title = opt.title || labelFor(opt.url);
+ w.style.left=(opt.left??(40+wins.length*28))+'px';
+ w.style.top =(opt.top ??(40+wins.length*28))+'px';
+ w.style.width =(opt.w??560)+'px';
+ w.style.height=(opt.h??460)+'px';
+ w.style.zIndex = opt.z || (++z);
+ w.innerHTML =
+ '<div class="win-bar">'+
+ '<span class="win-grip">⠿</span>'+
+ '<span class="win-title"></span>'+
+ '<a class="win-btn" title="Open full page" target="_blank" rel="noopener" href="'+esc(opt.url)+'">↗</a>'+
+ '<button class="win-btn b-reload" title="Reload">⟳</button>'+
+ '<button class="win-btn b-min" title="Collapse">—</button>'+
+ '<button class="win-btn b-close" title="Close">✕</button>'+
+ '</div>'+
+ '<div class="win-body"><iframe src="'+esc(opt.url)+'" loading="lazy" referrerpolicy="same-origin"></iframe></div>'+
+ '<div class="win-resize" title="Drag to resize"></div>';
+ w.querySelector('.win-title').textContent = w.dataset.title;
+ if (opt.min) w.classList.add('min');
+ stage.appendChild(w); wins.push(w);
+
+ const bar = w.querySelector('.win-bar');
+ // drag
+ let dx,dy,ox,oy,drag=false;
+ bar.addEventListener('pointerdown', e=>{
+ if(e.target.closest('.win-btn')) return;
+ drag=true; focusWin(w); dx=e.clientX; dy=e.clientY; ox=parseInt(w.style.left); oy=parseInt(w.style.top);
+ bar.setPointerCapture(e.pointerId); e.preventDefault();
+ });
+ bar.addEventListener('pointermove', e=>{
+ if(!drag) return;
+ let nl=ox+(e.clientX-dx), nt=oy+(e.clientY-dy);
+ nl=Math.max(2-w.offsetWidth+80, Math.min(nl, innerWidth-60));
+ nt=Math.max(0, Math.min(nt, innerHeight-90));
+ w.style.left=nl+'px'; w.style.top=nt+'px';
+ });
+ bar.addEventListener('pointerup', ()=>{ if(drag){drag=false; save();} });
+ // resize
+ const rz=w.querySelector('.win-resize'); let rw,rh,rx,ry,res=false;
+ rz.addEventListener('pointerdown', e=>{
+ res=true; focusWin(w); rx=e.clientX; ry=e.clientY; rw=w.offsetWidth; rh=w.offsetHeight;
+ rz.setPointerCapture(e.pointerId); e.preventDefault();
+ });
+ rz.addEventListener('pointermove', e=>{
+ if(!res) return;
+ w.style.width =Math.max(280, rw+(e.clientX-rx))+'px';
+ w.style.height=Math.max(180, rh+(e.clientY-ry))+'px';
+ });
+ rz.addEventListener('pointerup', ()=>{ if(res){res=false; save();} });
+ // focus on any click
+ w.addEventListener('pointerdown', ()=>focusWin(w), true);
+ // buttons
+ w.querySelector('.b-reload').addEventListener('click', ()=>{ const f=w.querySelector('iframe'); f.src=f.src; });
+ w.querySelector('.b-min').addEventListener('click', ()=>{ w.classList.toggle('min'); save(); });
+ w.querySelector('.b-close').addEventListener('click', ()=>{ w.remove(); wins=wins.filter(x=>x!==w); save(); });
+
+ focusWin(w); save();
+ return w;
+}
+
+function labelFor(url){ const p = PAGES.find(x=>x[1]===url); return p?p[0]:url; }
+function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+// tile / cascade
+function tile(){
+ const n=wins.length; if(!n) return;
+ const cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols);
+ const W=innerWidth, H=innerHeight-52, gw=W/cols, gh=H/rows;
+ wins.forEach((w,i)=>{ const c=i%cols, r=Math.floor(i/cols);
+ w.classList.remove('min');
+ w.style.left=Math.round(c*gw+4)+'px'; w.style.top=Math.round(r*gh+4)+'px';
+ w.style.width=Math.round(gw-8)+'px'; w.style.height=Math.round(gh-8)+'px'; });
+ save();
+}
+function cascade(){ wins.forEach((w,i)=>{ w.classList.remove('min');
+ w.style.left=(40+i*30)+'px'; w.style.top=(40+i*30)+'px'; w.style.width='560px'; w.style.height='460px';
+ w.style.zIndex=++z; }); save(); }
+
+$('#addSel').addEventListener('change', e=>{ const url=e.target.value; if(url){ makeWin({url}); e.target.value=''; } });
+$('#tileBtn').addEventListener('click', tile);
+$('#cascadeBtn').addEventListener('click', cascade);
+$('#clearBtn').addEventListener('click', ()=>{ if(wins.length && confirm('Close all panels?')){ wins.forEach(w=>w.remove()); wins=[]; save(); } });
+
+// restore
+(function init(){
+ let data=[]; try{ data=JSON.parse(localStorage.getItem(STORE)||'[]'); }catch(e){}
+ data.sort((a,b)=>(a.z||0)-(b.z||0)).forEach(makeWin);
+ save();
+})();
+</script>
+</body>
+</html>
diff --git a/src/admin.js b/src/admin.js
index e151bb0..3eb22fa 100644
--- a/src/admin.js
+++ b/src/admin.js
@@ -163,6 +163,7 @@ function adminHeader(active) {
['/admin/leads', 'Leads'],
['/admin/golive', 'Go-Live'],
['/admin/theme-gallery','Theme Gallery'],
+ ['/admin/workspace', 'Workspace'],
['/', 'View site →']
];
return `<header class="admin-header">
@@ -179,6 +180,10 @@ function mount(app) {
app.get('/admin/theme-gallery', gate, (req, res) =>
res.sendFile(require('path').join(__dirname, '..', 'public', 'admin', 'theme-gallery.html')));
+ // Workspace — windowed dashboard: open any admin grid as a movable/resizable panel.
+ app.get('/admin/workspace', gate, (req, res) =>
+ res.sendFile(require('path').join(__dirname, '..', 'public', 'admin', 'workspace.html')));
+
ensureInspirationSchema();
// ── Landing: vendor cards
← ebf027d Pattern generation always anchors on a REAL natural wallcove
·
back to Wallco Ai
·
harden generator_tick id-reconciliation: parse authoritative 0c8a15c →