← back to CelebritySignatures
public/index.html
174 lines
<!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; overflow-wrap:break-word; word-break:break-word; hyphens:auto; }
.row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; min-width:0; }
.tag { font-size:11px; padding:2px 8px; border-radius:999px; background:#eee; color:#444; max-width:100%; overflow-wrap:break-word; word-break:break-word; }
.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; }
.tabs { display:flex; gap:4px; margin-top:14px; }
.tab { padding:7px 18px; border:1px solid var(--line); border-bottom:none; border-radius:9px 9px 0 0; background:#faf9f6; cursor:pointer; font-size:14px; color:var(--muted); }
.tab.on { background:var(--ink); color:#fff; border-color:var(--ink); }
.collages { display:flex; flex-direction:column; gap:30px; max-width:1100px; }
.collage { border:1px solid var(--line); border-radius:14px; overflow:hidden; background:#fff; }
.collage .ch { display:flex; align-items:baseline; gap:12px; padding:14px 18px; border-bottom:1px solid var(--line); }
.collage .ch b { font-size:17px; }
.collage .ch .c { color:var(--muted); font-size:13px; }
.collage .ch a { margin-left:auto; font-size:13px; color:#1d7a36; text-decoration:none; }
.collage img { width:100%; display:block; }
.collage .note { padding:24px 18px; color:var(--muted); font-size:14px; }
</style>
</head>
<body>
<header>
<h1>Celebrity Signatures <span id="count"></span></h1>
<div class="tabs">
<span class="tab on" data-v="grid">Grid</span>
<span class="tab" data-v="collages">Collages</span>
</div>
<div class="controls" id="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 id="gridView"><div class="grid" id="grid"></div><div class="empty" id="empty" hidden>No signatures match these filters.</div></div>
<div id="collagesView" class="collages" hidden></div>
</main>
<script>
const LS = 'celebsig.prefs';
const prefs = Object.assign({ cat:'All', use:'all', sort:'cat', density:5, view:'grid' }, 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 = ['Oldest Signatures','Declaration of Independence','Politics'];
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','Oldest Signatures','Declaration of Independence','Politics'];
$('#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(); };
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
let collagesBuilt = false;
function buildCollages() {
if (collagesBuilt) return; collagesBuilt = true;
const order = ['Oldest Signatures','Declaration of Independence','Politics'];
const usable = {}; DATA.forEach(r => { if(r.usable_in_commercial_collage==='yes') usable[r.category]=(usable[r.category]||0)+1; });
const cats = [...new Set([...order, ...Object.keys(usable)])].filter(c => order.includes(c) || usable[c]);
$('#collagesView').innerHTML = cats.map(c => {
const n = usable[c]||0; const src = `/output/collage-${slug(c)}.png`;
if (!n) return `<div class="collage"><div class="ch"><b>${c}</b><span class="c">0 collage-usable signatures</span></div>
<div class="note">No collage — every entry in this category is a living celebrity (flagged <em>permission-needed</em> for right of publicity).</div></div>`;
return `<div class="collage">
<div class="ch"><b>${c}</b><span class="c">${n} collage-usable signatures · public domain / openly licensed</span>
<a href="${src}" download>↓ download PNG</a></div>
<img loading="lazy" src="${src}" alt="${c} signatures collage"
onerror="this.closest('.collage').querySelector('.ch .c').textContent='collage not built yet — run scripts/build-collages.py'; this.remove();"></div>`;
}).join('');
}
function setView(v) {
prefs.view = v; save();
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.dataset.v===v));
$('#controls').style.display = v==='grid' ? '' : 'none';
$('#gridView').hidden = v!=='grid';
$('#collagesView').hidden = v!=='collages';
$('#count').style.display = v==='grid' ? '' : 'none';
if (v==='collages') buildCollages();
}
document.querySelector('.tabs').onclick = (e) => { const v=e.target.dataset.v; if(v) setView(v); };
fetch('/api/signatures').then(r=>r.json()).then(d => { DATA=d; buildChips(); render(); setView(prefs.view); });
</script>
</body>
</html>