b3523e9dc4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
71 lines
7.6 KiB
JavaScript
71 lines
7.6 KiB
JavaScript
// Release notes + roadmap (Marty, 2026-09-14): what shipped and what is coming, written in Admin > Releases,
|
|
// shown on the public /whats-new page and in a "What's new" card on every member's Overview.
|
|
// Storage: DATA_DIR/releases.json { notes: [{id, date, title, body, tags[]}], roadmap: [{id, title, note, status, eta}] }
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
let FILE = null;
|
|
const SITE = 'https://instantadpay.com';
|
|
const TAGS = ['new', 'improved', 'fixed'];
|
|
const STATUSES = ['planned', 'building', 'done'];
|
|
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
|
|
function init(opts) { FILE = path.join(opts.dataDir, 'releases.json'); }
|
|
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { notes: [], roadmap: [] }; } }
|
|
function save(d) { fs.writeFileSync(FILE, JSON.stringify(d, null, 1)); }
|
|
const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
|
|
// body text: plain lines; a line starting with "- " becomes a bullet; blank line = paragraph break
|
|
function bodyHtml(text) {
|
|
const lines = String(text || '').split(/\r?\n/);
|
|
let out = '', list = false, para = [];
|
|
const flush = () => { if (para.length) { out += '<p>' + esc(para.join(' ')) + '</p>'; para = []; } };
|
|
for (const l of lines) {
|
|
if (/^\s*-\s+/.test(l)) { flush(); if (!list) { out += '<ul>'; list = true; } out += '<li>' + esc(l.replace(/^\s*-\s+/, '')) + '</li>'; continue; }
|
|
if (list) { out += '</ul>'; list = false; }
|
|
if (!l.trim()) { flush(); continue; }
|
|
para.push(l.trim());
|
|
}
|
|
if (list) out += '</ul>'; flush();
|
|
return out;
|
|
}
|
|
|
|
function notes() { return load().notes.slice().sort((a, b) => (b.date || '').localeCompare(a.date || '') || (b.ts || 0) - (a.ts || 0)); }
|
|
function roadmap() { const order = { building: 0, planned: 1, done: 2 }; return load().roadmap.slice().sort((a, b) => (order[a.status] - order[b.status]) || (a.order || 0) - (b.order || 0)); }
|
|
function saveNote(input) {
|
|
const d = load(); const title = String(input.title || '').trim().slice(0, 120); if (!title) return { error: 'Give the note a title.' };
|
|
const date = /^\d{4}-\d{2}-\d{2}$/.test(String(input.date || '')) ? input.date : new Date().toISOString().slice(0, 10);
|
|
const tags = String(input.tags || '').split(',').map(t => t.trim().toLowerCase()).filter(t => TAGS.includes(t));
|
|
const n = { id: input.id && d.notes.find(x => x.id === input.id) ? input.id : newId(), date, title, body: String(input.body || '').slice(0, 4000), tags: tags.length ? tags : ['new'], ts: Date.now() };
|
|
const i = d.notes.findIndex(x => x.id === n.id); if (i >= 0) d.notes[i] = Object.assign({}, d.notes[i], n); else d.notes.push(n);
|
|
save(d); return { ok: true, note: n };
|
|
}
|
|
function saveRoadmap(input) {
|
|
const d = load(); const title = String(input.title || '').trim().slice(0, 120); if (!title) return { error: 'Give the item a title.' };
|
|
const status = STATUSES.includes(input.status) ? input.status : 'planned';
|
|
const r = { id: input.id && d.roadmap.find(x => x.id === input.id) ? input.id : newId(), title, note: String(input.note || '').slice(0, 600), status, eta: String(input.eta || '').slice(0, 40), order: Number(input.order) || d.roadmap.length + 1, ts: Date.now() };
|
|
const i = d.roadmap.findIndex(x => x.id === r.id); if (i >= 0) d.roadmap[i] = Object.assign({}, d.roadmap[i], r); else d.roadmap.push(r);
|
|
save(d); return { ok: true, item: r };
|
|
}
|
|
function remove(kind, id) { const d = load(); const k = kind === 'roadmap' ? 'roadmap' : 'notes'; d[k] = d[k].filter(x => x.id !== id); save(d); return { ok: true }; }
|
|
function publicView() { return { notes: notes().slice(0, 30), roadmap: roadmap().filter(r => r.status !== 'done').slice(0, 12), latest: (notes()[0] || {}).date || null }; }
|
|
|
|
function fmt(d) { try { return new Date(d + 'T12:00:00Z').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } catch (e) { return d; } }
|
|
const tagChip = t => '<span class="tagc tag-' + t + '">' + t + '</span>';
|
|
function renderPage() {
|
|
const v = publicView();
|
|
const desc = 'What changed on InstantAdPay and what is coming next: release notes and the roadmap, updated as things ship.';
|
|
let h = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>What\'s new | InstantAdPay</title>'
|
|
+ '<meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + SITE + '/whats-new"><meta property="og:title" content="What\'s new on InstantAdPay"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:image" content="' + SITE + '/banners/iap-hero-1200x630.png"><meta name="twitter:card" content="summary_large_image">'
|
|
+ '<link rel="icon" type="image/png" href="/logo-icon.png"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"><link rel="stylesheet" href="/assets/site.css?v=20260913a">'
|
|
+ '<style>.wn{max-width:820px}.wn h1{margin:10px 0 6px}.wn .lead{margin:0 0 26px}.note{border-left:3px solid var(--mint);padding:4px 0 4px 18px;margin:0 0 26px}.note h2{font-size:21px;margin:0 0 4px}.note .when{color:var(--muted);font-size:13px;margin:0 0 8px}.note p,.note li{max-width:68ch}.note ul{margin:6px 0 0 20px}.tagc{display:inline-block;font-size:11px;letter-spacing:.08em;text-transform:uppercase;border-radius:999px;padding:2px 9px;margin-left:8px;vertical-align:middle;border:1px solid var(--line);color:var(--muted)}.tag-new{color:var(--mint);border-color:rgba(67,232,195,.5)}.tag-improved{color:#8fd8f0;border-color:rgba(143,216,240,.5)}.tag-fixed{color:#ffd15c;border-color:rgba(255,209,92,.5)}.rm{display:grid;gap:10px;margin:0 0 30px}.rmi{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px}.rmi b{display:block}.rmi span{color:var(--muted);font-size:14px}.st{flex:0 0 auto;font-size:11px;letter-spacing:.08em;text-transform:uppercase;border-radius:999px;padding:3px 10px;border:1px solid var(--line);color:var(--muted);margin-top:3px}.st-building{color:var(--mint);border-color:rgba(67,232,195,.5)}.st-planned{color:#8fd8f0;border-color:rgba(143,216,240,.5)}</style></head><body><div class="wrap wn">'
|
|
+ '<section class="hero" style="padding:56px 0 8px"><p class="eyebrow">Release notes and roadmap</p><h1>What\'s new, <em>and what\'s next</em>.</h1><p class="lead">' + esc(desc) + '</p></section>';
|
|
h += '<h2 style="font-size:24px;margin:0 0 12px">On the roadmap</h2>';
|
|
h += v.roadmap.length ? '<div class="rm">' + v.roadmap.map(r => '<div class="rmi"><span class="st st-' + r.status + '">' + r.status + (r.eta ? ' · ' + esc(r.eta) : '') + '</span><div><b>' + esc(r.title) + '</b>' + (r.note ? '<span>' + esc(r.note) + '</span>' : '') + '</div></div>').join('') + '</div>' : '<p class="muted">Nothing queued right now.</p>';
|
|
h += '<h2 style="font-size:24px;margin:10px 0 16px">Release notes</h2>';
|
|
h += v.notes.length ? v.notes.map(n => '<article class="note" id="' + esc(n.id) + '"><h2>' + esc(n.title) + n.tags.map(tagChip).join('') + '</h2><p class="when">' + fmt(n.date) + '</p>' + bodyHtml(n.body) + '</article>').join('') : '<p class="muted">No notes yet.</p>';
|
|
h += '<p class="muted small" style="margin-top:30px">Suggestions and bug reports: the chat bubble on any page, or the InstantAdPay Telegram group.</p>';
|
|
h += '</div><script src="/assets/common.js?v=20260913a"></script><script src="/assets/blog-page.js?v=20260912a"></script></body></html>';
|
|
return h;
|
|
}
|
|
module.exports = { init, notes, roadmap, saveNote, saveRoadmap, remove, publicView, renderPage, bodyHtml, TAGS, STATUSES };
|