← back to 4square Agentabrams
v2 layout — site nav rail + adjustable-width product browser + collapsible moodboard
2c34d92c4218637bb817cb0500c5391eeb35e849 · 2026-05-14 09:18:26 -0700 · Steve
LEFT rail (resizable): 104 of Steve's live, keep-flagged domains from CNCP,
in headliner order then alphabetical. TSD is wired; rest show 'Catalog
pending'. Filter input narrows the list as you type. Selection persists
in localStorage.
CENTER splitter (6px) drags to resize the rail (180-600px). Width persists.
RIGHT main panel: search input pinned at top ('Type anything to bring a
product up…') with debounced live filter through the wired site's catalog
API. Dense grid (auto-fill, 180px+ cards). Source pill + result count
visible. Empty states for no-selection / no-match / pending-catalog.
BOTTOM moodboard dock: the 4-square design board with collapse toggle.
Drag from the main grid into a slot, or double-click a card to place
into first empty slot. Slot↔slot swap still works. All persists.
Verified: 104 rail items, splitter drag, search ('vermeer' → 2/2),
rail filter ('wallpaper' → 20), drop → slot, reload persistence, mobile
stack. Zero console errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A public/data/sites.jsonM public/index.html
Diff
commit 2c34d92c4218637bb817cb0500c5391eeb35e849
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 14 09:18:26 2026 -0700
v2 layout — site nav rail + adjustable-width product browser + collapsible moodboard
LEFT rail (resizable): 104 of Steve's live, keep-flagged domains from CNCP,
in headliner order then alphabetical. TSD is wired; rest show 'Catalog
pending'. Filter input narrows the list as you type. Selection persists
in localStorage.
CENTER splitter (6px) drags to resize the rail (180-600px). Width persists.
RIGHT main panel: search input pinned at top ('Type anything to bring a
product up…') with debounced live filter through the wired site's catalog
API. Dense grid (auto-fill, 180px+ cards). Source pill + result count
visible. Empty states for no-selection / no-match / pending-catalog.
BOTTOM moodboard dock: the 4-square design board with collapse toggle.
Drag from the main grid into a slot, or double-click a card to place
into first empty slot. Slot↔slot swap still works. All persists.
Verified: 104 rail items, splitter drag, search ('vermeer' → 2/2),
rail filter ('wallpaper' → 20), drop → slot, reload persistence, mobile
stack. Zero console errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/data/sites.json | 631 +++++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 574 +++++++++++++++++++++++++++++---------------
2 files changed, 1013 insertions(+), 192 deletions(-)
diff --git a/public/data/sites.json b/public/data/sites.json
new file mode 100644
index 0000000..1bc5ba8
--- /dev/null
+++ b/public/data/sites.json
@@ -0,0 +1,631 @@
+{
+ "sites": [
+ {
+ "domain": "designerwallcoverings.com",
+ "name": "designerwallcoverings.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "thesetdecorator.com",
+ "name": "thesetdecorator.com",
+ "kind": "film",
+ "source": {
+ "kind": "tsd-catalog",
+ "endpoint": "/api/catalog/search"
+ }
+ },
+ {
+ "domain": "agentabrams.com",
+ "name": "agentabrams.com",
+ "kind": "agentabrams",
+ "source": null
+ },
+ {
+ "domain": "4square.agentabrams.com",
+ "name": "4square.agentabrams.com",
+ "kind": "agentabrams",
+ "source": null
+ },
+ {
+ "domain": "butlr.agentabrams.com",
+ "name": "butlr.agentabrams.com",
+ "kind": "agentabrams",
+ "source": null
+ },
+ {
+ "domain": "sdccanalysis.agentabrams.com",
+ "name": "sdccanalysis.agentabrams.com",
+ "kind": "agentabrams",
+ "source": null
+ },
+ {
+ "domain": "lawyers.agentabrams.com",
+ "name": "lawyers.agentabrams.com",
+ "kind": "agentabrams",
+ "source": null
+ },
+ {
+ "domain": "venturablvd.agentabrams.com",
+ "name": "venturablvd.agentabrams.com",
+ "kind": "agentabrams",
+ "source": null
+ },
+ {
+ "domain": "philipperomano.com",
+ "name": "philipperomano.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "novasuede.com",
+ "name": "novasuede.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "flockedwallpaper.com",
+ "name": "flockedwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "grassclothwallpaper.com",
+ "name": "grassclothwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "corkwallcovering.com",
+ "name": "corkwallcovering.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "silkwallpaper.com",
+ "name": "silkwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "linenwallpaper.com",
+ "name": "linenwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "chinoiseriewallpaper.com",
+ "name": "chinoiseriewallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "glassbeadedwallpaper.com",
+ "name": "glassbeadedwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "metallicwallpaper.com",
+ "name": "metallicwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "90210rentals.com",
+ "name": "90210rentals.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "agedwallpaper.com",
+ "name": "agedwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "apartmentwallpaper.com",
+ "name": "apartmentwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "architecturalwallcoverings.com",
+ "name": "architecturalwallcoverings.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "askgoodquestions.ai",
+ "name": "askgoodquestions.ai",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "asknonna.ai",
+ "name": "asknonna.ai",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "beverlyandhills.com",
+ "name": "beverlyandhills.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "beverlyhillspaints.com",
+ "name": "beverlyhillspaints.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "blockprintedwallpaper.com",
+ "name": "blockprintedwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "boomercalc.com",
+ "name": "boomercalc.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "borninbh.com",
+ "name": "borninbh.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "borrowerbills.org",
+ "name": "borrowerbills.org",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "bubbe.ai",
+ "name": "bubbe.ai",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "bubbeleh.ai",
+ "name": "bubbeleh.ai",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "bubbesblock.com",
+ "name": "bubbesblock.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "bubbie.ai",
+ "name": "bubbie.ai",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "carmelpaints.com",
+ "name": "carmelpaints.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "carmelriverwater.com",
+ "name": "carmelriverwater.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "carmelvalleywater.com",
+ "name": "carmelvalleywater.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "carmelwallpapers.com",
+ "name": "carmelwallpapers.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "cfafabrics.com",
+ "name": "cfafabrics.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "claimmyaddress.com",
+ "name": "claimmyaddress.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "commercialsalesreps.com",
+ "name": "commercialsalesreps.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "customdigitalwalls.com",
+ "name": "customdigitalwalls.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "cypresawards.com",
+ "name": "cypresawards.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "designerlaboratory.com",
+ "name": "designerlaboratory.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "designersalesreps.com",
+ "name": "designersalesreps.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "designerschat.com",
+ "name": "designerschat.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "designerwallcover.com",
+ "name": "designerwallcover.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "designerwallcovering.com",
+ "name": "designerwallcovering.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "designerwallcoverings.ai",
+ "name": "designerwallcoverings.ai",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "designerwallpaints.com",
+ "name": "designerwallpaints.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "designerwalltiles.com",
+ "name": "designerwalltiles.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "designtradereps.com",
+ "name": "designtradereps.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "dsgn.tv",
+ "name": "dsgn.tv",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "dwlosangeles.com",
+ "name": "dwlosangeles.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "edgrants.org",
+ "name": "edgrants.org",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "fabricwallpaper.com",
+ "name": "fabricwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "fentucci.com",
+ "name": "fentucci.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "forgivestudentloandebt.com",
+ "name": "forgivestudentloandebt.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "forza.agentabrams.com",
+ "name": "forza.agentabrams.com",
+ "kind": "agentabrams",
+ "source": null
+ },
+ {
+ "domain": "freethedegree.org",
+ "name": "freethedegree.org",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "gmail.agentabrams.com",
+ "name": "gmail.agentabrams.com",
+ "kind": "agentabrams",
+ "source": null
+ },
+ {
+ "domain": "goodquestion.ai",
+ "name": "goodquestion.ai",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "grassclothwallcovering.com",
+ "name": "grassclothwallcovering.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "grassclothwallcoverings.com",
+ "name": "grassclothwallcoverings.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "greenwallcoverings.com",
+ "name": "greenwallcoverings.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "hospitalitysalesagency.com",
+ "name": "hospitalitysalesagency.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "hospitalitysalesagents.com",
+ "name": "hospitalitysalesagents.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "hospitalitysalesreps.com",
+ "name": "hospitalitysalesreps.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "interiordesignreps.com",
+ "name": "interiordesignreps.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "joshdultz.com",
+ "name": "joshdultz.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "latke.ai",
+ "name": "latke.ai",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "museumwallpaper.com",
+ "name": "museumwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "mylarwallpaper.com",
+ "name": "mylarwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "nataliaabrams.com",
+ "name": "nataliaabrams.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "nationalpaperhangers.com",
+ "name": "nationalpaperhangers.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "naturaltextilewallpaper.com",
+ "name": "naturaltextilewallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "pastelwallpaper.com",
+ "name": "pastelwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "patterndesignlab.com",
+ "name": "patterndesignlab.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "philliperomano.com",
+ "name": "philliperomano.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "saloonwallpaper.com",
+ "name": "saloonwallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "steveabramsdesigns.com",
+ "name": "steveabramsdesigns.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "studentdebtattorney.com",
+ "name": "studentdebtattorney.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "studentdebtcrisis.com",
+ "name": "studentdebtcrisis.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "studentdebtcrisis.org",
+ "name": "studentdebtcrisis.org",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "studentdebtlawyers.com",
+ "name": "studentdebtlawyers.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "studentdebtlawyers.org",
+ "name": "studentdebtlawyers.org",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "studentloanvoter.com",
+ "name": "studentloanvoter.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "studentstrikes.org",
+ "name": "studentstrikes.org",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "summergamesrentals.com",
+ "name": "summergamesrentals.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "textilewallpaper.com",
+ "name": "textilewallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "theatrewallpaper.com",
+ "name": "theatrewallpaper.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "thecannabisnews.com",
+ "name": "thecannabisnews.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "thedesignerlaboratory.com",
+ "name": "thedesignerlaboratory.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "thedesignerlibrary.com",
+ "name": "thedesignerlibrary.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "thesalesreps.com",
+ "name": "thesalesreps.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "traditionalwhimsy.com",
+ "name": "traditionalwhimsy.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "vintagebh.com",
+ "name": "vintagebh.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "wallco.ai",
+ "name": "wallco.ai",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "wallpapersback.com",
+ "name": "wallpapersback.com",
+ "kind": "wallcovering",
+ "source": null
+ },
+ {
+ "domain": "wholivedthere.com",
+ "name": "wholivedthere.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "wintergamesrentals.com",
+ "name": "wintergamesrentals.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "withoutstudentdebt.com",
+ "name": "withoutstudentdebt.com",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "withoutstudentdebt.org",
+ "name": "withoutstudentdebt.org",
+ "kind": "other",
+ "source": null
+ },
+ {
+ "domain": "zayde.ai",
+ "name": "zayde.ai",
+ "kind": "other",
+ "source": null
+ }
+ ]
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 480b5c0..2defb25 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,181 +8,339 @@
<style>
:root {
--bg: #0e0d0b;
+ --panel: #14130f;
--card: #19181640;
--rule: #2a2825;
+ --rule-strong: #3a3631;
--fg: #f3ecdd;
--muted: #9d9586;
+ --muted-dim: #6e665a;
--accent: #d9b14e;
+ --accent-dim: #8a6f30;
+ --selected: #2a2415;
--font-display: 'Playfair Display', serif;
--font-ui: 'Inter', system-ui, sans-serif;
- --rad: 14px;
- --slot-gap: 18px;
+ --rad: 12px;
+ --rail-w: 240px;
+ --moodboard-h: 200px;
}
* { box-sizing: border-box; }
- html, body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--font-ui); }
- body { min-height: 100vh; }
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: var(--font-ui); }
+ body { display: flex; flex-direction: column; min-height: 100vh; }
+
+ /* ─ Header ─ */
header.top {
display: flex; align-items: center; justify-content: space-between;
- padding: 14px 22px; border-bottom: 1px solid var(--rule);
+ padding: 12px 20px; border-bottom: 1px solid var(--rule);
background: linear-gradient(180deg, #1c1a17 0%, #14130f 100%);
+ flex: 0 0 auto;
}
- header.top h1 {
- font-family: var(--font-display); margin: 0;
- font-size: 1.45rem; letter-spacing: 0.01em;
- color: var(--accent);
- }
- header.top .sub { color: var(--muted); font-size: 0.82rem; letter-spacing: 0.06em; text-transform: uppercase; }
+ header.top h1 { font-family: var(--font-display); margin: 0; font-size: 1.4rem; color: var(--accent); }
+ header.top .sub { color: var(--muted); font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase; }
header .save-status { color: var(--muted); font-size: 0.75rem; }
header .save-status.flash { color: var(--accent); }
+ .action-btn {
+ background: transparent; color: var(--fg); border: 1px solid var(--rule);
+ padding: 6px 12px; border-radius: 8px; cursor: pointer; font: 600 0.75rem var(--font-ui);
+ letter-spacing: 0.05em;
+ }
+ .action-btn:hover { border-color: var(--accent); color: var(--accent); }
+
+ /* ─ Main 3-pane (left rail + splitter + main + bottom moodboard) ─ */
+ .workspace { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
+ .panes { display: flex; flex: 1 1 auto; min-height: 0; }
+
+ /* Left rail — site nav */
+ .rail {
+ width: var(--rail-w); min-width: 180px; max-width: 50vw;
+ background: var(--panel); border-right: 1px solid var(--rule);
+ display: flex; flex-direction: column; flex: 0 0 auto; min-height: 0;
+ }
+ .rail h2 {
+ font-family: var(--font-display); margin: 0; font-size: 0.95rem;
+ letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted);
+ padding: 14px 16px 8px;
+ }
+ .rail .site-filter {
+ margin: 0 12px 8px; padding: 7px 10px; background: #100f0d; color: var(--fg);
+ border: 1px solid var(--rule); border-radius: 8px; font: 0.84rem var(--font-ui);
+ }
+ .rail .site-filter:focus { outline: 0; border-color: var(--accent-dim); }
+ .rail .meta { color: var(--muted-dim); font-size: 0.7rem; padding: 0 16px 6px; }
+ .site-list { overflow-y: auto; flex: 1 1 auto; padding: 0 6px 12px; }
+ .site-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 7px 10px; border-radius: 8px; cursor: pointer;
+ font: 0.84rem var(--font-ui); color: var(--fg);
+ border-left: 2px solid transparent;
+ margin: 1px 0;
+ }
+ .site-item:hover { background: #1a1815; }
+ .site-item.active { background: var(--selected); border-left-color: var(--accent); }
+ .site-item .dot {
+ width: 7px; height: 7px; border-radius: 999px;
+ background: var(--rule-strong); flex: 0 0 7px;
+ }
+ .site-item.wired .dot { background: var(--accent); }
+ .site-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; }
+ .site-item .kind {
+ font-size: 0.62rem; color: var(--muted-dim);
+ letter-spacing: 0.06em; text-transform: uppercase;
+ }
+
+ /* Splitter — drag to resize left rail */
+ .splitter {
+ width: 6px; flex: 0 0 6px; cursor: col-resize;
+ background: var(--bg); border-right: 1px solid var(--rule);
+ transition: background 0.12s ease;
+ }
+ .splitter:hover, .splitter.dragging { background: var(--accent-dim); }
- .app { display: grid; grid-template-columns: 320px 1fr; gap: 18px; padding: 18px; min-height: calc(100vh - 60px); }
+ /* Main — search + grid */
+ .main {
+ flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column;
+ background: var(--bg);
+ }
+ .search-bar {
+ display: flex; align-items: center; gap: 12px;
+ padding: 14px 22px; border-bottom: 1px solid var(--rule);
+ background: var(--panel);
+ }
+ .search-bar .search-icon { color: var(--muted); font-size: 1.1rem; }
+ .search-bar input[type=search] {
+ flex: 1 1 auto; background: #100f0d; color: var(--fg);
+ border: 1px solid var(--rule); border-radius: 10px;
+ padding: 10px 14px; font: 0.95rem var(--font-ui);
+ }
+ .search-bar input[type=search]:focus { outline: 0; border-color: var(--accent-dim); }
+ .search-bar .count { color: var(--muted); font-size: 0.78rem; min-width: 110px; text-align: right; }
+ .search-bar .source-label {
+ color: var(--accent); font: 600 0.78rem var(--font-ui); letter-spacing: 0.05em;
+ text-transform: uppercase; padding: 4px 10px; border: 1px solid var(--accent-dim); border-radius: 999px;
+ max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ }
- /* Rail — draggable tile catalog */
- aside.rail {
- background: var(--card); border: 1px solid var(--rule); border-radius: var(--rad);
- padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0;
- }
- aside.rail h2 { font-family: var(--font-display); margin: 0 0 4px; font-size: 1.05rem; }
- aside.rail .controls { display: flex; flex-direction: column; gap: 6px; }
- aside.rail input, aside.rail select {
- background: #100f0d; color: var(--fg); border: 1px solid var(--rule); border-radius: 8px;
- padding: 7px 10px; font: 0.85rem var(--font-ui);
- }
- aside.rail .meta { color: var(--muted); font-size: 0.72rem; }
- .tile-list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; overflow-y: auto; padding-right: 4px; flex: 1 1 auto; }
- .tile {
- aspect-ratio: 1/1.15; background: #100f0d; border: 1px solid var(--rule); border-radius: 10px;
- overflow: hidden; cursor: grab; position: relative; user-select: none;
+ .grid-scroll { flex: 1 1 auto; overflow-y: auto; padding: 18px 22px 22px; }
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 14px;
+ }
+ .card {
+ aspect-ratio: 1/1.15; background: #100f0d; border: 1px solid var(--rule);
+ border-radius: var(--rad); overflow: hidden; position: relative; cursor: grab;
transition: border-color 0.15s ease, transform 0.15s ease;
}
- .tile:active { cursor: grabbing; }
- .tile.dragging { opacity: 0.4; }
- .tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
- .tile .tile-label {
- position: absolute; left: 0; right: 0; bottom: 0; padding: 5px 7px;
- font: 600 0.65rem var(--font-ui); color: #fff; line-height: 1.1;
+ .card:active { cursor: grabbing; }
+ .card:hover { border-color: var(--accent-dim); transform: translateY(-1px); }
+ .card.dragging { opacity: 0.4; }
+ .card img { width: 100%; height: 100%; object-fit: cover; display: block; }
+ .card .card-label {
+ position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 9px;
+ font: 600 0.7rem var(--font-ui); color: #fff;
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.78) 100%);
- letter-spacing: 0.02em; text-transform: uppercase;
+ letter-spacing: 0.02em;
+ }
+ .card .card-label .by { color: rgba(255,255,255,0.65); font-weight: 400; font-size: 0.62rem; display: block; margin-top: 1px; }
+
+ .empty-state {
+ padding: 60px 30px; text-align: center; color: var(--muted);
+ max-width: 480px; margin: 0 auto;
}
+ .empty-state h3 { font-family: var(--font-display); color: var(--fg); margin: 0 0 8px; }
+ .empty-state .lead { font-size: 0.92rem; line-height: 1.5; }
+ .empty-state .domain { color: var(--accent); font-weight: 600; }
+
+ /* Moodboard dock at bottom */
+ .moodboard {
+ flex: 0 0 auto; min-height: var(--moodboard-h);
+ border-top: 1px solid var(--rule); background: var(--panel);
+ display: flex; flex-direction: column;
+ }
+ .moodboard.collapsed { min-height: 38px; }
+ .moodboard-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 8px 18px; border-bottom: 1px solid var(--rule);
+ cursor: pointer; user-select: none;
+ }
+ .moodboard-header h2 {
+ font-family: var(--font-display); margin: 0; font-size: 0.95rem;
+ letter-spacing: 0.04em; color: var(--accent);
+ }
+ .moodboard-header .toggle {
+ color: var(--muted); font-size: 0.8rem; transition: transform 0.18s ease;
+ }
+ .moodboard.collapsed .toggle { transform: rotate(180deg); }
+ .moodboard-body {
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
+ padding: 12px 18px 14px; flex: 1 1 auto;
+ }
+ .moodboard.collapsed .moodboard-body { display: none; }
- /* 2×2 board */
- main.board { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: var(--slot-gap); min-height: 0; }
.slot {
- position: relative; background: #131210; border: 2px dashed var(--rule); border-radius: var(--rad);
- overflow: hidden; min-height: 240px; transition: border-color 0.15s ease, background 0.15s ease;
+ position: relative; background: #131210; border: 2px dashed var(--rule); border-radius: 10px;
+ overflow: hidden; min-height: 150px;
+ transition: border-color 0.15s ease;
}
.slot.empty::after {
content: attr(data-empty-label); position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
- color: var(--muted); font: 600 0.85rem var(--font-ui); letter-spacing: 0.16em; text-transform: uppercase;
+ color: var(--muted-dim); font: 700 1.4rem var(--font-display);
pointer-events: none;
}
- .slot.over { border-color: var(--accent); background: #1a1714; }
+ .slot.over { border-color: var(--accent); }
.slot.filled { border-style: solid; border-color: var(--rule); cursor: grab; }
.slot.filled:active { cursor: grabbing; }
.slot.dragging-source { opacity: 0.5; }
.slot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.slot .badge {
- position: absolute; top: 8px; left: 8px;
- background: rgba(20, 18, 15, 0.85); color: var(--accent);
- font: 700 0.65rem var(--font-ui); letter-spacing: 0.1em;
- padding: 3px 8px; border-radius: 999px; backdrop-filter: blur(6px);
+ position: absolute; top: 6px; left: 6px; background: rgba(20,18,15,0.85);
+ color: var(--accent); font: 700 0.6rem var(--font-ui); letter-spacing: 0.1em;
+ padding: 2px 7px; border-radius: 999px;
}
.slot .info {
- position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 14px;
- background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.85) 100%);
- font-family: var(--font-display); color: #fff; font-size: 0.95rem;
+ position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 10px;
+ font: 0.72rem var(--font-ui); color: #fff;
+ background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.82) 100%);
}
- .slot .info .by { font: 0.72rem var(--font-ui); color: var(--muted); margin-top: 2px; }
.slot .clear {
- position: absolute; top: 8px; right: 8px;
- background: rgba(20, 18, 15, 0.85); color: var(--fg); border: 1px solid var(--rule);
- width: 26px; height: 26px; border-radius: 999px; cursor: pointer;
- display: none; align-items: center; justify-content: center; font-size: 1rem; line-height: 1;
+ position: absolute; top: 6px; right: 6px;
+ background: rgba(20,18,15,0.85); color: var(--fg); border: 1px solid var(--rule);
+ width: 22px; height: 22px; border-radius: 999px; cursor: pointer;
+ display: none; align-items: center; justify-content: center; font-size: 0.9rem; line-height: 1;
}
.slot.filled .clear { display: flex; }
- .slot .clear:hover { background: #d44; color: #fff; }
+ .slot .clear:hover { background: #c54; color: #fff; }
@media (max-width: 880px) {
- .app { grid-template-columns: 1fr; padding: 12px; gap: 14px; }
- main.board { grid-template-columns: 1fr 1fr; }
- aside.rail { max-height: 50vh; }
- }
-
- button.action {
- background: transparent; color: var(--fg); border: 1px solid var(--rule);
- padding: 7px 14px; border-radius: 8px; cursor: pointer; font: 600 0.78rem var(--font-ui);
- letter-spacing: 0.05em;
+ .panes { flex-direction: column; }
+ .rail { width: 100% !important; max-width: none; max-height: 36vh; }
+ .splitter { display: none; }
+ .search-bar { padding: 10px 14px; }
+ .search-bar .count { display: none; }
+ .grid-scroll { padding: 12px 14px; }
+ .moodboard-body { grid-template-columns: repeat(2, 1fr); }
}
- button.action:hover { border-color: var(--accent); color: var(--accent); }
</style>
</head>
<body>
<header class="top">
<div>
<h1>4Square</h1>
- <div class="sub">Agent Abrams · drag a piece into a square</div>
+ <div class="sub">Agent Abrams · pick a site → drag products into the moodboard</div>
</div>
<div style="display:flex;align-items:center;gap:14px">
- <span class="save-status" id="save-status">layout autosaves to this browser</span>
- <button class="action" id="reset-all" title="Clear all four squares">Reset all</button>
+ <span class="save-status" id="save-status">moodboard autosaves to this browser</span>
+ <button class="action-btn" id="reset-all" title="Clear all four moodboard squares">Reset moodboard</button>
</div>
</header>
- <div class="app">
- <aside class="rail">
- <h2>Catalog</h2>
- <div class="controls">
- <input id="q" type="search" placeholder="Search title, artist, era…" autocomplete="off">
- <select id="artist"><option value="">All artists</option></select>
- </div>
- <div class="meta" id="rail-meta">loading…</div>
- <div class="tile-list" id="tiles"></div>
- </aside>
+ <div class="workspace">
+ <div class="panes">
+ <aside class="rail" id="rail">
+ <h2>Sites</h2>
+ <input class="site-filter" id="site-filter" type="search" placeholder="Filter domains…" autocomplete="off">
+ <div class="meta" id="rail-meta">loading…</div>
+ <div class="site-list" id="site-list"></div>
+ </aside>
- <main class="board" id="board">
- <div class="slot empty" data-slot="1" data-empty-label="1"></div>
- <div class="slot empty" data-slot="2" data-empty-label="2"></div>
- <div class="slot empty" data-slot="3" data-empty-label="3"></div>
- <div class="slot empty" data-slot="4" data-empty-label="4"></div>
- </main>
+ <div class="splitter" id="splitter" aria-label="Drag to resize"></div>
+
+ <main class="main">
+ <div class="search-bar">
+ <span class="search-icon" aria-hidden="true">⌕</span>
+ <input id="q" type="search" placeholder="Type anything to bring a product up…" autocomplete="off">
+ <span class="source-label" id="source-label">no site selected</span>
+ <span class="count" id="result-count"></span>
+ </div>
+ <div class="grid-scroll" id="grid-scroll">
+ <div class="grid" id="grid"></div>
+ <div class="empty-state" id="empty-state" style="display:none">
+ <h3>Pick a site from the left rail.</h3>
+ <p class="lead">Wired sites have a yellow dot. Other sites are listed by name and will populate when their catalog API is connected.</p>
+ </div>
+ </div>
+ </main>
+ </div>
+
+ <section class="moodboard" id="moodboard">
+ <div class="moodboard-header" id="moodboard-header">
+ <h2>Moodboard — 4 Squares</h2>
+ <span class="toggle">▾</span>
+ </div>
+ <div class="moodboard-body">
+ <div class="slot empty" data-slot="1" data-empty-label="1"></div>
+ <div class="slot empty" data-slot="2" data-empty-label="2"></div>
+ <div class="slot empty" data-slot="3" data-empty-label="3"></div>
+ <div class="slot empty" data-slot="4" data-empty-label="4"></div>
+ </div>
+ </section>
</div>
<script>
const STORAGE_KEY = '4square.agentabrams.layout.v1';
+ const SITES_KEY = '4square.agentabrams.selectedSite.v1';
+ const RAIL_W_KEY = '4square.agentabrams.railWidth.v1';
+ const MOOD_KEY = '4square.agentabrams.moodCollapsed.v1';
- // ── persisted state ──────────────────────────────────────────────
let layout = loadLayout();
+ let sites = [];
+ let selectedSite = null;
+ let railItems = [];
+ let queryToken = 0;
function loadLayout() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return { 1: null, 2: null, 3: null, 4: null };
- const parsed = JSON.parse(raw);
- return { 1: parsed[1] || null, 2: parsed[2] || null, 3: parsed[3] || null, 4: parsed[4] || null };
- } catch { return { 1: null, 2: null, 3: null, 4: null }; }
+ if (!raw) return { 1:null, 2:null, 3:null, 4:null };
+ const p = JSON.parse(raw);
+ return { 1: p[1]||null, 2: p[2]||null, 3: p[3]||null, 4: p[4]||null };
+ } catch { return { 1:null, 2:null, 3:null, 4:null }; }
}
function saveLayout() {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
- flashSaved();
- } catch (e) {
- document.getElementById('save-status').textContent = 'autosave failed: ' + e.message;
- }
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); flashSaved(); }
+ catch (e) { document.getElementById('save-status').textContent = 'autosave failed'; }
}
function flashSaved() {
const el = document.getElementById('save-status');
- el.textContent = 'saved ✓';
- el.classList.add('flash');
+ el.textContent = 'saved ✓'; el.classList.add('flash');
clearTimeout(flashSaved._t);
flashSaved._t = setTimeout(() => {
el.classList.remove('flash');
- el.textContent = 'layout autosaves to this browser';
+ el.textContent = 'moodboard autosaves to this browser';
}, 1200);
}
+ function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ }
+
+ // ── Resizable rail ─────────────────────────────────────────────
+ (function rail() {
+ const saved = parseInt(localStorage.getItem(RAIL_W_KEY) || '240', 10);
+ if (saved >= 180 && saved <= 600) document.documentElement.style.setProperty('--rail-w', saved + 'px');
+ const split = document.getElementById('splitter');
+ let dragging = false, startX = 0, startW = 0;
+ split.addEventListener('mousedown', (e) => {
+ dragging = true; split.classList.add('dragging');
+ startX = e.clientX;
+ startW = document.getElementById('rail').getBoundingClientRect().width;
+ e.preventDefault();
+ });
+ window.addEventListener('mousemove', (e) => {
+ if (!dragging) return;
+ const newW = Math.max(180, Math.min(600, startW + (e.clientX - startX)));
+ document.documentElement.style.setProperty('--rail-w', newW + 'px');
+ });
+ window.addEventListener('mouseup', () => {
+ if (!dragging) return;
+ dragging = false; split.classList.remove('dragging');
+ const w = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--rail-w'), 10);
+ localStorage.setItem(RAIL_W_KEY, String(w));
+ });
+ })();
- // ── render slots ─────────────────────────────────────────────────
- const board = document.getElementById('board');
+ // ── Moodboard ──────────────────────────────────────────────────
+ const board = document.querySelector('.moodboard-body');
function renderSlots() {
board.querySelectorAll('.slot').forEach(slot => {
const n = slot.dataset.slot;
@@ -193,18 +351,11 @@
slot.setAttribute('draggable', item ? 'true' : 'false');
if (item) {
const img = document.createElement('img');
- img.src = item.imageProxy;
- img.alt = item.title || '';
- img.loading = 'lazy';
- const badge = document.createElement('span');
- badge.className = 'badge';
- badge.textContent = '#' + n;
- const info = document.createElement('div');
- info.className = 'info';
- info.innerHTML = '<div>' + escapeHtml(item.title || 'Untitled') + '</div>' +
- '<div class="by">' + escapeHtml(item.artist || '') + (item.year ? ' · ' + item.year : '') + '</div>';
- const clear = document.createElement('button');
- clear.className = 'clear'; clear.title = 'Clear this square'; clear.textContent = '×';
+ img.src = item.imageProxy; img.alt = item.title || ''; img.loading = 'lazy';
+ const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = '#'+n;
+ const info = document.createElement('div'); info.className = 'info';
+ info.innerHTML = escapeHtml(item.title || 'Untitled');
+ const clear = document.createElement('button'); clear.className='clear'; clear.title='Clear'; clear.textContent='×';
clear.addEventListener('click', (e) => {
e.stopPropagation();
layout[n] = null; saveLayout(); renderSlots();
@@ -213,136 +364,175 @@
}
});
}
-
- function escapeHtml(s) {
- return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
- }
-
- // ── drag/drop wiring ─────────────────────────────────────────────
- // Tile -> slot OR slot -> slot. Data payload format: { kind: 'tile'|'slot', payload }
function attachSlotDnD() {
board.querySelectorAll('.slot').forEach(slot => {
slot.addEventListener('dragstart', (e) => {
const n = slot.dataset.slot;
if (!layout[n]) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = 'move';
- e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind: 'slot', from: n }));
+ e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind:'slot', from:n }));
slot.classList.add('dragging-source');
});
slot.addEventListener('dragend', () => slot.classList.remove('dragging-source'));
- slot.addEventListener('dragover', (e) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'move';
- slot.classList.add('over');
- });
+ slot.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect='move'; slot.classList.add('over'); });
slot.addEventListener('dragleave', () => slot.classList.remove('over'));
slot.addEventListener('drop', (e) => {
- e.preventDefault();
- slot.classList.remove('over');
+ e.preventDefault(); slot.classList.remove('over');
const raw = e.dataTransfer.getData('application/x-4sq');
if (!raw) return;
- let payload;
- try { payload = JSON.parse(raw); } catch { return; }
+ let payload; try { payload = JSON.parse(raw); } catch { return; }
const target = slot.dataset.slot;
- if (payload.kind === 'tile') {
- layout[target] = payload.item;
- } else if (payload.kind === 'slot') {
- const from = payload.from;
- if (from === target) return;
+ if (payload.kind === 'tile') layout[target] = payload.item;
+ else if (payload.kind === 'slot' && payload.from !== target) {
const tmp = layout[target];
- layout[target] = layout[from];
- layout[from] = tmp; // swap (tmp may be null = move)
+ layout[target] = layout[payload.from];
+ layout[payload.from] = tmp;
}
- saveLayout();
- renderSlots();
+ saveLayout(); renderSlots();
});
});
}
- attachSlotDnD();
- renderSlots();
+ attachSlotDnD(); renderSlots();
+
+ // Moodboard collapse toggle
+ (function moodCollapse() {
+ const mb = document.getElementById('moodboard');
+ const hdr = document.getElementById('moodboard-header');
+ if (localStorage.getItem(MOOD_KEY) === 'true') mb.classList.add('collapsed');
+ hdr.addEventListener('click', () => {
+ mb.classList.toggle('collapsed');
+ localStorage.setItem(MOOD_KEY, mb.classList.contains('collapsed') ? 'true' : 'false');
+ });
+ })();
+ document.getElementById('reset-all').addEventListener('click', () => {
+ if (!confirm('Clear all 4 moodboard squares?')) return;
+ layout = { 1:null, 2:null, 3:null, 4:null };
+ saveLayout(); renderSlots();
+ });
+
+ // ── Sites rail ─────────────────────────────────────────────────
+ const $list = document.getElementById('site-list');
+ const $railMeta = document.getElementById('rail-meta');
+ const $siteFilter = document.getElementById('site-filter');
+
+ async function loadSites() {
+ try {
+ const r = await fetch('/data/sites.json');
+ const j = await r.json();
+ sites = j.sites || [];
+ const wired = sites.filter(s => s.source).length;
+ $railMeta.textContent = `${sites.length} sites · ${wired} wired`;
+ renderSites();
+ // restore selection
+ const saved = localStorage.getItem(SITES_KEY);
+ const initial = sites.find(s => s.domain === saved) || sites.find(s => s.source) || sites[0];
+ if (initial) selectSite(initial);
+ } catch (e) {
+ $railMeta.textContent = 'failed to load sites';
+ }
+ }
- // ── catalog rail ─────────────────────────────────────────────────
+ function renderSites() {
+ const filter = $siteFilter.value.toLowerCase().trim();
+ const matches = filter ? sites.filter(s => s.domain.toLowerCase().includes(filter)) : sites;
+ $list.innerHTML = '';
+ for (const s of matches) {
+ const div = document.createElement('div');
+ div.className = 'site-item' + (s.source ? ' wired' : '') + (selectedSite && selectedSite.domain === s.domain ? ' active' : '');
+ div.title = s.domain + (s.source ? ' (catalog wired)' : ' (catalog pending)');
+ div.innerHTML = `<span class="dot"></span><span class="name">${escapeHtml(s.domain)}</span><span class="kind">${escapeHtml(s.kind)}</span>`;
+ div.addEventListener('click', () => selectSite(s));
+ $list.appendChild(div);
+ }
+ }
+ $siteFilter.addEventListener('input', renderSites);
+
+ function selectSite(s) {
+ selectedSite = s;
+ localStorage.setItem(SITES_KEY, s.domain);
+ document.getElementById('source-label').textContent = s.domain;
+ renderSites();
+ fetchProducts();
+ }
+
+ // ── Product grid ───────────────────────────────────────────────
+ const $grid = document.getElementById('grid');
+ const $count = document.getElementById('result-count');
+ const $empty = document.getElementById('empty-state');
const $q = document.getElementById('q');
- const $artist = document.getElementById('artist');
- const $tiles = document.getElementById('tiles');
- const $meta = document.getElementById('rail-meta');
- let railItems = [];
- let lastQueryToken = 0;
- async function fetchRail() {
- const token = ++lastQueryToken;
- $meta.textContent = 'loading…';
+ let qDebounce;
+ $q.addEventListener('input', () => {
+ clearTimeout(qDebounce);
+ qDebounce = setTimeout(fetchProducts, 200);
+ });
+
+ async function fetchProducts() {
+ if (!selectedSite) { $grid.innerHTML = ''; $empty.style.display = ''; return; }
+ $empty.style.display = 'none';
+ const src = selectedSite.source;
+ if (!src) {
+ $grid.innerHTML = '';
+ $empty.innerHTML = `<h3>Catalog connection pending</h3>
+ <p class="lead">Site <span class="domain">${escapeHtml(selectedSite.domain)}</span> is in the directory, but its product catalog isn't wired into 4Square yet. Connect its API to enable browsing here.</p>`;
+ $empty.style.display = '';
+ $count.textContent = '';
+ return;
+ }
+ const token = ++queryToken;
+ $count.textContent = 'loading…';
const params = new URLSearchParams();
if ($q.value) params.set('q', $q.value);
- if ($artist.value) params.set('artist', $artist.value);
- params.set('limit', '60');
+ params.set('limit', '120');
try {
- const r = await fetch('/api/catalog/search?' + params.toString());
+ const r = await fetch(src.endpoint + '?' + params.toString());
const j = await r.json();
- if (token !== lastQueryToken) return; // a newer query already fired
+ if (token !== queryToken) return;
railItems = j.items || [];
- $meta.textContent = `${railItems.length} of ${(j.total || railItems.length).toLocaleString()} pieces`;
- renderRail();
- } catch (e) {
- $meta.textContent = 'load failed: ' + e.message;
- }
+ $count.textContent = `${railItems.length} of ${(j.total || railItems.length).toLocaleString()}`;
+ renderGrid();
+ } catch (e) { $count.textContent = 'load failed'; }
}
- function renderRail() {
- $tiles.innerHTML = '';
+
+ function renderGrid() {
+ $grid.innerHTML = '';
+ if (!railItems.length) {
+ $empty.innerHTML = `<h3>No matches</h3><p class="lead">Try a different search term.</p>`;
+ $empty.style.display = '';
+ return;
+ }
+ $empty.style.display = 'none';
for (const it of railItems) {
const div = document.createElement('div');
- div.className = 'tile';
+ div.className = 'card';
div.draggable = true;
const imageProxy = '/img?path=' + encodeURIComponent(it.image_url || it.image || '');
- const lite = {
- id: it.id, title: it.title, artist: it.artist, year: it.year, era: it.era,
- imageProxy,
- };
+ const lite = { id: it.id, title: it.title, artist: it.artist, year: it.year, era: it.era, imageProxy };
div.dataset.item = JSON.stringify(lite);
const img = document.createElement('img');
- img.src = imageProxy;
- img.loading = 'lazy';
- img.alt = it.title || '';
+ img.src = imageProxy; img.loading = 'lazy'; img.alt = it.title || '';
const label = document.createElement('div');
- label.className = 'tile-label';
- label.textContent = it.artist || it.title || '—';
+ label.className = 'card-label';
+ label.innerHTML = `${escapeHtml(it.title || 'Untitled')}<span class="by">${escapeHtml(it.artist || '')}${it.year ? ' · ' + it.year : ''}</span>`;
div.append(img, label);
div.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'copy';
- e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind: 'tile', item: lite }));
+ e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind:'tile', item: lite }));
div.classList.add('dragging');
});
div.addEventListener('dragend', () => div.classList.remove('dragging'));
- // Click-to-place fallback for touch / accessibility: place into first empty slot.
+ // Double-click = place into first empty slot
div.addEventListener('dblclick', () => {
for (const n of ['1','2','3','4']) {
if (!layout[n]) { layout[n] = lite; saveLayout(); renderSlots(); return; }
}
});
- $tiles.appendChild(div);
+ $grid.appendChild(div);
}
}
- async function fetchArtists() {
- try {
- const r = await fetch('/api/catalog/artists');
- const j = await r.json();
- const opts = (j.artists || []).map(a => `<option value="${escapeHtml(a)}">${escapeHtml(a)}</option>`).join('');
- $artist.insertAdjacentHTML('beforeend', opts);
- } catch {}
- }
- let qDebounce;
- $q.addEventListener('input', () => { clearTimeout(qDebounce); qDebounce = setTimeout(fetchRail, 220); });
- $artist.addEventListener('change', fetchRail);
-
- document.getElementById('reset-all').addEventListener('click', () => {
- if (!confirm('Clear all 4 squares?')) return;
- layout = { 1: null, 2: null, 3: null, 4: null };
- saveLayout(); renderSlots();
- });
-
- fetchArtists();
- fetchRail();
+ // ── boot ───────────────────────────────────────────────────────
+ loadSites();
</script>
</body>
</html>
← 57645db scaffold 4square design board
·
back to 4square Agentabrams
·
wire 19 more DW microsites — click → real products, not 'pen a00276f →