Release notes + roadmap: /whats-new page, Overview What's new card, Admin > Releases editor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-14 04:54:43 -05:00
parent f7b911bab6
commit b3523e9dc4
10 changed files with 171 additions and 9 deletions
+21 -2
View File
@@ -30,8 +30,9 @@ const traffic = require('./traffic'); // public page views by referring domain (
const promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box)
const blog = require('./blog');
const adminMember = require('./adminmember');
const syndicate = require('./syndicate'); // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog']);
const syndicate = require('./syndicate');
const releases = require('./releases'); // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog', '/whats-new']);
let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute)
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
@@ -365,6 +366,7 @@ async function boot() {
adminMember.init({ accounts, ads, chain, tank, legacy, promos, messages, dataDir: DATA_DIR });
loadOpenTokens();
syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR });
releases.init({ dataDir: DATA_DIR });
console.log('blog syndication:', syndicate.enabled() ? 'on (X + Instagram via Blotato)' : 'off (no blotato.key)');
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
@@ -1211,6 +1213,22 @@ const server = http.createServer(async (req, res) => {
console.log('admin removed account', e);
return json(res, 200, { ok: true });
}
// -- release notes + roadmap (public read; admin write) (Marty, 2026-09-14)
if (p === '/api/releases' && req.method === 'GET') return json(res, 200, releases.publicView());
if (p === '/api/admin/releases' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { notes: releases.notes(), roadmap: releases.roadmap(), tags: releases.TAGS, statuses: releases.STATUSES });
}
if (p === '/api/admin/releases' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = b.kind === 'roadmap' ? releases.saveRoadmap(b) : releases.saveNote(b);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/releases' && req.method === 'DELETE') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, releases.remove(u.searchParams.get('kind'), String(u.searchParams.get('id') || '')));
}
// -- admin: blog (list all incl. drafts, save/create, delete) (Marty, 2026-09-12)
if (p === '/api/admin/blog' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
@@ -2396,6 +2414,7 @@ const server = http.createServer(async (req, res) => {
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }));
return res.end(blog.renderIndex(posts, pg, tag));
}
if (p === '/whats-new') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=120' })); return res.end(releases.renderPage()); }
if (p === '/blog/feed.xml') { res.writeHead(200, baseHeaders({ 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=900' })); return res.end(blog.rss(await blog.listPublished())); }
if (p === '/sitemap.xml') { res.writeHead(200, baseHeaders({ 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600' })); return res.end(blog.sitemap(await blog.listPublished())); }
if (p === '/robots.txt') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' })); return res.end(blog.robots()); }