← 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
M .gitignoreA public/index.htmlA server.js
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 →