[object Object]

← back to CelebritySignatures

Add zero-dep grid viewer (server.js + public/index.html): sort + density slider, risk/usability badges, uniform card sizing

44a7d34021d3baffeb7efc792c946b9d926e4599 · 2026-06-18 10:00:05 -0700 · Steve

Files touched

Diff

commit 44a7d34021d3baffeb7efc792c946b9d926e4599
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 18 10:00:05 2026 -0700

    Add zero-dep grid viewer (server.js + public/index.html): sort + density slider, risk/usability badges, uniform card sizing
---
 .gitignore        |   1 +
 public/index.html | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         |  25 +++++++++++
 3 files changed, 152 insertions(+)

diff --git a/.gitignore b/.gitignore
index 34bf3f8..654e015 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ dist/
 build/
 .next/
 tmp_wdqs.log
+tmp_*.log
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..30f409d
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,126 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Celebrity Signatures</title>
+<style>
+  :root { --cols: 5; --ink:#1a1a1a; --muted:#6b6b6b; --line:#e6e3dc; --bg:#f7f5f0; --card:#fff; }
+  * { box-sizing: border-box; }
+  body { margin:0; font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:var(--bg); }
+  header { padding:26px 28px 14px; border-bottom:1px solid var(--line); background:#fff; position:sticky; top:0; z-index:20; }
+  h1 { margin:0; font-weight:600; font-size:22px; letter-spacing:.01em; }
+  h1 span { color:var(--muted); font-weight:400; font-size:14px; margin-left:8px; }
+  .controls { display:flex; flex-wrap:wrap; gap:18px 22px; align-items:center; margin-top:14px; }
+  .controls label { font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-right:8px; }
+  select, input[type=range] { font:inherit; }
+  select { padding:6px 10px; border:1px solid var(--line); border-radius:7px; background:#fff; }
+  .chips { display:flex; flex-wrap:wrap; gap:7px; }
+  .chip { padding:5px 12px; border:1px solid var(--line); border-radius:999px; background:#fff; cursor:pointer; font-size:13px; color:var(--muted); }
+  .chip.on { background:var(--ink); color:#fff; border-color:var(--ink); }
+  .density { display:flex; align-items:center; gap:10px; }
+  main { padding:22px 28px 60px; }
+  .grid { display:grid; grid-template-columns:repeat(var(--cols), 1fr); gap:18px; }
+  .card { background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden; display:flex; flex-direction:column; }
+  .sig { height:120px; display:flex; align-items:center; justify-content:center; padding:16px; background:#fff; border-bottom:1px solid var(--line); }
+  .sig img { max-width:100%; max-height:100%; object-fit:contain; }
+  .meta { padding:11px 13px 13px; display:flex; flex-direction:column; gap:6px; flex:1; }
+  .name { font-weight:600; font-size:14.5px; line-height:1.25; }
+  .row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
+  .tag { font-size:11px; padding:2px 8px; border-radius:999px; background:#eee; color:#444; }
+  .badge { font-size:11px; padding:2px 8px; border-radius:999px; font-weight:600; }
+  .b-low { background:#e3f3e6; color:#1d7a36; } .b-medium { background:#fdf1d8; color:#9a6b00; } .b-high { background:#fae0df; color:#b3261e; }
+  .use-yes { background:#1d7a36; color:#fff; } .use-perm { background:#9a6b00; color:#fff; } .use-review { background:#777; color:#fff; }
+  .sub { font-size:11.5px; color:var(--muted); margin-top:auto; }
+  .sub a { color:var(--muted); }
+  .empty { color:var(--muted); padding:40px; text-align:center; }
+</style>
+</head>
+<body>
+<header>
+  <h1>Celebrity Signatures <span id="count"></span></h1>
+  <div class="controls">
+    <div><label>Category</label></div>
+    <div class="chips" id="catChips"></div>
+    <div><label>Use</label>
+      <select id="useFilter">
+        <option value="all">All</option>
+        <option value="yes">Collage-usable only</option>
+        <option value="permission-needed">Permission-needed only</option>
+      </select>
+    </div>
+    <div><label>Sort</label>
+      <select id="sort">
+        <option value="cat">Category, then rank</option>
+        <option value="name">Name A→Z</option>
+        <option value="notability">Notability ↓</option>
+        <option value="usable">Usable first</option>
+        <option value="risk">Risk low→high</option>
+      </select>
+    </div>
+    <div class="density"><label>Density</label>
+      <input id="density" type="range" min="3" max="9" value="5">
+    </div>
+  </div>
+</header>
+<main><div class="grid" id="grid"></div><div class="empty" id="empty" hidden>No signatures match these filters.</div></main>
+
+<script>
+const LS = 'celebsig.prefs';
+const prefs = Object.assign({ cat:'All', use:'all', sort:'cat', density:5 }, JSON.parse(localStorage.getItem(LS)||'{}'));
+let DATA = [];
+const $ = (s) => document.querySelector(s);
+const notability = (r) => { const m=(r.reason_for_ranking||'').match(/(\d+) language/); return m?+m[1]:0; };
+const riskRank = { low:0, medium:1, high:2 };
+
+function save() { localStorage.setItem(LS, JSON.stringify(prefs)); }
+
+function render() {
+  document.documentElement.style.setProperty('--cols', prefs.density);
+  $('#density').value = prefs.density;
+  $('#useFilter').value = prefs.use;
+  $('#sort').value = prefs.sort;
+
+  let rows = DATA.filter(r => (prefs.cat==='All' || r.category===prefs.cat)
+                           && (prefs.use==='all' || r.usable_in_commercial_collage===prefs.use));
+  const cats = ['Declaration of Independence','Politics','Sports','Hollywood','Movies','TV'];
+  const sorters = {
+    cat: (a,b) => cats.indexOf(a.category)-cats.indexOf(b.category) || a.rank-b.rank,
+    name: (a,b) => a.full_name.localeCompare(b.full_name),
+    notability: (a,b) => notability(b)-notability(a),
+    usable: (a,b) => (a.usable_in_commercial_collage==='yes'?0:1)-(b.usable_in_commercial_collage==='yes'?0:1) || a.rank-b.rank,
+    risk: (a,b) => (riskRank[a.risk_level]??9)-(riskRank[b.risk_level]??9) || a.rank-b.rank,
+  };
+  rows.sort(sorters[prefs.sort] || sorters.cat);
+
+  $('#count').textContent = `· ${rows.length} of ${DATA.length}`;
+  $('#empty').hidden = rows.length>0;
+  $('#grid').innerHTML = rows.map(r => {
+    const ub = r.usable_in_commercial_collage;
+    const uClass = ub==='yes'?'use-yes':ub==='permission-needed'?'use-perm':'use-review';
+    const uText = ub==='yes'?'usable':ub==='permission-needed'?'permission':'review';
+    return `<div class="card">
+      <div class="sig"><img loading="lazy" src="${r.signature_image_url}" alt="Signature of ${r.full_name}" onerror="this.parentNode.innerHTML='<span style=color:#bbb;font-size:12px>image unavailable</span>'"></div>
+      <div class="meta">
+        <div class="name">${r.full_name}</div>
+        <div class="row"><span class="tag">${r.category}</span><span class="badge b-${r.risk_level}">${r.risk_level}</span><span class="badge ${uClass}">${uText}</span></div>
+        <div class="sub">${r.deceased==='yes'?'† ':'● living · '}${(r.image_license||'').slice(0,28)}</div>
+        <div class="sub"><a href="${r.wikidata}" target="_blank" rel="noopener noreferrer">Wikidata</a> · <a href="${r.backup_source}" target="_blank" rel="noopener noreferrer">source</a></div>
+      </div></div>`;
+  }).join('');
+}
+
+function buildChips() {
+  const cats = ['All','Declaration of Independence','Politics','Sports','Hollywood','Movies','TV'];
+  $('#catChips').innerHTML = cats.map(c => `<span class="chip${c===prefs.cat?' on':''}" data-c="${c}">${c}</span>`).join('');
+  $('#catChips').onclick = (e) => { const c=e.target.dataset.c; if(!c) return; prefs.cat=c; save();
+    document.querySelectorAll('.chip').forEach(x=>x.classList.toggle('on', x.dataset.c===c)); render(); };
+}
+$('#density').oninput = (e) => { prefs.density=+e.target.value; save(); render(); };
+$('#useFilter').onchange = (e) => { prefs.use=e.target.value; save(); render(); };
+$('#sort').onchange = (e) => { prefs.sort=e.target.value; save(); render(); };
+
+fetch('/api/signatures').then(r=>r.json()).then(d => { DATA=d; buildChips(); render(); });
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..cf5735a
--- /dev/null
+++ b/server.js
@@ -0,0 +1,25 @@
+#!/usr/bin/env node
+// Zero-dependency static server for the CelebritySignatures grid.
+import { createServer } from 'node:http';
+import { readFile } from 'node:fs/promises';
+import { extname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = fileURLToPath(new URL('.', import.meta.url));
+const PORT = process.env.PORT || 9920;
+const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml' };
+
+createServer(async (req, res) => {
+  try {
+    let path = decodeURIComponent(req.url.split('?')[0]);
+    if (path === '/') path = '/public/index.html';
+    if (path === '/api/signatures') path = '/data/celebrity_signatures.json';
+    const file = join(ROOT, path);
+    if (!file.startsWith(ROOT)) { res.writeHead(403).end('forbidden'); return; }
+    const body = await readFile(file);
+    res.writeHead(200, { 'Content-Type': TYPES[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
+    res.end(body);
+  } catch {
+    res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found');
+  }
+}).listen(PORT, () => console.log(`CelebritySignatures grid → http://localhost:${PORT}`));

← 7d02e4f Populate 556 verified signatures (500 celebrity + 56 DoI); s  ·  back to CelebritySignatures  ·  Build per-category collages (usable-only): Declaration(56), 933e944 →