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:
+91
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user