← back to Ventura Corridor

public/cover-picker.html

172 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cover picker · The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;1,400;1,500&family=Inter:wght@200;300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#888475;--metal:#b89968;--metal-glow:#d4b683;--green:#6a9b73;--red:#b66565;--rule:#2a2622}
*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300}
header{padding:30px 32px 16px;border-bottom:1px solid var(--rule)}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:32px;margin:0}
header h1 em{color:var(--metal)}
header .sub{font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute);margin-top:4px}
nav{padding:10px 32px;border-bottom:1px solid var(--rule);display:flex;gap:12px;flex-wrap:wrap;font-size:9px;letter-spacing:.26em;text-transform:uppercase}
nav a{color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)}
nav a:hover, nav a.active{color:var(--metal);border-color:var(--metal)}

main{max-width:1100px;margin:0 auto;padding:32px}
.row{display:grid;grid-template-columns:1.2fr 1fr;gap:32px;align-items:start}
.preview{background:var(--noir-rise);border:1px solid var(--rule);overflow:hidden;position:relative}
.preview img{width:100%;height:auto;display:block}
.preview .none{padding:120px 24px;text-align:center;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:22px}
.preview .overlay{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,0.7) 100%);pointer-events:none}
.preview .meta{position:absolute;left:0;right:0;bottom:0;padding:24px;color:var(--ink)}
.preview .meta .h{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:30px;line-height:1.05;margin:0 0 4px}
.preview .meta .b{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:rgba(240,236,226,0.65)}

.panel{background:var(--noir-rise);border:1px solid var(--rule);padding:24px}
.panel h3{font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--metal);font-size:24px;margin:0 0 16px;font-weight:500}
.field{margin-bottom:18px}
.field label{display:block;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:6px;font-weight:500}
.field input, .field select, .field textarea{width:100%;background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:10px 12px;font-family:'Inter',sans-serif;font-size:14px}
.field input[type=file]{padding:8px;border-style:dashed}
.field textarea{font-family:'Cormorant Garamond',serif;font-style:italic;min-height:80px;resize:vertical;font-size:15px}
.field .hint{font-size:11px;color:var(--ink-mute);margin-top:4px;font-style:italic;font-family:'Cormorant Garamond',serif}
.actions{display:flex;gap:8px;margin-top:24px;flex-wrap:wrap}
.actions button{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);background:transparent;border:1px solid var(--metal);padding:10px 18px;cursor:pointer;font-family:Inter,sans-serif;font-weight:500}
.actions button:hover{background:var(--metal);color:var(--noir)}
.actions button.danger{color:var(--red);border-color:var(--red)}
.actions button.danger:hover{background:var(--red);color:var(--noir)}
.toast{margin-top:14px;padding:12px;font-size:13px;border:1px solid var(--rule);font-family:'Cormorant Garamond',serif;font-style:italic;display:none}
.toast.ok{color:var(--green);border-color:var(--green)}
.toast.err{color:var(--red);border-color:var(--red)}
.toast.show{display:block}

.feature-pick{max-height:360px;overflow-y:auto;border:1px solid var(--rule);background:var(--noir);padding:6px}
.feature-pick label{display:block;padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--rule);font-size:13px}
.feature-pick label:hover{background:rgba(184,153,104,0.06)}
.feature-pick label.sel{background:rgba(184,153,104,0.12);color:var(--metal)}
.feature-pick small{color:var(--ink-mute);font-size:11px;display:block;margin-top:2px}
</style>
</head>
<body>
<header><h1>Cover <em>picker</em></h1>
  <div class="sub">monthly issue cover · pick feature, write caption, drop image</div>
</header>
<nav>
  <a href="/today.html">today</a>
  <a href="/magazine.html">magazine</a>
  <a href="/issue">issue</a>
  <a href="/scoreboard.html">scoreboard</a>
  <a href="/cover-picker.html" class="active">cover</a>
</nav>
<main>
<div class="row">
  <div>
    <div id="preview" class="preview"><div class="none">No cover set yet for this month.</div></div>
    <div style="margin-top:14px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-mute)" id="state">—</div>
  </div>
  <div class="panel">
    <h3>This month: <span id="month-label">—</span></h3>

    <div class="field">
      <label>Pick the cover feature</label>
      <input id="search" placeholder="Filter by headline, business, category…" oninput="filterFeats()">
      <div class="feature-pick" id="feats"></div>
      <div class="hint">Click to nominate. Only published features are shown.</div>
    </div>

    <div class="field">
      <label>Cover kicker (optional)</label>
      <input id="kicker" placeholder="On the cover this month" maxlength="80">
    </div>
    <div class="field">
      <label>Cover caption (optional — overrides subhead)</label>
      <textarea id="caption" maxlength="240" placeholder="A single italic sentence for the cover."></textarea>
    </div>

    <div class="field">
      <label>Cover image (jpg / png / webp · max 8MB)</label>
      <input type="file" id="img" accept="image/jpeg,image/png,image/webp">
    </div>

    <div class="actions">
      <button onclick="saveCover()">★ Save cover</button>
      <button onclick="uploadImg()">⬆ Upload image</button>
      <button class="danger" onclick="clearImg()">✕ Remove image</button>
    </div>
    <div id="toast" class="toast"></div>
  </div>
</div>
</main>
<script>
const month = new Date().toISOString().slice(0, 7);
document.getElementById('month-label').textContent = month;
let allFeats = [], picked = null, currentIssue = null;
const $ = id => document.getElementById(id);
function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }

async function loadIssue() {
  const r = await fetch('/api/issues/current').then(r => r.json());
  currentIssue = r.issue || {};
  picked = currentIssue.cover_feature_id ? Number(currentIssue.cover_feature_id) : null;
  $('caption').value = currentIssue.cover_caption || '';
  $('kicker').value = currentIssue.cover_kicker || '';
  renderPreview();
}
async function loadFeats() {
  const d = await fetch('/api/magazine?status=published&limit=300').then(r => r.json());
  allFeats = (d.rows || []).filter(r => r.editorial && r.editorial.length > 200);
  filterFeats();
}
function filterFeats() {
  const q = ($('search').value || '').toLowerCase();
  const rows = q ? allFeats.filter(r => `${r.headline} ${r.name} ${r.category_tag}`.toLowerCase().includes(q)) : allFeats;
  $('feats').innerHTML = rows.slice(0, 80).map(r => `
    <label class="${picked === Number(r.id) ? 'sel' : ''}">
      <input type="radio" name="f" value="${r.id}" ${picked === Number(r.id) ? 'checked' : ''} style="margin-right:8px" onchange="picked=${r.id};renderPreview();this.closest('.feature-pick').querySelectorAll('label').forEach(l=>l.classList.remove('sel'));this.parentElement.classList.add('sel');">
      <b style="font-family:'Cormorant Garamond',serif;font-style:italic">${escHtml(r.headline || r.name)}</b>
      <small>${escHtml(r.name)} · ${escHtml(r.category_tag || '—')}</small>
    </label>`).join('');
}
function renderPreview() {
  const f = allFeats.find(r => Number(r.id) === picked) || (currentIssue.cover_feature_id ? { id: currentIssue.cover_feature_id, headline: currentIssue.cover_headline, name: currentIssue.cover_biz, category_tag: currentIssue.cover_category, subhead: currentIssue.cover_subhead } : null);
  const img = currentIssue.cover_image_path ? `/covers/${currentIssue.cover_image_path}?ts=${Date.now()}` : null;
  if (!f) { $('preview').innerHTML = '<div class="none">No cover set yet for this month.</div>'; $('state').textContent = '—'; return; }
  if (img) {
    $('preview').innerHTML = `<img src="${img}"><div class="overlay"></div><div class="meta"><div class="h">${escHtml(f.headline || f.name)}</div><div class="b">${escHtml(f.name)} · ${escHtml(f.category_tag || '')}</div></div>`;
  } else {
    $('preview').innerHTML = `<div style="padding:60px 32px;text-align:center"><div style="font-size:9px;letter-spacing:.5em;text-transform:uppercase;color:var(--metal);margin-bottom:14px">${escHtml($('kicker').value || 'On the cover this month')}</div><div style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:36px;line-height:1.05;color:var(--ink)">${escHtml(f.headline || f.name)}</div>${(($('caption').value || f.subhead || '') ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--ink-mute);margin:14px 0 6px;font-size:18px">${escHtml($('caption').value || f.subhead || '')}</p>` : '')}<div style="font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-top:14px">${escHtml(f.name)} · ${escHtml(f.category_tag || '')}</div></div>`;
  }
  $('state').textContent = `feature_id=${f.id}${img ? ' · image=' + currentIssue.cover_image_path : ''}`;
}
function toast(msg, ok = true) {
  const t = $('toast'); t.className = `toast show ${ok ? 'ok' : 'err'}`; t.textContent = msg;
  setTimeout(() => t.className = 'toast', 4000);
}
async function saveCover() {
  if (!picked) return toast('pick a feature first', false);
  const r = await fetch(`/api/issues/${month}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cover_feature_id: picked, cover_caption: $('caption').value || null, cover_kicker: $('kicker').value || null }) });
  if (r.ok) { toast(`saved ★ feature ${picked} as ${month} cover`); await loadIssue(); }
  else toast('save failed: ' + (await r.text()), false);
}
async function uploadImg() {
  const f = $('img').files[0];
  if (!f) return toast('pick an image first', false);
  const fd = new FormData(); fd.append('image', f);
  const r = await fetch(`/api/issues/${month}/cover-image`, { method: 'POST', body: fd });
  if (r.ok) { const d = await r.json(); toast(`image uploaded → ${d.image}`); await loadIssue(); }
  else toast('upload failed: ' + (await r.text()), false);
}
async function clearImg() {
  if (!confirm(`Remove cover image for ${month}?`)) return;
  const r = await fetch(`/api/issues/${month}/cover-image`, { method: 'DELETE' });
  if (r.ok) { toast(`image cleared`); await loadIssue(); } else toast('clear failed', false);
}
(async () => { await loadFeats(); await loadIssue(); })();
</script>
</body>
</html>