[object Object]

← back to Wallco Ai

global search: live typeahead overlay consuming /api/search — instant designs+collections+pages dropdown with keyboard nav + did-you-mean, wired site-wide via shared header (public/global-search.js + header script include + input id). LOCAL only — deploy parked for Steve.

fe7ac026e05fa29f1db064edd1f29d92e6ec0669 · 2026-06-03 09:06:57 -0700 · Steve Abrams

Files touched

Diff

commit fe7ac026e05fa29f1db064edd1f29d92e6ec0669
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 3 09:06:57 2026 -0700

    global search: live typeahead overlay consuming /api/search — instant designs+collections+pages dropdown with keyboard nav + did-you-mean, wired site-wide via shared header (public/global-search.js + header script include + input id). LOCAL only — deploy parked for Steve.
---
 public/global-search.js | 159 ++++++++++++++++++++++++++++++++++++++++++++++++
 server.js               |   3 +-
 2 files changed, 161 insertions(+), 1 deletion(-)

diff --git a/public/global-search.js b/public/global-search.js
new file mode 100644
index 0000000..73bb349
--- /dev/null
+++ b/public/global-search.js
@@ -0,0 +1,159 @@
+/*!
+ * global-search.js — live typeahead overlay for wallco.ai's universal search.
+ * Consumes the already-built /api/search (universalSearch: categories + pages +
+ * designs, scored, did-you-mean). Hijacks the header search input (id
+ * global-search-input) and renders an instant dropdown as you type. Submitting
+ * (Enter on no selection) still falls through to the full /search page.
+ * Self-contained: injects its own CSS, zero deps. Progressive enhancement —
+ * if JS is off, the form's GET /search still works.
+ */
+(function () {
+  'use strict';
+  function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
+
+  ready(function () {
+    var input = document.getElementById('global-search-input');
+    if (!input) return;
+    var form = input.closest('form') || input.parentNode;
+
+    // ── styles (scoped under .gs-*) ─────────────────────────────────────────
+    if (!document.getElementById('gs-style')) {
+      var st = document.createElement('style');
+      st.id = 'gs-style';
+      st.textContent = [
+        '.gs-wrap{position:relative;flex:1;min-width:0}',
+        '.gs-panel{position:absolute;left:0;right:0;top:calc(100% + 6px);z-index:60;max-height:min(70vh,520px);overflow:auto;',
+        '  background:var(--card-bg,#faf8f3);border:1px solid var(--line,#d8d0c0);border-radius:14px;',
+        '  box-shadow:0 18px 50px rgba(0,0,0,.22);padding:6px;display:none;font:13px var(--sans,system-ui)}',
+        '.gs-panel.open{display:block}',
+        '.gs-sec{padding:6px 8px 2px}',
+        '.gs-sec-h{font:600 10px/1 var(--sans,system-ui);letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#8a7e68);margin:6px 6px 4px}',
+        '.gs-item{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:9px;text-decoration:none;color:var(--ink,#1f1808);cursor:pointer}',
+        '.gs-item:hover,.gs-item.gs-active{background:rgba(0,0,0,.06)}',
+        '.gs-thumb{flex:0 0 auto;width:38px;height:38px;border-radius:7px;background-size:cover;background-position:center 12%;background-color:#e9e3d6}',
+        '.gs-body{min-width:0;flex:1}',
+        '.gs-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
+        '.gs-sub{font-size:11px;color:var(--ink-faint,#8a7e68);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
+        '.gs-empty{padding:14px 12px;color:var(--ink-faint,#8a7e68);font-size:12.5px}',
+        '.gs-dym{margin-top:6px}',
+        '.gs-dym a{display:inline-block;margin:2px 4px 0 0;padding:3px 9px;border:1px solid var(--line,#d8d0c0);border-radius:999px;color:var(--ink,#1f1808);text-decoration:none;font-size:12px}',
+        '.gs-dym a:hover{background:rgba(0,0,0,.06)}',
+        '.gs-all{display:block;text-align:center;padding:9px;margin-top:4px;border-top:1px solid var(--line,#e7e0d2);color:var(--ink,#1f1808);text-decoration:none;font-size:12px;font-weight:500}',
+        '.gs-all:hover{background:rgba(0,0,0,.05)}'
+      ].join('');
+      document.head.appendChild(st);
+    }
+
+    // wrap the input so the panel can anchor to it
+    var wrap = document.createElement('span');
+    wrap.className = 'gs-wrap';
+    input.parentNode.insertBefore(wrap, input);
+    wrap.appendChild(input);
+    var panel = document.createElement('div');
+    panel.className = 'gs-panel';
+    panel.setAttribute('role', 'listbox');
+    wrap.appendChild(panel);
+
+    var esc = function (s) { return String(s == null ? '' : s).replace(/[<>&"]/g, function (c) { return { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]; }); };
+    var debounceT = null, lastQ = '', items = [], activeIdx = -1, ctrl = null;
+
+    function close() { panel.classList.remove('open'); activeIdx = -1; }
+    function open() { if (panel.innerHTML.trim()) panel.classList.add('open'); }
+
+    function render(data) {
+      var q = data.q || '';
+      var r = data.results || {};
+      var designs = r.designs || [], cats = r.categories || [], pages = r.pages || [];
+      var html = '';
+      items = [];
+      var allUrl = '/search?q=' + encodeURIComponent(q);
+
+      if (designs.length) {
+        html += '<div class="gs-sec"><div class="gs-sec-h">Designs</div>';
+        designs.slice(0, 8).forEach(function (d) {
+          items.push(d.url);
+          html += '<a class="gs-item" role="option" href="' + esc(d.url) + '">'
+            + '<span class="gs-thumb" style="background-image:url(\'' + esc(d.image_url || '') + '\')"></span>'
+            + '<span class="gs-body"><span class="gs-title">' + esc(d.title || 'Untitled') + '</span>'
+            + '<span class="gs-sub">' + esc(d.category || '') + '</span></span></a>';
+        });
+        html += '</div>';
+      }
+      if (cats.length) {
+        html += '<div class="gs-sec"><div class="gs-sec-h">Collections</div>';
+        cats.slice(0, 6).forEach(function (c) {
+          items.push(c.url);
+          html += '<a class="gs-item" role="option" href="' + esc(c.url) + '">'
+            + '<span class="gs-thumb" style="background-image:url(\'' + esc(c.thumb || '') + '\')"></span>'
+            + '<span class="gs-body"><span class="gs-title">' + esc(c.title || c.slug) + '</span>'
+            + '<span class="gs-sub">' + (c.count || 0) + ' design' + (c.count === 1 ? '' : 's') + '</span></span></a>';
+        });
+        html += '</div>';
+      }
+      if (pages.length) {
+        html += '<div class="gs-sec"><div class="gs-sec-h">Pages</div>';
+        pages.slice(0, 6).forEach(function (p) {
+          items.push(p.path || p.url);
+          html += '<a class="gs-item" role="option" href="' + esc(p.path || p.url) + '">'
+            + '<span class="gs-body"><span class="gs-title">' + esc(p.title || '') + '</span>'
+            + '<span class="gs-sub">' + esc(p.blurb || '') + '</span></span></a>';
+        });
+        html += '</div>';
+      }
+
+      if (!designs.length && !cats.length && !pages.length) {
+        html += '<div class="gs-empty">No matches for &ldquo;' + esc(q) + '&rdquo;.';
+        if (data.did_you_mean && data.did_you_mean.length) {
+          html += '<div class="gs-dym">Did you mean: ' + data.did_you_mean.map(function (s) {
+            return '<a href="/search?q=' + encodeURIComponent(s.word) + '">' + esc(s.word) + '</a>';
+          }).join('') + '</div>';
+        }
+        html += '</div>';
+      } else {
+        html += '<a class="gs-all" href="' + esc(allUrl) + '">See all results for &ldquo;' + esc(q) + '&rdquo; &rarr;</a>';
+        items.push(allUrl);
+      }
+      panel.innerHTML = html;
+      activeIdx = -1;
+      open();
+    }
+
+    function fetchResults(q) {
+      if (ctrl) try { ctrl.abort(); } catch (e) {}
+      ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
+      fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=8', ctrl ? { signal: ctrl.signal } : undefined)
+        .then(function (r) { return r.json(); })
+        .then(function (data) { if ((input.value || '').trim() === q) render(data); })
+        .catch(function () {});
+    }
+
+    input.setAttribute('autocomplete', 'off');
+    input.addEventListener('input', function () {
+      var q = (input.value || '').trim();
+      clearTimeout(debounceT);
+      if (q.length < 2) { close(); panel.innerHTML = ''; lastQ = q; return; }
+      debounceT = setTimeout(function () { if (q !== lastQ) { lastQ = q; fetchResults(q); } }, 180);
+    });
+    input.addEventListener('focus', function () { if ((input.value || '').trim().length >= 2 && panel.innerHTML.trim()) open(); });
+
+    var nodes = function () { return panel.querySelectorAll('.gs-item, .gs-all'); };
+    function setActive(i) {
+      var ns = nodes(); if (!ns.length) return;
+      activeIdx = (i + ns.length) % ns.length;
+      ns.forEach(function (n, k) { n.classList.toggle('gs-active', k === activeIdx); });
+      ns[activeIdx].scrollIntoView({ block: 'nearest' });
+    }
+    input.addEventListener('keydown', function (e) {
+      if (!panel.classList.contains('open')) return;
+      if (e.key === 'ArrowDown') { e.preventDefault(); setActive(activeIdx + 1); }
+      else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(activeIdx - 1); }
+      else if (e.key === 'Enter') {
+        var ns = nodes();
+        if (activeIdx >= 0 && ns[activeIdx]) { e.preventDefault(); window.location.href = ns[activeIdx].getAttribute('href'); }
+        // else: let the form submit to /search (default behavior)
+      } else if (e.key === 'Escape') { close(); }
+    });
+
+    document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
+  });
+})();
diff --git a/server.js b/server.js
index 0262952..08e9ff1 100644
--- a/server.js
+++ b/server.js
@@ -6445,7 +6445,7 @@ function htmlHeader(active) {
 </header>
 <nav class="nav-panel" id="nav-panel" hidden>
   <form class="nav-search" method="get" action="/search" role="search" style="display:flex;gap:8px;margin:0 14px 12px;align-items:center">
-    <input type="search" name="q" placeholder="Search wallco.ai…" autocomplete="off" aria-label="Search wallco.ai" style="flex:1;padding:10px 14px;font:14px var(--sans,system-ui);border:1px solid var(--line,#d8d0c0);border-radius:999px;background:var(--card-bg,#faf8f3);color:var(--ink,#1f1808);outline:none">
+    <input type="search" id="global-search-input" name="q" placeholder="Search wallco.ai…" autocomplete="off" aria-label="Search wallco.ai" style="flex:1;padding:10px 14px;font:14px var(--sans,system-ui);border:1px solid var(--line,#d8d0c0);border-radius:999px;background:var(--card-bg,#faf8f3);color:var(--ink,#1f1808);outline:none">
     <button type="submit" aria-label="Search" style="flex:0 0 auto;width:38px;height:38px;border:0;border-radius:50%;background:var(--accent,#1a1a1a);color:var(--bg,#fff);cursor:pointer;display:inline-flex;align-items:center;justify-content:center">
       <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
     </button>
@@ -6480,6 +6480,7 @@ function htmlHeader(active) {
     <svg class="moon-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
   </button>
 </nav>
+<script src="/global-search.js" defer></script>
 <script>
 (function(){
   // Trade-user nav hydration. Hits /api/trade/me; swaps Sign-In ↔ name+saved-count.

← f7b76dd refresh_designs_snapshot: admit all gemini-%image% generator  ·  back to Wallco Ai  ·  mural-contact-sheet.py: visual panel review aid for any buil e5dc163 →