← back to Ventura Corridor

public/hero.html

216 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>The Corridor · spotlight</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,300;0,400;0,500;0,600;1,300;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:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--accent:#6a3a1a;--rule:#2a2622}
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300;overflow:hidden}

.stage{position:fixed;inset:0;overflow:hidden}
.slide{position:absolute;inset:0;opacity:0;transition:opacity 1500ms cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;justify-content:flex-end;padding:64px 72px 100px;pointer-events:none}
.slide.on{opacity:1;pointer-events:auto}

.slide .img{position:absolute;inset:0;background-size:cover;background-position:center;background-repeat:no-repeat;filter:saturate(0.85) contrast(1.05)}
.slide .grad{position:absolute;inset:0;background:linear-gradient(180deg,rgba(10,10,12,0.25) 0%,rgba(10,10,12,0.55) 45%,rgba(10,10,12,0.95) 100%)}

.slide.no-img{background:radial-gradient(ellipse at top right,rgba(184,153,104,0.18) 0%,transparent 60%),radial-gradient(ellipse at bottom left,rgba(106,58,26,0.22) 0%,transparent 55%),var(--noir)}

.slide .meta{position:relative;z-index:2;max-width:980px}
.slide .kicker{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.5em;text-transform:uppercase;color:var(--metal-glow);margin-bottom:18px;opacity:.92}
.slide h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:clamp(40px,7vw,108px);line-height:.98;margin:0 0 22px;color:#fff;letter-spacing:-0.01em;text-shadow:0 4px 30px rgba(0,0,0,0.5)}
.slide h1 em{font-style:normal;color:var(--metal-glow)}
.slide .sub{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:300;font-size:clamp(18px,1.7vw,28px);line-height:1.4;color:rgba(240,236,226,0.88);max-width:780px;margin-bottom:34px}
.slide .pull{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:clamp(15px,1.4vw,22px);color:var(--metal-glow);border-left:2px solid var(--metal);padding-left:18px;margin:0 0 30px;max-width:680px;line-height:1.4}
.slide .biz{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.32em;text-transform:uppercase;color:rgba(240,236,226,0.7)}
.slide .biz a{color:var(--metal-glow);text-decoration:none;border-bottom:1px solid rgba(212,182,131,0.3);padding-bottom:1px}
.slide .biz a:hover{border-color:var(--metal-glow)}

/* Top frame — masthead + counter */
.frame{position:fixed;top:0;left:0;right:0;z-index:100;display:flex;justify-content:space-between;align-items:baseline;padding:22px 32px;background:linear-gradient(180deg,rgba(10,10,12,0.85) 0%,rgba(10,10,12,0) 100%);pointer-events:none}
.frame .brand{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:22px;color:rgba(240,236,226,0.92);letter-spacing:-0.01em;pointer-events:auto}
.frame .brand em{color:var(--metal-glow);font-style:normal;font-weight:500}
.frame .counter{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:rgba(240,236,226,0.55);pointer-events:auto}
.frame .counter b{color:var(--metal-glow);font-weight:500}

/* Bottom controls */
.dock{position:fixed;bottom:32px;left:0;right:0;z-index:100;display:flex;gap:14px;justify-content:center;align-items:center}
.dock button,.dock a{background:rgba(10,10,12,0.55);backdrop-filter:blur(10px);border:1px solid rgba(184,153,104,0.32);color:rgba(240,236,226,0.85);font-family:Inter,sans-serif;font-size:10px;letter-spacing:.32em;text-transform:uppercase;padding:10px 16px;cursor:pointer;text-decoration:none;transition:all 200ms}
.dock button:hover,.dock a:hover{border-color:var(--metal-glow);color:var(--metal-glow)}
.dock .play{min-width:46px;display:flex;align-items:center;justify-content:center;font-size:14px}
.dock .dots{display:flex;gap:6px;align-items:center;padding:0 8px}
.dock .dot{width:7px;height:7px;border-radius:50%;background:rgba(240,236,226,0.2);transition:all 200ms;cursor:pointer}
.dock .dot.on{background:var(--metal-glow);box-shadow:0 0 12px rgba(212,182,131,0.6)}

/* Progress bar — drains as the slide advances */
.progress{position:fixed;top:0;left:0;right:0;height:2px;background:transparent;z-index:101}
.progress .fill{height:100%;background:linear-gradient(90deg,var(--metal),var(--metal-glow));width:0;transition:width linear}

@media (max-width:760px){
  .slide{padding:120px 28px 110px}
  .frame{padding:14px 18px}
  .frame .brand{font-size:18px}
  .dock{flex-wrap:wrap;padding:0 16px}
}
</style>
</head>
<body>
<div class="frame">
  <div class="brand">The <em>Corridor</em> · spotlight</div>
  <div class="counter"><b id="curr">—</b> / <span id="total">—</span></div>
</div>

<div class="progress"><div class="fill" id="fill"></div></div>

<div class="stage" id="stage"></div>

<div class="dock">
  <button id="prev" title="previous">←</button>
  <button class="play" id="play" title="pause / play">⏸</button>
  <button id="next" title="next">→</button>
  <div class="dots" id="dots"></div>
  <a href="/issue" title="read the full issue">★ issue</a>
  <a href="/today.html" title="admin">⚙</a>
</div>

<script>
const SLIDE_MS = 9000;
const FETCH_LIMIT = 12;
let slides = [];
let idx = 0;
let timer = null;
let playing = true;
let progressStart = 0;

function escHtml(s){return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}

function renderSlide(f, hasImg) {
  const headline = escHtml(f.headline || f.biz_name || '—');
  const sub = escHtml(f.subhead || '');
  const pull = f.pull_quote ? `<div class="pull">&ldquo;${escHtml(f.pull_quote)}&rdquo;</div>` : '';
  const biz = escHtml(f.biz_name || '');
  const city = escHtml(f.city || '');
  const cat = escHtml(f.category_tag || '');
  const imgUrl = f.cover_image_path ? `/covers/${escHtml(f.cover_image_path)}` : null;

  const node = document.createElement('div');
  node.className = 'slide' + (imgUrl ? '' : ' no-img');
  node.innerHTML = `
    ${imgUrl ? `<div class="img" style="background-image:url('${imgUrl}')"></div><div class="grad"></div>` : ''}
    <div class="meta">
      <div class="kicker">${cat || 'feature'} · the corridor</div>
      <h1>${headline}</h1>
      ${sub ? `<div class="sub">${sub}</div>` : ''}
      ${pull}
      <div class="biz">${biz}${city ? ' · ' + city : ''} &nbsp;·&nbsp; <a href="/magazine/${f.id}">read the full feature →</a></div>
    </div>
  `;
  return node;
}

function show(n) {
  if (!slides.length) return;
  idx = ((n % slides.length) + slides.length) % slides.length;
  document.querySelectorAll('.slide').forEach((el, i) => el.classList.toggle('on', i === idx));
  document.querySelectorAll('.dot').forEach((el, i) => el.classList.toggle('on', i === idx));
  document.getElementById('curr').textContent = String(idx + 1).padStart(2, '0');
  // Reset progress
  const fill = document.getElementById('fill');
  fill.style.transition = 'none';
  fill.style.width = '0%';
  if (playing) {
    requestAnimationFrame(() => {
      fill.style.transition = `width ${SLIDE_MS}ms linear`;
      fill.style.width = '100%';
    });
  }
}

function next() { show(idx + 1); }
function prev() { show(idx - 1); }

function startTimer() { if (timer) clearInterval(timer); timer = setInterval(next, SLIDE_MS); }
function stopTimer() { if (timer) clearInterval(timer); timer = null; }

document.getElementById('next').addEventListener('click', () => { next(); if (playing) startTimer(); });
document.getElementById('prev').addEventListener('click', () => { prev(); if (playing) startTimer(); });
document.getElementById('play').addEventListener('click', () => {
  playing = !playing;
  document.getElementById('play').textContent = playing ? '⏸' : '▶';
  if (playing) { startTimer(); show(idx); } else { stopTimer(); document.getElementById('fill').style.transition = 'none'; document.getElementById('fill').style.width = '0%'; }
});
window.addEventListener('keydown', e => {
  if (e.key === 'ArrowRight') { next(); if (playing) startTimer(); }
  else if (e.key === 'ArrowLeft') { prev(); if (playing) startTimer(); }
  else if (e.key === ' ') { e.preventDefault(); document.getElementById('play').click(); }
});

async function load() {
  // Pull a random sample of published features. Fall back to lottery if /today not available.
  let feats = [];
  try {
    const r = await fetch(`/api/magazine/lottery?n=${FETCH_LIMIT}`).then(r => r.json());
    feats = r.features || [];
  } catch {}
  if (!feats.length) {
    try {
      const r = await fetch('/api/magazine?status=published&limit=20').then(r => r.json());
      feats = (r.rows || []).filter(x => x.editorial && x.editorial.length > 100).slice(0, FETCH_LIMIT);
      // Map shape to lottery's
      feats = feats.map(x => ({
        id: x.id, headline: x.headline, subhead: x.subhead, pull_quote: x.pull_quote,
        category_tag: x.category_tag, biz_name: x.name, city: x.city
      }));
    } catch {}
  }
  // Pull current month's cover so it can lead off w/ image, if available
  try {
    const ci = await fetch('/api/issues/current').then(r => r.json());
    const issue = ci?.issue;
    if (issue?.cover_image_path && issue?.cover_feature_id) {
      // Move cover to front, attach image_path
      const idx = feats.findIndex(f => Number(f.id) === Number(issue.cover_feature_id));
      const cover = idx >= 0 ? feats.splice(idx, 1)[0] : null;
      if (cover) {
        cover.cover_image_path = issue.cover_image_path;
        feats.unshift(cover);
      }
    }
  } catch {}

  slides = feats;
  if (!slides.length) {
    document.getElementById('stage').innerHTML = `<div class="slide on no-img"><div class="meta"><div class="kicker">no published features yet</div><h1>The <em>Corridor</em></h1><div class="sub">Publish a feature on <a style="color:var(--metal-glow)" href="/magazine.html">/magazine.html</a> and it will appear here.</div></div></div>`;
    document.getElementById('total').textContent = '0';
    document.getElementById('curr').textContent = '00';
    return;
  }

  const stage = document.getElementById('stage');
  stage.innerHTML = '';
  slides.forEach(f => stage.appendChild(renderSlide(f)));

  const dots = document.getElementById('dots');
  dots.innerHTML = '';
  slides.forEach((_, i) => {
    const d = document.createElement('div');
    d.className = 'dot' + (i === 0 ? ' on' : '');
    d.addEventListener('click', () => { show(i); if (playing) startTimer(); });
    dots.appendChild(d);
  });

  document.getElementById('total').textContent = String(slides.length).padStart(2, '0');
  show(0);
  startTimer();
}

load();
// Re-load every 5 min so newly published features rotate in
setInterval(load, 5 * 60_000);
</script>
</body>
</html>