← back to Ventura Corridor
public/magazine.html
550 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ventura Corridor — the magazine</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;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
:root {
--paper: #faf6ee;
--ink: #1a1815;
--ink-mute: #6e6356;
--metal: #8a6d3b;
--metal-glow: #b89968;
--accent: #6a3a1a;
--rule: #d8cdb8;
--serif: 'Cormorant Garamond', 'Times New Roman', serif;
--sans: 'Inter', -apple-system, system-ui, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
}
html, body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-weight: 300; min-height: 100vh; }
* { box-sizing: border-box; }
/* Masthead */
header.masthead {
text-align: center;
padding: 56px 24px 32px;
border-bottom: 4px double var(--ink);
background: var(--paper);
}
header.masthead .kicker {
font-family: var(--sans); font-weight: 500;
font-size: 10px; letter-spacing: 0.4em; text-transform: uppercase;
color: var(--metal); margin-bottom: 14px;
}
header.masthead h1 {
font-family: var(--serif); font-style: italic; font-weight: 500;
font-size: 72px; line-height: 0.95; margin: 0; letter-spacing: -0.02em;
color: var(--ink);
}
header.masthead h1 em { font-style: normal; color: var(--metal); }
header.masthead .deck {
font-family: var(--serif); font-weight: 300; font-style: italic;
font-size: 17px; color: var(--ink-mute); margin-top: 14px;
max-width: 540px; margin-left: auto; margin-right: auto; line-height: 1.5;
}
header.masthead .issue {
margin-top: 20px;
font-family: var(--mono); font-size: 9px; letter-spacing: 0.3em;
text-transform: uppercase; color: var(--ink-mute);
}
/* Navigation rail (admin links — collapsed by default) */
nav.admin-rail {
background: rgba(0,0,0,0.04);
padding: 10px 24px;
text-align: center;
border-bottom: 1px solid var(--rule);
}
nav.admin-rail a, nav.admin-rail span {
font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase;
color: var(--ink-mute); margin: 0 8px; text-decoration: none;
}
nav.admin-rail a:hover { color: var(--metal); }
/* Filter chips */
.chips {
display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;
padding: 18px 24px; border-bottom: 1px solid var(--rule);
}
.chips button {
background: transparent; border: 1px solid var(--rule);
color: var(--ink-mute); font-size: 9px; letter-spacing: 0.22em;
text-transform: uppercase; padding: 5px 12px; cursor: pointer;
font-family: var(--sans);
}
.chips button.active { color: var(--ink); border-color: var(--metal); background: rgba(184,153,104,0.08); }
.chips button:hover { color: var(--ink); border-color: var(--metal); }
/* Magazine grid */
main.spread {
max-width: 1240px; margin: 0 auto; padding: 32px 24px 80px;
display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 36px;
}
article.feature {
background: var(--paper);
padding: 8px 0;
position: relative;
break-inside: avoid;
}
article.feature .photo-frame {
aspect-ratio: 4/3;
background:
repeating-linear-gradient(45deg, rgba(0,0,0,0.04) 0 8px, transparent 8px 16px),
linear-gradient(135deg, #c8b894 0%, #8a7754 100%);
border: 1px solid var(--rule);
display: flex; align-items: center; justify-content: center;
color: var(--paper);
font-family: var(--serif); font-style: italic; font-size: 18px;
margin-bottom: 14px;
position: relative;
overflow: hidden;
}
/* Per-category mood gradients — distinct identity per vertical, no external imagery */
article.feature .photo-frame.cat-restaurant { background: linear-gradient(135deg, #8a3a1a 0%, #c47b5a 60%, #f4d6b8 100%); }
article.feature .photo-frame.cat-restaurant::before { content:'🍴'; position:absolute; right:14px; bottom:14px; font-size:48px; opacity:.18; z-index:1 }
article.feature .photo-frame.cat-professional { background: linear-gradient(135deg, #2d3a4a 0%, #5a6c7d 50%, #8a9ba8 100%); }
article.feature .photo-frame.cat-professional::before { content:'⚖'; position:absolute; right:14px; bottom:14px; font-size:54px; opacity:.18; z-index:1 }
article.feature .photo-frame.cat-medical { background: linear-gradient(135deg, #2a5a4a 0%, #5a8a7a 50%, #b0d4c4 100%); }
article.feature .photo-frame.cat-medical::before { content:'+'; position:absolute; right:18px; bottom:6px; font-size:140px; opacity:.10; z-index:1; font-weight:300; line-height:1 }
article.feature .photo-frame.cat-beauty { background: linear-gradient(135deg, #6a3a5a 0%, #a76a8a 50%, #e8c4d4 100%); }
article.feature .photo-frame.cat-beauty::before { content:'✦'; position:absolute; right:14px; bottom:14px; font-size:54px; opacity:.20; z-index:1 }
article.feature .photo-frame.cat-salon { background: linear-gradient(135deg, #5a2a4a 0%, #8a5a7a 50%, #d4a4c4 100%); }
article.feature .photo-frame.cat-salon::before { content:'§'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.18; z-index:1; font-style:italic }
article.feature .photo-frame.cat-fitness { background: linear-gradient(135deg, #2a3a1a 0%, #5a7a3a 50%, #a4c47a 100%); }
article.feature .photo-frame.cat-fitness::before { content:'△'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.20; z-index:1 }
article.feature .photo-frame.cat-shop { background: linear-gradient(135deg, #4a2a1a 0%, #8a5a3a 50%, #d4a48a 100%); }
article.feature .photo-frame.cat-shop::before { content:'◊'; position:absolute; right:14px; bottom:14px; font-size:54px; opacity:.20; z-index:1 }
article.feature .photo-frame.cat-real-estate { background: linear-gradient(135deg, #1a3a4a 0%, #3a6a8a 50%, #8aa4c4 100%); }
article.feature .photo-frame.cat-real-estate::before { content:'◮'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.18; z-index:1 }
article.feature .photo-frame.cat-hospitality { background: linear-gradient(135deg, #4a3a1a 0%, #8a6a3a 50%, #d4b48a 100%); }
article.feature .photo-frame.cat-hospitality::before { content:'※'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.20; z-index:1 }
article.feature .photo-frame.cat-automotive { background: linear-gradient(135deg, #2a2a2a 0%, #5a5a5a 50%, #a4a4a4 100%); }
article.feature .photo-frame.cat-automotive::before { content:'⊙'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.20; z-index:1 }
article.feature .photo-frame .placeholder-text {
text-align: center; padding: 12px; opacity: 0.85;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
font-size: 13px; letter-spacing: 0.05em;
}
article.feature .photo-frame::after {
content: '';
position: absolute; inset: 0;
background: linear-gradient(180deg, transparent 60%, rgba(0,0,0,0.4));
}
article.feature .photo-frame .cat-tag {
position: absolute; top: 12px; left: 12px;
background: var(--ink); color: var(--paper);
padding: 4px 10px; font-size: 8px; letter-spacing: 0.3em;
text-transform: uppercase; font-family: var(--sans); font-weight: 500;
z-index: 2;
}
article.feature h2 {
font-family: var(--serif); font-weight: 500; font-style: italic;
font-size: 30px; line-height: 1.05; margin: 0 0 8px;
letter-spacing: -0.01em;
color: var(--ink);
}
article.feature .subhead {
font-family: var(--serif); font-weight: 300; font-style: italic;
font-size: 16px; color: var(--ink-mute); margin: 0 0 16px;
line-height: 1.4;
}
article.feature .lede {
font-family: var(--serif); font-weight: 400;
font-size: 16px; line-height: 1.65;
color: var(--ink); margin: 0 0 16px;
}
article.feature .lede::first-letter {
font-family: var(--serif); font-weight: 600;
font-size: 52px; line-height: 0.9; float: left;
margin: 4px 8px 0 0; color: var(--accent);
}
article.feature .pull {
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 19px; line-height: 1.35;
color: var(--accent);
border-left: 2px solid var(--metal);
padding: 8px 0 8px 16px;
margin: 14px 0;
}
article.feature footer {
margin-top: 16px; padding-top: 12px;
border-top: 1px solid var(--rule);
display: flex; justify-content: space-between; align-items: baseline;
gap: 12px; flex-wrap: wrap;
}
article.feature footer .biz {
font-family: var(--sans); font-weight: 500; font-size: 11px;
color: var(--ink); letter-spacing: 0.02em;
}
article.feature footer .addr {
font-family: var(--mono); font-size: 9px; letter-spacing: 0.08em;
color: var(--ink-mute);
}
article.feature footer .actions {
display: flex; gap: 6px;
}
article.feature footer .actions button {
background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
font-size: 8px; letter-spacing: 0.22em; text-transform: uppercase;
padding: 3px 9px; cursor: pointer; font-family: var(--sans);
}
article.feature footer .actions button:hover { color: var(--metal); border-color: var(--metal); }
article.feature footer .actions button.publish { color: var(--accent); border-color: var(--accent); }
article.feature footer .actions button.regen { color: var(--metal); }
article.feature.s-published { border-top: 3px solid var(--accent); padding-top: 14px; }
article.feature.s-spiked { opacity: 0.4; }
.empty { text-align: center; padding: 80px 24px; color: var(--ink-mute); font-style: italic; font-family: var(--serif); font-size: 22px; }
.loading { text-align: center; padding: 60px; color: var(--ink-mute); font-size: 11px; letter-spacing: .26em; text-transform: uppercase; }
/* Print */
@media print {
nav.admin-rail, .chips, article.feature footer .actions { display: none; }
article.feature { break-inside: avoid; page-break-inside: avoid; }
}
</style>
</head>
<body>
<header class="masthead">
<div class="kicker">Volume I · Spring Issue · 2026</div>
<h1>The <em>Corridor</em></h1>
<div class="deck">A magazine of who's here. The corridor's restaurants, professionals, ateliers, and ateliers-disguised-as-storefronts — written warm.</div>
<div class="issue" id="issue-stat">— · — · —</div>
</header>
<nav class="admin-rail">
<span style="color:var(--ink-mute)">admin:</span>
<a href="/today.html">today</a>
<a href="/pitches.html">pitches</a>
<a href="/buildings.html">buildings</a>
<a href="/walk-route.html">walk route</a>
<a href="/responses.html">responses</a>
<a href="/postcards.html">postcards</a>
<a href="/magazine.html" style="color:var(--metal-glow);font-weight:500">magazine</a>
<a href="/issue" style="color:var(--accent)">★ public issue</a>
<a href="/scoreboard.html">🏆 scoreboard</a>
<a href="/search.html">🔍 search</a>
<a href="/rate-card.html">💵 rate card</a>
<a href="/crawl-derby.html">🏇 derby</a>
</nav>
<div class="chips">
<button data-status="all" class="active">All features</button>
<button data-status="draft">⚙ Drafts</button>
<button data-status="reviewed">✓ Reviewed</button>
<button data-status="published">★ Published</button>
</div>
<div class="chips" id="cat-chips" style="border-bottom:1px solid var(--rule);padding-top:0">
<span style="color:var(--ink-mute);font-size:9px;letter-spacing:.22em;text-transform:uppercase;align-self:center;margin-right:8px">vertical →</span>
<!-- chips injected by JS -->
</div>
<div class="chips" style="padding-top:0;border-bottom:1px solid var(--rule);align-items:center">
<span style="color:var(--ink-mute);font-size:9px;letter-spacing:.22em;text-transform:uppercase;align-self:center;margin-right:8px">candidates →</span>
<button id="ads-only-btn" onclick="toggleAdsOnly()">$ paid-ad runners only</button>
<input id="search-box" type="search" placeholder="search businesses, headlines…" oninput="onSearch()" style="background:transparent;border:1px solid var(--rule);color:var(--ink);padding:5px 12px;font-size:11px;font-family:var(--sans);min-width:240px;margin-left:auto">
<button onclick="toggleView()" id="view-btn">📑 Table of contents</button>
<button onclick="reviewDrafts()" style="border:1px solid var(--metal);color:var(--metal)">⚙→✓ Review drafts</button>
<button onclick="publishReviewed()" style="border:1px solid var(--metal-glow);color:var(--metal-glow)">★ Publish reviewed</button>
<button id="since-last-btn" onclick="toggleSinceLast()" title="Show only features generated since you last opened this page">🆕 since last</button>
<a href="/issue" target="_blank" style="display:inline-block;background:transparent;border:1px solid var(--accent);color:var(--accent);font-size:9px;letter-spacing:.22em;text-transform:uppercase;padding:5px 12px;text-decoration:none;font-family:var(--sans)">↗ Public issue</a>
</div>
<main class="spread" id="spread">
<div class="loading">Loading the issue…</div>
</main>
<script>
function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
let currentStatus = 'all';
let currentCat = 'all';
let adsOnly = false;
let searchTerm = '';
let viewMode = 'cards'; // 'cards' | 'toc'
let sinceLast = false;
const SINCE_LAST_KEY = 'corridor-magazine-last-seen';
const SINCE_LAST_MARK_AT = Date.now();
const _lastSeenAt = (() => { try { return parseInt(localStorage.getItem(SINCE_LAST_KEY) || '0', 10); } catch { return 0; } })();
// Stamp current visit at end-of-load so "since last" filters anything older than the PREVIOUS visit.
function stampSeen() { try { localStorage.setItem(SINCE_LAST_KEY, String(SINCE_LAST_MARK_AT)); } catch {} }
let _searchT;
function onSearch() {
clearTimeout(_searchT);
_searchT = setTimeout(() => { searchTerm = document.getElementById('search-box').value.trim().toLowerCase(); load(); }, 200);
}
function toggleView() {
viewMode = viewMode === 'cards' ? 'toc' : 'cards';
document.getElementById('view-btn').textContent = viewMode === 'cards' ? '📑 Table of contents' : '📰 Card view';
load();
}
function toggleAdsOnly() {
adsOnly = !adsOnly;
const btn = document.getElementById('ads-only-btn');
btn.classList.toggle('active', adsOnly);
load();
}
function toggleSinceLast() {
sinceLast = !sinceLast;
const btn = document.getElementById('since-last-btn');
btn.classList.toggle('active', sinceLast);
load();
}
const CATS = [
{ id: 'all', label: 'All' },
{ id: 'restaurant', label: '🍴 Restaurants' },
{ id: 'professional', label: '⚖️ Professional' },
{ id: 'beauty', label: '✨ Beauty' },
{ id: 'salon', label: '💇 Salon' },
{ id: 'medical', label: '🩺 Medical' },
{ id: 'fitness', label: '💪 Fitness' },
{ id: 'shop', label: '🛍 Shops' },
{ id: 'real-estate', label: '🏠 Real estate' },
{ id: 'hospitality', label: '🏨 Hospitality' },
{ id: 'automotive', label: '🚗 Automotive' },
];
function renderCatChips() {
const root = document.getElementById('cat-chips');
// keep the leading label span if it's already there
const lead = root.querySelector('span') ? root.querySelector('span').outerHTML : '';
root.innerHTML = lead + CATS.map(c =>
`<button data-cat="${c.id}" class="${currentCat === c.id ? 'active' : ''}">${c.label}</button>`
).join('');
root.querySelectorAll('button').forEach(b => {
b.addEventListener('click', () => { currentCat = b.dataset.cat; renderCatChips(); load(); });
});
}
renderCatChips();
async function load() {
try {
const params = new URLSearchParams();
if (currentStatus !== 'all') params.set('status', currentStatus);
if (currentCat !== 'all') params.set('cat', currentCat);
const url = params.toString() ? '/api/magazine?' + params : '/api/magazine';
const data = await fetch(url).then(r => r.json());
let rows = data.rows || [];
if (adsOnly) rows = rows.filter(r => Number(r.paid_ads_count || 0) > 0);
if (sinceLast && _lastSeenAt) {
rows = rows.filter(r => new Date(r.generated_at).getTime() > _lastSeenAt);
}
if (searchTerm) {
rows = rows.filter(r => {
const hay = ((r.name || '') + ' ' + (r.headline || '') + ' ' + (r.editorial || '') + ' ' + (r.address || '') + ' ' + (r.city || '') + ' ' + (r.naics || '')).toLowerCase();
return hay.includes(searchTerm);
});
}
const counts = data.by_status || [];
const total = counts.reduce((a, x) => a + Number(x.n), 0);
const draftN = (counts.find(x => x.status === 'draft') || {}).n || 0;
const pubN = (counts.find(x => x.status === 'published') || {}).n || 0;
document.getElementById('issue-stat').textContent =
`${total} features · ${draftN} drafts awaiting review · ${pubN} published`;
const main = document.getElementById('spread');
if (!rows.length) {
main.innerHTML = `<div class="empty">No features yet.<br><br><span style="font-size:14px;font-style:normal;color:var(--metal)">Click "+ Generate 5 more" to seed the issue.</span></div>`;
return;
}
if (viewMode === 'toc') {
// Group by category, sorted alphabetically; show TOC list
const byCat = {};
for (const r of rows) {
const c = r.category_tag || 'uncategorized';
(byCat[c] = byCat[c] || []).push(r);
}
const cats = Object.keys(byCat).sort();
main.style.display = 'block';
main.style.maxWidth = '720px';
main.style.margin = '0 auto';
main.innerHTML = cats.map(cat => `
<div style="margin-bottom:36px">
<h3 style="font-family:var(--serif);font-style:italic;font-weight:500;font-size:24px;color:var(--metal);border-bottom:1px solid var(--rule);padding-bottom:6px;margin:0 0 14px;text-transform:capitalize">${escHtml(cat.replace(/-/g, ' '))} <span style="color:var(--ink-mute);font-style:normal;font-size:12px;letter-spacing:.2em;margin-left:10px">${byCat[cat].length}</span></h3>
${byCat[cat].sort((a,b) => (a.name || '').localeCompare(b.name || '')).map(r => `
<a href="/magazine/${r.id}" style="display:grid;grid-template-columns:1fr auto;gap:14px;padding:7px 0;border-bottom:1px dotted var(--rule);text-decoration:none;color:var(--ink);font-size:14px">
<div>
<span style="font-family:var(--serif);font-style:italic;font-size:18px;color:var(--ink)">${escHtml(r.headline || r.name)}</span>
<span style="color:var(--ink-mute);font-size:11px;margin-left:8px">${escHtml(r.name)}${r.paid_ads_count > 0 ? ` <span style="color:var(--accent);font-weight:500">· $</span>` : ''}</span>
</div>
<span style="color:var(--ink-mute);font-family:var(--mono);font-size:10px;align-self:center">${escHtml((r.address || '').split(' ').slice(0, 2).join(' '))}</span>
</a>
`).join('')}
</div>
`).join('');
return;
}
main.style.display = '';
main.style.maxWidth = '';
main.style.margin = '';
main.innerHTML = rows.map(r => {
const cat = (r.category_tag || 'shop').toUpperCase();
const adsCount = Number(r.paid_ads_count || 0);
const adsBadge = adsCount > 0
? `<span style="position:absolute;top:12px;right:12px;background:var(--accent);color:var(--paper);padding:4px 10px;font-size:8px;letter-spacing:.3em;text-transform:uppercase;font-family:'Inter',sans-serif;font-weight:500;z-index:2" title="${adsCount} paid-ad pixel${adsCount===1?'':'s'} detected — proven ad buyer, candidate sponsored placement">$ ${adsCount}× ads</span>`
: '';
return `
<article class="feature s-${r.status}" data-id="${r.id}">
<div class="photo-frame cat-${escHtml(r.category_tag || 'shop')}">
<span class="cat-tag">${escHtml(cat)}</span>
${adsBadge}
<div class="placeholder-text" style="z-index:2;position:relative">${escHtml(r.name)}</div>
</div>
<h2><a href="/magazine/${r.id}" style="color:inherit;text-decoration:none">${escHtml(r.headline || '(untitled)')}</a></h2>
<div class="subhead">${escHtml(r.subhead || '')}</div>
<p class="lede">${escHtml(r.editorial || '')}</p>
${r.pull_quote ? `<div class="pull">"${escHtml(r.pull_quote)}"</div>` : ''}
<div style="display:flex;gap:14px;font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.1em;color:var(--ink-mute);margin-top:10px;border-top:1px dotted var(--rule);padding-top:8px">
<span title="Generated">⏱ ${new Date(r.generated_at).toLocaleDateString('en-US',{month:'short',day:'numeric'})}</span>
${r.views > 0 ? `<span title="Reader-page views" style="color:var(--metal)">👁 ${r.views}</span>` : ''}
${r.taps > 0 ? `<span title="Outbound taps (share/sponsor/etc)" style="color:var(--metal-glow)">↗ ${r.taps}</span>` : ''}
${r.editorial ? `<span title="Editorial length">${r.editorial.length}c</span>` : ''}
<span style="color:var(--ink-mute)">${escHtml(r.model || '')}</span>
</div>
<details class="margin-note" ${r.notes ? 'open' : ''} style="margin-top:10px;font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);border-top:1px dotted var(--rule);padding-top:8px">
<summary style="cursor:pointer;font-family:'Inter',sans-serif;font-style:normal;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:${r.notes ? 'var(--metal)' : 'var(--ink-mute)'};font-weight:500;outline:none">${r.notes ? '✎ note' : '✎ add note'}</summary>
<textarea data-feature-id="${r.id}" oninput="this.dataset.dirty='1'" onblur="saveNote(this)" placeholder="margin note (private editor's note)" rows="2" style="width:100%;margin-top:6px;background:transparent;color:var(--ink);border:1px solid var(--rule);padding:6px 8px;font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;resize:vertical">${escHtml(r.notes || '')}</textarea>
</details>
<footer>
<div>
<div class="biz">${escHtml(r.name)}</div>
<div class="addr">${escHtml(r.address || '')} · ${escHtml(r.city || '')}</div>
</div>
<div class="actions">
${r.status === 'draft' ? `<button onclick="advance(${r.id}, 'reviewed')">✓ Mark reviewed</button>` : ''}
${r.status === 'reviewed' ? `<button class="publish" onclick="advance(${r.id}, 'published')">★ Publish</button>` : ''}
${r.status === 'published' ? `<button onclick="setCover(${r.id})" style="border:1px solid var(--metal-glow);color:var(--metal-glow)" title="Set as this month's cover">⛧ Cover</button>` : ''}
${r.status !== 'spiked' ? `<button onclick="advance(${r.id}, 'spiked')">✕ Spike</button>` : `<button onclick="advance(${r.id}, 'draft')">↶ Restore</button>`}
<button class="regen" onclick="regen(${r.id})">↻ Regen</button>
</div>
</footer>
</article>`;
}).join('');
// Mark seen AFTER first successful render so the badge reflects truly-new gen on next visit.
if (!window._stamped) { stampSeen(); window._stamped = true; }
// Update the "since last" button label with the unseen count
const sinceBtn = document.getElementById('since-last-btn');
if (sinceBtn && _lastSeenAt) {
const newCount = (data.rows || []).filter(r => new Date(r.generated_at).getTime() > _lastSeenAt).length;
sinceBtn.textContent = newCount > 0 ? `🆕 since last (${newCount})` : '🆕 since last';
sinceBtn.style.color = newCount > 0 ? 'var(--metal-glow)' : '';
sinceBtn.style.borderColor = newCount > 0 ? 'var(--metal-glow)' : '';
}
} catch (e) {
document.getElementById('spread').innerHTML = `<div class="empty">load failed: ${e.message}</div>`;
}
}
async function advance(id, status) {
try {
const r = await fetch('/api/magazine/' + id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!r.ok) throw new Error(await r.text());
load();
} catch (e) { alert('failed: ' + e.message); }
}
async function saveNote(el) {
if (el.dataset.dirty !== '1') return;
const id = Number(el.dataset.featureId);
try {
const r = await fetch('/api/magazine/' + id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: el.value || '' })
});
if (!r.ok) throw new Error(await r.text());
el.dataset.dirty = '0';
const summary = el.parentElement.querySelector('summary');
if (summary) {
summary.style.color = el.value ? 'var(--metal)' : 'var(--ink-mute)';
summary.textContent = el.value ? '✎ note' : '✎ add note';
}
} catch (e) { console.error('saveNote', e); }
}
async function setCover(id) {
try {
const month = new Date().toISOString().slice(0, 7);
const caption = prompt(`Cover for ${month} → feature ${id}\n\nOptional cover-page caption (leave blank to use the feature's subhead):`, '') || null;
const r = await fetch(`/api/issues/${month}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cover_feature_id: id, cover_caption: caption, cover_kicker: 'On the cover this month' })
});
if (!r.ok) throw new Error(await r.text());
alert(`Set as cover for ${month}. View at /issue.`);
} catch (e) { alert('failed: ' + e.message); }
}
async function regen(featureId) {
if (!confirm('Regenerate via Mac1 Ollama? Takes ~30s.')) return;
// Find the article + show a spinner state on its regen button
const art = document.querySelector(`article.feature[data-id="${featureId}"]`);
if (art) {
const btn = art.querySelector('button.regen');
if (btn) { btn.textContent = '⟳ regenerating…'; btn.disabled = true; }
}
try {
const r = await fetch('/api/magazine/' + featureId + '/regen', { method: 'POST' });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
console.log('regen ok in', d.ms, 'ms →', d.headline);
load();
} catch (e) {
alert('Regen failed: ' + e.message);
if (art) {
const btn = art.querySelector('button.regen');
if (btn) { btn.textContent = '↻ Regen'; btn.disabled = false; }
}
}
}
async function generateMore() {
alert('Run in terminal:\n\nnpm run magazine:gen 5\n\nTakes ~2 minutes for 5 features. Then click any chip to refresh.');
}
async function publishReviewed() {
if (!confirm('Publish ALL features currently in "reviewed" status? They will appear on the public /issue page.')) return;
try {
const r = await fetch('/api/magazine/publish-reviewed', { method: 'POST' });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
alert(`Published ${d.published} feature${d.published === 1 ? '' : 's'} → /issue`);
load();
} catch (e) { alert('Publish failed: ' + e.message); }
}
async function reviewDrafts() {
const cat = currentCat === 'all' ? null : currentCat;
const scopeMsg = cat ? `category "${cat}"` : 'ALL categories';
if (!confirm(`Move drafts → reviewed in ${scopeMsg}? Only features with editorial ≥120 chars qualify.`)) return;
try {
const r = await fetch('/api/magazine/review-drafts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cat, min_len: 120 })
});
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
alert(`Reviewed ${d.reviewed} feature${d.reviewed === 1 ? '' : 's'} (${d.scope})`);
load();
} catch (e) { alert('Review failed: ' + e.message); }
}
document.querySelectorAll('.chips button[data-status]').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.chips button[data-status]').forEach(x => x.classList.remove('active'));
b.classList.add('active');
currentStatus = b.dataset.status;
load();
});
});
load();
</script>
</body>
</html>