← 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>