sites/randogoth.com/bbs/static/bbs.js
randogoth 4238af304e fix: key threads on the canonical URL, and space the header box
SITEURL is empty in this config, so rendering the thread key into the template
produced a relative path. Threads would have been keyed on /pastfetch.html and
orphaned the moment the site moved or gained a prefix. The page computes
location.origin + location.pathname instead, which is unambiguous and needs no
configuration.

The fill in the REPLIES header ran straight into the count, which read as
'────0 messages'. Every other box on the board keeps a space there.
2026-10-06 18:14:23 +03:00

406 lines
16 KiB
JavaScript

var BBS = {};
/* One ANSI ramp per session, and the dark/paper switch.
*
* Boards rotated their welcome screens; this draws a palette instead. It is
* held in sessionStorage so it stays put while you read and changes when you
* come back, rather than flickering on every page load.
*
* Every dark ramp is three of the sixteen EGA indices, ordered dark to bright,
* and none of them bottom out on the frame blue, which would dissolve the
* logo's feet into the chrome. Without JS the CSS defaults apply and nothing
* is lost.
*
* On paper the ordering reverses: presence is ink rather than light, so the
* feet are the palest step and the crown the darkest, which keeps the weight
* where the dark version puts it. None of them start at the frame tone, for
* the same reason as above.
*
* This runs from <head>, before first paint, so neither the ramp nor the
* theme flashes. Dark is the default and the no-JS result; paper is opt in
* and remembered in localStorage rather than per session, because it is a
* preference and not a welcome screen.
*/
(function () {
var RAMPS = [
['#0000aa', '#00aaaa', '#55ffff'], // ice
['#0000aa', '#00aaaa', '#ffffff'], // ice/white
['#00aaaa', '#55ffff', '#ffffff'], // shallow
['#555555', '#00aaaa', '#55ffff'], // slate cyan
['#00aa00', '#55ff55', '#ffffff'], // moss
['#555555', '#00aa00', '#55ff55'], // slate green
['#00aa00', '#00aaaa', '#55ffff'], // verdigris
['#aa0000', '#aa5500', '#ffff55'], // ember
['#aa0000', '#ff5555', '#ffff55'], // fire
['#aa5500', '#ff5555', '#ffff55'], // rust
['#aa5500', '#ffff55', '#ffffff'], // amber
['#555555', '#aaaaaa', '#ffffff'], // mono
['#555555', '#aaaaaa', '#55ffff'], // cold mono
['#aa00aa', '#ff55ff', '#ffffff'], // violet
['#aa0000', '#aa00aa', '#ff55ff'] // sunset
];
/* The paper ramps, pale to dark, off the same warm grey scale as bbs.css. */
var PAPER = [
['#887a64', '#655c50', '#38342e'], // paper
['#887a64', '#534d43', '#1e1c19'], // ink
['#756b5b', '#534d43', '#2b2824'], // umber
['#756b5b', '#47423a', '#1e1c19'], // strong
['#655c50', '#47423a', '#2b2824'], // close
['#887a64', '#756b5b', '#534d43'], // soft
['#534d43', '#38342e', '#1e1c19'], // heavy
['#38342e', '#655c50', '#887a64'], // sunlit, the one that runs the other way
['#a0917c', '#756b5b', '#2b2824'] // wide
];
var i;
try {
i = sessionStorage.getItem('ramp');
if (i === null) {
i = Math.floor(Math.random() * RAMPS.length);
sessionStorage.setItem('ramp', i);
}
} catch (e) {
i = Math.floor(Math.random() * RAMPS.length);
}
BBS.ramp = function (theme) {
var set = theme === 'light' ? PAPER : RAMPS;
var r = set[i % set.length];
var s = document.documentElement.style;
s.setProperty('--l1', r[0]);
s.setProperty('--l2', r[1]);
s.setProperty('--l3', r[2]);
};
var t;
try { t = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
BBS.ramp(t === 'light' ? 'light' : 'dark');
})();
/* The prompt accepts commands.
*
* Numbers mean different things depending on the screen, which is how a board
* worked: at the main menu they select an area, inside an area they select a
* message. Nothing has to disambiguate because no two things on one screen
* claim the same key. Anything typeable carries data-key and the chrome that
* marks it, so the brackets are a promise rather than decoration.
*/
(function () {
// The script sits in <head> so the ramp applies before first paint; the
// command handler has to wait for a DOM to read its keys out of.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
var buf = '', timer;
var out = document.getElementById('typed');
if (!out) return;
var hint = document.createElement('span');
hint.className = 'hint';
out.parentNode.insertBefore(hint, out.nextSibling.nextSibling);
function keys() {
var m = {};
[].forEach.call(document.querySelectorAll('[data-key]'), function (a) {
m[String(a.dataset.key).trim().toUpperCase()] = {
href: a.getAttribute('href'),
cmd: a.dataset.cmd,
label: a.textContent.trim().replace(/\s+/g, ' ').slice(0, 44)
};
});
return m;
}
var MAP = keys();
// Echo what the number is pointing at, so a pause is informative rather than
// just a delay: you can see it is right and wait, keep typing, or hit Enter.
function show() {
out.textContent = buf;
var hit = MAP[buf];
hint.textContent = hit ? ' ' + hit.label : '';
}
function go(k) {
var hit = MAP[k];
if (!hit) return false;
if (hit.cmd === 'theme') { flip(); return true; }
location.href = hit.href;
return true;
}
/* Dark or paper, as a command rather than a button: a board had no widgets,
it had a letter you typed. The entry names where you are going, so it is
[L]ight on a dark screen and [D]ark on a light one, and the key moves with
the label. Injected rather than written into the template, so a reader
without JS is not offered a switch that cannot work. */
var root = document.documentElement;
var slot = document.getElementById('themecmd');
function theme() {
return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
function draw() {
if (!slot) return;
var next = theme() === 'light' ? 'dark' : 'light';
var k = next.charAt(0).toUpperCase();
slot.innerHTML = '<a href="#" data-cmd="theme" data-key="' + k +
'"><span class="key">[<b>' + k + '</b>]</span>' + next.slice(1) + '</a>';
slot.firstChild.addEventListener('click', function (e) {
e.preventDefault();
flip();
});
MAP = keys();
}
function flip() {
var next = theme() === 'light' ? 'dark' : 'light';
if (next === 'light') root.setAttribute('data-theme', 'light');
else root.removeAttribute('data-theme');
try { localStorage.setItem('theme', next); } catch (e) { /* private mode */ }
BBS.ramp(next);
draw();
}
draw();
addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
// Until the reply form existed there was nothing on the board to type
// into, so every letter was a command. Now `i` in a message box would
// navigate to the index mid-sentence.
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
var k = e.key;
clearTimeout(timer);
if (k === 'Enter') {
if (buf && !go(buf)) { out.textContent = ''; }
buf = ''; show(); e.preventDefault(); return;
}
if (k === 'Backspace') { buf = buf.slice(0, -1); show(); e.preventDefault(); return; }
if (k === 'Escape') { buf = ''; show(); e.preventDefault(); return; }
if (/^[0-9]$/.test(k)) {
buf += k;
show();
clearTimeout(timer);
var longer = Object.keys(MAP).some(function (x) {
return x.length > buf.length && x.indexOf(buf) === 0;
});
if (!longer) {
// Nothing longer can match, so settle it now: go, or drop what was
// typed so a mistyped number never sticks at the prompt.
if (MAP[buf]) { go(buf); } else { buf = ''; show(); }
} else if (MAP[buf]) {
// Ambiguous: "1" is an area and "12" is an entry further down the same
// screen. Terminal menus settled this on a short pause rather than
// making every selection need Enter, so this does too.
// 1200ms, because the message index runs past 40 and reaching for a
// second digit takes longer than a confirmation would. Enter commits
// at once for anyone who does not want to wait.
timer = setTimeout(function () { go(buf); }, 1200);
}
e.preventDefault();
return;
}
if (/^[a-zA-Z]$/.test(k)) {
if (go(k.toUpperCase())) e.preventDefault();
}
});
}
})();
/* The address is stored rot13 so it is not sitting in the HTML for a scraper to
* lift. Same trick as tobias.raayoni-last.com. Without JS the fallback text
* still reads, spelled out, so nothing is lost but the click.
*/
(function () {
function rot13(s) {
return s.replace(/[a-zA-Z]/g, function (c) {
return String.fromCharCode((c <= 'Z' ? 90 : 122) >= (c = c.charCodeAt(0) + 13) ? c : c - 26);
});
}
function init() {
[].forEach.call(document.querySelectorAll('.mail[data-r]'), function (el) {
var a = document.createElement('a');
a.href = 'mailto:' + rot13(el.dataset.r);
a.textContent = rot13(el.dataset.r);
el.replaceWith(a);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
/* Replies.
*
* A message base is a thing people answer, and until now this one could only be
* read. The comments come from a remark42 instance which serves three sites
* from one database; everything below talks to its API directly rather than
* loading its widget, because the widget is a React app in an iframe and would
* arrive with its own fonts, spacing and avatars. None of that can be made to
* look like 80 columns of CP437, and an iframe cannot inherit the page's font
* in any case.
*
* The rules are drawn the way the guestbook draws them, padded to exactly 80
* cells, so a reply is indistinguishable from any other row on the board.
*
* Auth is anonymous and the token comes back in a header rather than a cookie,
* which is what lets this work from another origin at all: a cross-site cookie
* would be blocked outright by Safari and Firefox.
*/
(function () {
var W = 80;
function init() {
var box = document.getElementById('replies');
if (!box) return;
var host = box.dataset.host, site = box.dataset.site;
// The thread key is the canonical page URL, taken from the page rather than
// rendered in: SITEURL is empty in this config, which would key every
// thread on a relative path and break the moment the site moved.
var url = location.origin + location.pathname;
var token = null;
function esc(s) {
return String(s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
function fill(n) { return n > 0 ? new Array(n + 1).join('─') : ''; }
function span(cls, text) { return '<span class="' + cls + '">' + esc(text) + '</span>'; }
/* `── 06 oct 26 · 14:09 ──`, lower case, as the listings elsewhere do it. */
function stamp(iso) {
var d = new Date(iso);
if (isNaN(d)) return '';
var mon = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'][d.getMonth()];
function p(n) { return (n < 10 ? '0' : '') + n; }
return p(d.getDate()) + ' ' + mon + ' ' + String(d.getFullYear()).slice(2) +
' · ' + p(d.getHours()) + ':' + p(d.getMinutes());
}
/* One rule, padded so the visible width is exactly W. */
function rule(n, who, when) {
var num = (' ' + n).slice(-3) + ' ';
var left = '──[' + num + ']── ' + who;
var right = ' ' + when + ' ──';
var gap = W - left.length - right.length;
if (gap < 1) { // a very long handle: trim it, never overflow
who = who.slice(0, Math.max(1, who.length + gap - 1)) + '…';
left = '──[' + num + ']── ' + who;
gap = W - left.length - right.length;
}
return '<pre class="gb-rule">' + span('box', '──[') + span('count', num) +
span('box', ']── ') + span('area', who) + span('box', fill(gap)) +
span('date', ' ' + when + ' ') + span('box', '──') + '</pre>';
}
function header(count) {
var title = ' REPLIES ';
var tail = count === 1 ? '1 message ' : count + ' messages ';
var left = '┌─' + title;
var right = ' ' + tail + '─┐';
return '<pre class="box">' + esc(left) + fill(W - left.length - right.length) + esc(right) + '</pre>';
}
function render(list) {
var html = header(list.length);
list.forEach(function (c, i) {
html += rule(i + 1, c.user.name, stamp(c.time));
html += '<div class="gb-body">' + c.text + '</div>';
});
if (!list.length) {
html += '<div class="gb-body">No replies yet. Be the first.</div>';
}
box.innerHTML = html + form();
wire();
}
function form() {
return '<pre class="rule">' + fill(W) + '</pre>' +
'<form class="gb-form" id="replyform">' +
'<div class="gb-row"><label for="rh">Handle</label>' +
'<input id="rh" name="handle" maxlength="24" required placeholder="anonymous"></div>' +
'<div class="gb-row"><label for="rt">Message</label>' +
'<textarea id="rt" name="text" rows="4" required></textarea></div>' +
'<div class="gb-row"><label></label>' +
'<button type="submit">' + span('key', '[') + '<b>P</b>' + span('key', ']') + 'ost</button>' +
' <span class="hint" id="rs"></span></div></form>';
}
function say(msg) { var s = document.getElementById('rs'); if (s) s.textContent = msg; }
function load() {
return fetch(host + '/api/v1/find?site=' + encodeURIComponent(site) +
'&url=' + encodeURIComponent(url) + '&format=tree')
.then(function (r) { return r.json(); })
.then(function (d) {
render((d.comments || []).map(function (n) { return n.comment; }));
})
.catch(function () { box.innerHTML = header(0) +
'<div class="gb-body">The message base is unreachable.</div>'; });
}
/* Anonymous login hands the JWT back in a header; we keep it ourselves so
no cookie is involved and nothing is blocked as third-party. */
function login(name) {
return fetch(host + '/auth/anonymous/login?user=' + encodeURIComponent(name) +
'&aud=' + encodeURIComponent(site) + '&site=' + encodeURIComponent(site))
.then(function (r) {
token = r.headers.get('X-JWT');
if (!token) throw new Error('no token');
});
}
function post(name, text) {
// The site goes on the query string as well as in the body: a middleware
// reads it from there, and without it an authenticated request is
// refused outright.
return fetch(host + '/api/v1/comment?site=' + encodeURIComponent(site), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-JWT': token },
body: JSON.stringify({ text: text, locator: { site: site, url: url } })
}).then(function (r) {
if (!r.ok) throw new Error(r.status);
});
}
function wire() {
var f = document.getElementById('replyform');
if (!f) return;
f.addEventListener('submit', function (e) {
e.preventDefault();
var name = (f.handle.value || 'anonymous').trim();
var text = f.text.value.trim();
if (!text) return;
// remark42 wants at least three characters, letters, numbers,
// underscores and spaces only. Fail here rather than at the server.
if (name.length < 3 || /[^A-Za-z0-9_ ]/.test(name)) {
say('handle: 3+ characters, letters numbers _ and spaces'); return;
}
say('posting…');
login(name).then(function () { return post(name, text); })
.then(function () { say(''); return load(); })
.catch(function () { say('could not post, try again'); });
});
}
// P puts the cursor in the message box. The bracket chrome is a promise
// everywhere else on this board, so it has to be one here too.
addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'p' || e.key === 'P') {
var ta = document.getElementById('rt');
if (ta) { ta.focus(); e.preventDefault(); }
}
});
load();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();