[object Object]

← back to SDCC Stories

Global search across candidates + stories + settings

dbfa7ca46c51bda4a358eef92c98438a92452eca · 2026-06-01 07:39:53 -0700 · Steve Abrams

Add a unified search bar to the SDCC-Stories admin header. Replaces the
narrow ?search= filter on /api/candidates (which only hit 3 columns)
with cross-table coverage.

server.js
  GET /api/search?q=<query>&limit=25 — searches:
    · candidates (name, email, zip, story, key_quote, demographics,
      district, representative, story_category, press_pitch_angle,
      story_type, tier — 18 LIKE columns)
    · stories (one_liner, instagram_caption, x_post, hashtags,
      story_category, lane, status)
    · settings (key + value — credential values masked as
      '(set — N chars)' for keys matching /token|secret|password|key$/i)
  Returns unified {results, query, counts, total} grouped by type.

public/index.html
  Add header-center search input + results panel.

public/css/styles.css
  .global-search-* styles matching the existing light theme.

public/js/app.js
  Debounced 220ms search → fetch → grouped render.
  Cmd+K / Ctrl+K focuses input. Esc closes. Arrow keys + Enter.
  Click-through: candidate → switchTab + openCandidate;
                  story → switchTab + scroll-into-view;
                  setting → switchTab('settings').
  Story cards get data-story-id so the scroll-into-view works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit dbfa7ca46c51bda4a358eef92c98438a92452eca
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 07:39:53 2026 -0700

    Global search across candidates + stories + settings
    
    Add a unified search bar to the SDCC-Stories admin header. Replaces the
    narrow ?search= filter on /api/candidates (which only hit 3 columns)
    with cross-table coverage.
    
    server.js
      GET /api/search?q=<query>&limit=25 — searches:
        · candidates (name, email, zip, story, key_quote, demographics,
          district, representative, story_category, press_pitch_angle,
          story_type, tier — 18 LIKE columns)
        · stories (one_liner, instagram_caption, x_post, hashtags,
          story_category, lane, status)
        · settings (key + value — credential values masked as
          '(set — N chars)' for keys matching /token|secret|password|key$/i)
      Returns unified {results, query, counts, total} grouped by type.
    
    public/index.html
      Add header-center search input + results panel.
    
    public/css/styles.css
      .global-search-* styles matching the existing light theme.
    
    public/js/app.js
      Debounced 220ms search → fetch → grouped render.
      Cmd+K / Ctrl+K focuses input. Esc closes. Arrow keys + Enter.
      Click-through: candidate → switchTab + openCandidate;
                      story → switchTab + scroll-into-view;
                      setting → switchTab('settings').
      Story cards get data-story-id so the scroll-into-view works.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/css/styles.css |  80 +++++++++++++++++++++
 public/index.html     |  15 ++++
 public/js/app.js      | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js             |  88 +++++++++++++++++++++++
 4 files changed, 372 insertions(+)

diff --git a/public/css/styles.css b/public/css/styles.css
index e527f61..7520d87 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -43,6 +43,86 @@ header h1 { font-size: 26px; color: var(--text); }
 .subtitle { color: var(--text-muted); font-size: 16px; margin-left: 10px; }
 .header-right { display: flex; align-items: center; gap: 14px; }
 
+/* Global Search — header-center */
+.header-center { flex: 1; max-width: 520px; padding: 0 24px; position: relative; }
+.global-search-wrap { position: relative; width: 100%; }
+.global-search-input {
+  width: 100%;
+  padding: 10px 14px 10px 38px;
+  font-size: 15px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  background: var(--bg) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="%23555555" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>') no-repeat 12px center;
+  color: var(--text);
+  font-family: inherit;
+  transition: border-color 150ms ease, box-shadow 150ms ease;
+}
+.global-search-input:focus {
+  outline: none;
+  border-color: var(--primary);
+  box-shadow: 0 0 0 3px rgba(37,99,235,0.18);
+}
+.global-search-results {
+  position: absolute;
+  top: calc(100% + 6px);
+  left: 0;
+  right: 0;
+  max-height: 70vh;
+  overflow-y: auto;
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  box-shadow: 0 12px 28px rgba(0,0,0,0.12);
+  z-index: 1000;
+}
+.gs-section { padding: 6px 0; border-top: 1px solid var(--border); }
+.gs-section:first-child { border-top: none; }
+.gs-section-label {
+  padding: 8px 16px 4px;
+  font-size: 11px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--text-muted);
+  font-weight: 700;
+}
+.gs-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  padding: 8px 16px;
+  cursor: pointer;
+  border-left: 3px solid transparent;
+  text-decoration: none;
+  color: var(--text);
+}
+.gs-item:hover, .gs-item.gs-selected {
+  background: var(--surface-el);
+  border-left-color: var(--primary);
+}
+.gs-item-icon {
+  flex-shrink: 0; width: 28px; height: 28px; border-radius: 6px;
+  display: flex; align-items: center; justify-content: center;
+  font-size: 14px; font-weight: 700;
+}
+.gs-icon-candidate { background: rgba(37,99,235,0.12); color: var(--primary); }
+.gs-icon-story     { background: rgba(22,163,74,0.12); color: var(--success); }
+.gs-icon-setting   { background: rgba(180,83,9,0.12); color: var(--secondary); }
+.gs-item-body { flex: 1; min-width: 0; }
+.gs-item-title { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.gs-item-subtitle { font-size: 12px; color: var(--text-muted); }
+.gs-item-meta { font-size: 12px; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.gs-empty { padding: 18px 16px; text-align: center; color: var(--text-muted); font-size: 14px; }
+.gs-footer {
+  padding: 8px 16px; border-top: 1px solid var(--border);
+  font-size: 11px; color: var(--text-muted);
+  display: flex; justify-content: space-between;
+}
+.gs-kbd {
+  display: inline-block; padding: 1px 6px; font-size: 10px;
+  background: var(--surface-el); border: 1px solid var(--border);
+  border-radius: 3px; font-family: inherit;
+}
+
 /* Stats Bar */
 .stats-bar {
   display: flex;
diff --git a/public/index.html b/public/index.html
index d216510..a50ad15 100644
--- a/public/index.html
+++ b/public/index.html
@@ -14,6 +14,21 @@
       <h1>SDCC Story Engine</h1>
       <span class="subtitle">Borrower Stories &rarr; Social Impact</span>
     </div>
+    <div class="header-center">
+      <div class="global-search-wrap">
+        <input
+          type="text"
+          id="global-search-input"
+          class="global-search-input"
+          placeholder="Search candidates, stories, settings… (⌘K)"
+          autocomplete="off"
+          onfocus="openGlobalSearch()"
+          oninput="onGlobalSearchInput(event)"
+          onkeydown="onGlobalSearchKeyDown(event)"
+        >
+        <div id="global-search-results" class="global-search-results" hidden></div>
+      </div>
+    </div>
     <div class="header-right">
       <span id="ig-status" class="badge">Instagram: Checking...</span>
       <button class="btn btn-sm" onclick="connectInstagram()">Connect IG</button>
diff --git a/public/js/app.js b/public/js/app.js
index b691aba..d3c6f90 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -392,6 +392,7 @@ async function loadStories() {
     const card = document.createElement('div');
     card.className = 'story-card';
     card.style.cursor = 'pointer';
+    card.dataset.storyId = s.id;
 
     // Header row
     const header = document.createElement('div');
@@ -864,6 +865,194 @@ async function refreshData() {
   }
 }
 
+// ─── Global Search ───
+let gsTimer = null;
+let gsResults = [];
+let gsSelected = 0;
+
+function openGlobalSearch() {
+  const panel = document.getElementById('global-search-results');
+  if (panel) panel.hidden = false;
+}
+
+function closeGlobalSearch() {
+  const panel = document.getElementById('global-search-results');
+  if (panel) panel.hidden = true;
+}
+
+function onGlobalSearchInput(e) {
+  const q = (e.target.value || '').trim();
+  clearTimeout(gsTimer);
+  if (q.length < 2) {
+    gsResults = [];
+    renderGlobalSearchResults('');
+    return;
+  }
+  gsTimer = setTimeout(() => runGlobalSearch(q), 220);
+}
+
+async function runGlobalSearch(q) {
+  const panel = document.getElementById('global-search-results');
+  if (!panel) return;
+  panel.innerHTML = '<div class="gs-empty">Searching…</div>';
+  panel.hidden = false;
+  try {
+    const res = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=25', { credentials: 'include' });
+    if (!res.ok) {
+      panel.innerHTML = '<div class="gs-empty">Search failed (HTTP ' + res.status + ').</div>';
+      return;
+    }
+    const data = await res.json();
+    gsResults = data.results || [];
+    gsSelected = 0;
+    renderGlobalSearchResults(q, data);
+  } catch (err) {
+    panel.innerHTML = '<div class="gs-empty">Search error: ' + (err.message || err) + '</div>';
+  }
+}
+
+function renderGlobalSearchResults(q, data) {
+  const panel = document.getElementById('global-search-results');
+  if (!panel) return;
+  if (!q || gsResults.length === 0) {
+    if (q && q.length >= 2) {
+      panel.innerHTML = '<div class="gs-empty">No results for &ldquo;' + escapeHtml(q) + '&rdquo;.</div>'
+        + '<div class="gs-footer"><span><span class="gs-kbd">esc</span> close</span></div>';
+      panel.hidden = false;
+    } else {
+      panel.innerHTML = '';
+      panel.hidden = true;
+    }
+    return;
+  }
+  // Group by type for sections
+  const groups = { candidate: [], story: [], setting: [] };
+  for (const r of gsResults) {
+    if (groups[r.type]) groups[r.type].push(r);
+  }
+  const LABELS = { candidate: 'Candidates', story: 'Stories', setting: 'Settings' };
+  const ICONS  = { candidate: 'C', story: 'S', setting: '⚙' };
+  let html = '';
+  let flatIdx = 0;
+  for (const type of ['candidate', 'story', 'setting']) {
+    const arr = groups[type];
+    if (!arr.length) continue;
+    const cnt = (data && data.counts && data.counts[type + 's']) || arr.length;
+    html += '<div class="gs-section"><div class="gs-section-label">' + LABELS[type] + ' (' + cnt + ')</div>';
+    for (const r of arr) {
+      const selected = flatIdx === gsSelected ? ' gs-selected' : '';
+      html += '<div class="gs-item' + selected + '" data-idx="' + flatIdx + '" data-type="' + type + '" data-id="' + escapeAttr(r.id) + '">'
+        + '<div class="gs-item-icon gs-icon-' + type + '">' + ICONS[type] + '</div>'
+        + '<div class="gs-item-body">'
+        + '<div class="gs-item-title">' + escapeHtml(r.title) + '</div>'
+        + (r.subtitle ? '<div class="gs-item-subtitle">' + escapeHtml(r.subtitle) + '</div>' : '')
+        + (r.meta     ? '<div class="gs-item-meta">' + escapeHtml(r.meta) + '</div>' : '')
+        + '</div></div>';
+      flatIdx++;
+    }
+    html += '</div>';
+  }
+  html += '<div class="gs-footer">'
+       +    '<span><span class="gs-kbd">↑</span> <span class="gs-kbd">↓</span> navigate &nbsp; <span class="gs-kbd">↵</span> open</span>'
+       +    '<span><span class="gs-kbd">esc</span> close</span>'
+       + '</div>';
+  panel.innerHTML = html;
+  panel.hidden = false;
+  // Wire clicks
+  panel.querySelectorAll('.gs-item').forEach(el => {
+    el.addEventListener('click', () => {
+      const idx = parseInt(el.dataset.idx, 10);
+      pickGlobalSearchResult(idx);
+    });
+    el.addEventListener('mouseenter', () => {
+      gsSelected = parseInt(el.dataset.idx, 10);
+      highlightGlobalSearchSelection();
+    });
+  });
+}
+
+function highlightGlobalSearchSelection() {
+  const panel = document.getElementById('global-search-results');
+  if (!panel) return;
+  panel.querySelectorAll('.gs-item').forEach(el => {
+    el.classList.toggle('gs-selected', parseInt(el.dataset.idx, 10) === gsSelected);
+  });
+  // Ensure selection visible
+  const sel = panel.querySelector('.gs-selected');
+  if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' });
+}
+
+function onGlobalSearchKeyDown(e) {
+  if (e.key === 'Escape') {
+    e.target.value = '';
+    gsResults = [];
+    closeGlobalSearch();
+    e.target.blur();
+    return;
+  }
+  if (!gsResults.length) return;
+  if (e.key === 'ArrowDown') {
+    e.preventDefault();
+    gsSelected = Math.min(gsSelected + 1, gsResults.length - 1);
+    highlightGlobalSearchSelection();
+  } else if (e.key === 'ArrowUp') {
+    e.preventDefault();
+    gsSelected = Math.max(gsSelected - 1, 0);
+    highlightGlobalSearchSelection();
+  } else if (e.key === 'Enter') {
+    e.preventDefault();
+    pickGlobalSearchResult(gsSelected);
+  }
+}
+
+function pickGlobalSearchResult(idx) {
+  const r = gsResults[idx];
+  if (!r) return;
+  closeGlobalSearch();
+  const input = document.getElementById('global-search-input');
+  if (input) input.value = '';
+  if (r.type === 'candidate') {
+    switchTab('candidates');
+    // Best-effort: openCandidate exists in this file
+    if (typeof openCandidate === 'function') {
+      setTimeout(() => openCandidate(r.id), 50);
+    }
+  } else if (r.type === 'story') {
+    switchTab('stories');
+    // Try to scroll the matching story card into view
+    setTimeout(() => {
+      const card = document.querySelector('[data-story-id="' + r.id + '"]');
+      if (card) {
+        card.scrollIntoView({ behavior: 'smooth', block: 'center' });
+        card.style.outline = '2px solid var(--primary)';
+        setTimeout(() => { card.style.outline = ''; }, 1500);
+      }
+    }, 250);
+  } else if (r.type === 'setting') {
+    switchTab('settings');
+  }
+}
+
+function escapeHtml(s) {
+  return String(s == null ? '' : s)
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+function escapeAttr(s) { return escapeHtml(s); }
+
+// Close on outside click + ⌘K / Ctrl+K to focus the search input.
+document.addEventListener('click', (e) => {
+  const wrap = document.querySelector('.global-search-wrap');
+  if (wrap && !wrap.contains(e.target)) closeGlobalSearch();
+});
+document.addEventListener('keydown', (e) => {
+  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
+    e.preventDefault();
+    const input = document.getElementById('global-search-input');
+    if (input) { input.focus(); input.select(); }
+  }
+});
+
 // ─── Init ───
 document.addEventListener('DOMContentLoaded', () => {
   loadStats();
diff --git a/server.js b/server.js
index 9f8317d..b810f7b 100644
--- a/server.js
+++ b/server.js
@@ -353,6 +353,94 @@ app.post('/api/stories/:id/generate-lawmaker-graphic', async (req, res) => {
   }
 });
 
+// ─── Global Search (candidates + stories + settings) ───
+// GET /api/search?q=<query>&limit=25
+// Returns unified results grouped by type. Replaces the narrow ?search= on
+// /api/candidates (still kept for back-compat) with cross-table coverage.
+app.get('/api/search', (req, res) => {
+  const q = (req.query.q || '').trim();
+  const limit = Math.min(parseInt(req.query.limit, 10) || 25, 50);
+  if (q.length < 2) return res.json({ results: [], query: q, counts: {}, total: 0 });
+  const pat = '%' + q + '%';
+
+  // Candidates — wide LIKE across name, location, story, demographics, district.
+  const candidates = db.prepare(`
+    SELECT id, first_name, last_name, zip_code, tier, candidate_score,
+           amount_display, district_code, representative_name,
+           emotional_impact, story_category
+    FROM candidates
+    WHERE first_name LIKE ? OR last_name LIKE ? OR email LIKE ?
+       OR zip_code LIKE ? OR (first_name || ' ' || last_name) LIKE ?
+       OR story LIKE ? OR key_quote LIKE ?
+       OR emotional_impact LIKE ? OR affordability_impacts_raw LIKE ?
+       OR alt_spending LIKE ? OR employment LIKE ? OR education LIKE ?
+       OR story_category LIKE ? OR press_pitch_angle LIKE ?
+       OR representative_name LIKE ? OR district_code LIKE ? OR tier LIKE ?
+       OR story_type LIKE ?
+    ORDER BY CASE
+        WHEN (first_name || ' ' || last_name) LIKE ? THEN 0
+        WHEN candidate_score IS NOT NULL THEN 1
+        ELSE 2
+      END,
+      candidate_score DESC NULLS LAST
+    LIMIT ?
+  `).all(pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, pat, limit);
+
+  // Stories — join candidate for context, search caption + post + one-liner + hashtags.
+  const stories = db.prepare(`
+    SELECT s.id, s.candidate_id, s.story_one_liner, s.instagram_caption, s.x_post,
+           s.story_category, s.status, s.scheduled_at, s.posted_at, s.lane,
+           c.first_name, c.last_name, c.tier, c.zip_code
+    FROM stories s
+    LEFT JOIN candidates c ON s.candidate_id = c.id
+    WHERE s.story_one_liner LIKE ? OR s.instagram_caption LIKE ?
+       OR s.x_post LIKE ? OR s.hashtags LIKE ?
+       OR s.story_category LIKE ? OR s.lane LIKE ? OR s.status LIKE ?
+    ORDER BY s.created_at DESC
+    LIMIT ?
+  `).all(pat, pat, pat, pat, pat, pat, pat, limit);
+
+  // Settings — useful for IG/app config lookups by key (e.g. "ig_app_id").
+  const settings = db.prepare(`
+    SELECT key, value
+    FROM settings
+    WHERE key LIKE ? OR value LIKE ?
+    ORDER BY key
+    LIMIT ?
+  `).all(pat, pat, limit);
+
+  const results = [];
+  for (const c of candidates) {
+    const name = ((c.first_name || '') + ' ' + (c.last_name || '')).trim() || '(no name)';
+    const sub = [c.tier ? 'Tier ' + c.tier : null, c.zip_code, c.district_code].filter(Boolean).join(' · ');
+    const meta = [c.amount_display, c.emotional_impact && c.emotional_impact.slice(0, 60)].filter(Boolean).join(' · ');
+    results.push({ id: c.id, type: 'candidate', title: name, subtitle: sub, meta, score: c.candidate_score });
+  }
+  for (const s of stories) {
+    const candidateName = ((s.first_name || '') + ' ' + (s.last_name || '')).trim() || '(unlinked)';
+    const sub = [s.lane, s.status, s.story_category].filter(Boolean).join(' · ');
+    const meta = (s.story_one_liner || s.instagram_caption || s.x_post || '').slice(0, 80);
+    results.push({ id: s.id, type: 'story', title: candidateName + ' — story #' + s.id, subtitle: sub, meta });
+  }
+  for (const st of settings) {
+    const safeValue = /token|secret|password|key$/i.test(st.key)
+      ? (st.value ? '(set — ' + st.value.length + ' chars)' : '(empty)')
+      : (st.value || '').slice(0, 80);
+    results.push({ id: st.key, type: 'setting', title: st.key, subtitle: 'Setting', meta: safeValue });
+  }
+
+  res.json({
+    results,
+    query: q,
+    counts: {
+      candidates: candidates.length,
+      stories: stories.length,
+      settings: settings.length,
+    },
+    total: results.length,
+  });
+});
+
 // ─── Stories CRUD ───
 app.get('/api/stories', (req, res) => {
   const status = req.query.status;

← 8c69aad gitignore: add *.bak-* *.pre-* *.orig patterns to prevent ba  ·  back to SDCC Stories  ·  (newest)