Blog: admin-written coaching articles, server-rendered public /blog with SEO metadata, sitemap, RSS, robots

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-12 18:42:56 -05:00
parent 1a83f4f2ff
commit 49a98e7ee3
24 changed files with 375 additions and 23 deletions
+91 -2
View File
@@ -44,8 +44,8 @@
});
// ── panes ──
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, pnl: loadPnl, settings: loadSettings };
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', blog: 'Blog', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, pnl: loadPnl, settings: loadSettings };
function setPane(name) {
if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
@@ -347,6 +347,95 @@
$('trfAngles').innerHTML = '<tr><th>Angle</th><th>Join-page views</th><th>Signups</th></tr>' + (d.angles.length ? d.angles.map(a => '<tr><td>' + esc(a.angle) + '</td><td>' + n(a.views) + '</td><td>' + n(a.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">No angle data yet.</td></tr>');
$('trfDaily').innerHTML = '<tr><th>Day</th><th>Page views</th><th>Signups</th></tr>' + (d.daily.length ? d.daily.slice().reverse().map(x => '<tr><td>' + esc(x.day) + '</td><td>' + n(x.hits) + '</td><td>' + n(x.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">Nothing yet.</td></tr>');
}
// ── blog: coaching articles, public at /blog (Marty, 2026-09-12) ──
let blCur = null; // slug being edited, or null for a new one
function blCount() {
const t = $('blTitle').value.length, e = $('blExcerpt').value.length;
$('blTitleCount').textContent = t + '/60' + (t > 60 ? ' (long)' : '');
$('blExcCount').textContent = e + ' chars' + (e && (e < 120 || e > 160) ? ' (aim 120-160)' : '');
const w = $('blBody').textContent.trim().split(/\s+/).filter(Boolean).length;
$('blWords').textContent = w + ' words';
}
['blTitle', 'blExcerpt'].forEach(id => $(id).addEventListener('input', blCount));
$('blBody').addEventListener('input', blCount);
$('blTitle').addEventListener('input', () => { if (!blCur && !$('blSlug').dataset.touched) $('blSlug').value = $('blTitle').value.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); });
$('blSlug').addEventListener('input', () => { $('blSlug').dataset.touched = '1'; });
document.querySelectorAll('.ed-bar [data-bl]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand(b.dataset.bl, false, null); }));
document.querySelectorAll('.ed-bar [data-blblock]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand('formatBlock', false, b.dataset.blblock); }));
$('blLinkBtn').addEventListener('click', async () => {
const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
const u = await IAP.ask({ title: 'Link address', label: 'https://', placeholder: 'https://instantadpay.com/join/martbost', ok: 'Insert' });
if (u) { $('blBody').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, u); }
});
$('blImgBtn').addEventListener('click', () => $('blImgFile').click());
$('blImgFile').addEventListener('change', async () => {
const f = $('blImgFile').files[0]; if (!f) return;
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) throw new Error(r.error);
$('blBody').focus();
const html = '<img src="' + r.url + '" alt="">';
if (!document.execCommand('insertHTML', false, html)) $('blBody').insertAdjacentHTML('beforeend', html);
blCount();
} catch (e) { IAP.status(e.message || 'Upload failed.', 'bad'); }
$('blImgFile').value = '';
});
$('blCoverBtn').addEventListener('click', () => $('blCoverFile').click());
$('blCoverFile').addEventListener('change', () => upload($('blCoverFile'), $('blCoverInfo'), $('blCover')));
$('blHtmlBtn').addEventListener('click', () => {
const raw = !$('blHtml').hidden;
if (raw) { $('blBody').innerHTML = $('blHtml').value; $('blHtml').hidden = true; $('blBody').hidden = false; }
else { $('blHtml').value = $('blBody').innerHTML; $('blBody').hidden = true; $('blHtml').hidden = false; }
blCount();
});
function blBodyHtml() { return $('blHtml').hidden ? $('blBody').innerHTML : $('blHtml').value; }
function blMsg(t, bad) { $('blMsg').textContent = t; $('blMsg').hidden = !t; $('blMsg').className = 'small ' + (bad ? 'bad' : 'ok'); }
function blOpen(post) {
blCur = post ? post.slug : null;
$('blogList').hidden = true; $('blogEditor').hidden = false;
$('blEdTitle').textContent = post ? 'Edit article' : 'New article';
$('blEdSub').textContent = post ? (post.status === 'published' ? 'published ' + when(post.publishedAt) + ' · ' + (post.views || 0) + ' views' : 'draft') : '';
$('blTitle').value = post ? post.title : ''; $('blSlug').value = post ? post.slug : ''; delete $('blSlug').dataset.touched;
$('blTags').value = post ? post.tags.join(', ') : ''; $('blExcerpt').value = post ? post.excerpt : ''; $('blCover').value = post ? post.cover : ''; $('blCoverInfo').textContent = '';
$('blHtml').hidden = true; $('blBody').hidden = false; $('blBody').innerHTML = post ? post.body : '';
$('blUnpublish').hidden = !(post && post.status === 'published'); $('blDelete').hidden = !post;
$('blPreview').hidden = !post; if (post) $('blPreview').href = '/blog/' + post.slug;
$('blPublish').textContent = post && post.status === 'published' ? 'Save and publish' : 'Publish';
blMsg(''); blCount(); $('blTitle').focus();
}
async function blSave(status) {
const body = { existingSlug: blCur, title: $('blTitle').value, slug: $('blSlug').value, tags: $('blTags').value, excerpt: $('blExcerpt').value, cover: $('blCover').value, body: blBodyHtml(), status };
const r = await api('/api/admin/blog', body);
blCur = r.post.slug;
$('blSlug').value = r.post.slug; $('blPreview').hidden = false; $('blPreview').href = '/blog/' + r.post.slug; $('blDelete').hidden = false;
$('blUnpublish').hidden = r.post.status !== 'published'; $('blPublish').textContent = r.post.status === 'published' ? 'Save and publish' : 'Publish';
$('blEdTitle').textContent = 'Edit article';
blMsg(r.post.status === 'published' ? 'Published. Live at instantadpay.com/blog/' + r.post.slug : 'Draft saved.');
IAP.status(r.post.status === 'published' ? 'Published.' : 'Draft saved.', 'ok');
}
$('blSaveDraft').addEventListener('click', busy($('blSaveDraft'), () => blSave('draft')));
$('blPublish').addEventListener('click', busy($('blPublish'), () => blSave('published')));
$('blUnpublish').addEventListener('click', busy($('blUnpublish'), () => blSave('draft')));
$('blClose').addEventListener('click', () => { $('blogEditor').hidden = true; $('blogList').hidden = false; loadBlog().catch(e => IAP.status(e.message, 'bad')); });
$('blDelete').addEventListener('click', busy($('blDelete'), async () => {
if (!blCur) return;
if (!await IAP.confirmBox('The page at /blog/' + blCur + ' stops existing. There is no undo.', { title: 'Delete this article?', ok: 'Delete', cancel: 'Keep it' })) return;
await api('/api/admin/blog?slug=' + encodeURIComponent(blCur), undefined, 'DELETE');
$('blClose').click();
}));
$('blNew').addEventListener('click', () => blOpen(null));
async function loadBlog() {
const d = await api('/api/admin/blog');
const pub = d.posts.filter(p => p.status === 'published').length;
$('blSub').textContent = pub + ' published · ' + (d.posts.length - pub) + ' drafts';
$('blTable').innerHTML = '<tr><th>Title</th><th>Status</th><th>Tags</th><th>Views</th><th>Updated</th><th></th></tr>'
+ (d.posts.length ? d.posts.map(p => '<tr><td><b>' + esc(p.title) + '</b><br><span class="muted small">/blog/' + esc(p.slug) + '</span></td><td>' + (p.status === 'published' ? '<span class="chip-t on">published</span>' : '<span class="chip-t">draft</span>') + '</td><td>' + esc(p.tags.join(', ')) + '</td><td>' + (p.views || 0) + '</td><td>' + when(p.updated) + '</td><td class="act"><button type="button" class="btn small sec" data-bledit="' + esc(p.slug) + '">Edit</button> <a class="btn small sec" href="/blog/' + esc(p.slug) + '" target="_blank" rel="noopener">View</a></td></tr>').join('')
: '<tr><td colspan="6" class="muted">No articles yet. Start with "New article".</td></tr>');
$('blTable').querySelectorAll('[data-bledit]').forEach(b => b.addEventListener('click', async () => {
try { const r = await api('/api/admin/blog?slug=' + encodeURIComponent(b.dataset.bledit)); blOpen(r.post); } catch (e) { IAP.status(e.message, 'bad'); }
}));
}
async function loadPnl() {
const r = await api('/api/admin/pnl?days=' + pnlDays);
const px = r.polUsd || 0;
+2
View File
@@ -0,0 +1,2 @@
// public blog pages: the shared nav (with wallet status) and footer, nothing else
(function () { try { IAP.renderNav('blog'); } catch (e) {} })();
+1 -1
View File
@@ -53,7 +53,7 @@ window.IAP = (function () {
f.style.cssText = 'border-top:1px solid var(--line);margin-top:48px;padding:26px 22px;text-align:center;color:var(--muted);font-size:13px';
f.innerHTML = '<div>© ' + new Date().getFullYear() + ' InstantAdPay</div>'
+ '<div style="margin-top:8px;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">'
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a>'
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a><a href="/blog">Blog</a>'
+ '<a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/disclaimer">Disclaimer</a></div>';
document.body.appendChild(f);
}