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.
406 lines
16 KiB
JavaScript
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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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();
|
|
})();
|