c.stupid.cat/js/terminal.js

428 lines
18 KiB
JavaScript

/* =====================================================================
* terminal.js — the homepage's interactive terminal.
*
* Flow: a short boot log streams in, the side chrome fades in alongside
* it, then the banner + a pinned prompt appear. You type a command (or a
* social's name) and the output is appended to the scrollback BELOW the
* input — the input itself never moves.
* ===================================================================== */
(function terminal() {
const root = document.getElementById("terminal");
if (!root) return;
// ---- socials (keyword -> destination) ----------------------------------
const SOCIALS = {
gitgay: { label: "Git.Gay", sub: "@doughmination", url: "https://git.gay/doughmination", aliases: ["git.gay", "gitea", "github", "git"] },
twitter: { label: "Twitter", sub: "@DoughminCEO", url: "https://x.com/DoughminCEO", aliases: ["x"] },
bluesky: { label: "Bluesky", sub: "@doughmination.win", url: "https://bsky.app/profile/doughmination.win", aliases: ["bsky"] },
linkedin: { label: "LinkedIn", sub: "Clove Twilight", url: "https://www.linkedin.com/in/estrogen/" },
spotify: { label: "Spotify", sub: "doughmination", url: "https://open.spotify.com/user/x060f5w4ftwv8zc8fi9662t70" },
discord: { label: "Discord", sub: "Doughmination", url: "https://discord.gg/YtJayCYEw5" },
twitch: { label: "Twitch", sub: "@doughminationgaming", url: "https://www.twitch.tv/doughminationgaming" },
reddit: { label: "Reddit", sub: "u/XerinDotZero", url: "https://www.reddit.com/user/XerinDotZero/" },
youtube: { label: "YouTube", sub: "@CloveTwiGaming", url: "https://www.youtube.com/@CloveTwiGaming", aliases: ["yt"] },
mastodon: { label: "Mastodon", sub: "@doughmination@mastodon.social", url: "https://mastodon.social/@doughmination" },
email: { label: "Email", sub: "admin@doughmination.win", url: "mailto:admin@doughmination.win", aliases: ["mail"] },
portfolio: { label: "Portfolio", sub: "doughmination.co.uk", url: "https://doughmination.co.uk/", aliases: ["website", "site"] }
};
const ALIASES = {};
Object.keys(SOCIALS).forEach((k) => {
(SOCIALS[k].aliases || []).forEach((a) => { ALIASES[a] = k; });
});
// keyword -> svg filename in /assets/socials
const SOCIAL_ICON = {
github: "github", gitgay: "git-gay", twitter: "twitter", bluesky: "bluesky",
linkedin: "linkedin", spotify: "spotify", discord: "discord", twitch: "twitch",
reddit: "reddit", youtube: "youtube", mastodon: "mastodon", email: "email",
company: "site", portfolio: "site"
};
function iconImg(key) {
return '<img class="t-social-ic" src="/assets/socials/' + (SOCIAL_ICON[key] || "site") + '.svg" alt="">';
}
let cache = null;
async function checkDomain(subdomain) {
if (!cache) {
const response = await fetch("https://raw.is-a.dev/v2.json");
cache = await response.json();
}
return cache.some((d) => d.subdomain === subdomain);
}
// arch.ascii (hyfetch format) is fetched once at startup for `hyfetch`.
let archLines = null;
function loadArt() {
fetch("/arch.ascii").then(function (r) { return r.ok ? r.text() : ""; }).then(function (t) {
if (!t) return;
var lines = t.replace(/\r/g, "").split("\n");
if (lines[0] && lines[0].trim().charAt(0) === "{") lines.shift();
lines = lines.map(function (l) { return l.replace(/\$\{c\d\}/g, ""); });
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
archLines = lines;
}).catch(function () { });
}
// ---- ascii banner -------------------------------------------------------
const BANNER = [
" ██████╗██╗ ██████╗ ██╗ ██╗███████╗",
"██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝",
"██║ ██║ ██║ ██║██║ ██║█████╗ ",
"██║ ██║ ██║ ██║╚██╗ ██╔╝██╔══╝ ",
"╚██████╗███████╗╚██████╔╝ ╚████╔╝ ███████╗",
" ╚═════╝╚══════╝ ╚═════╝ ╚═══╝ ╚══════╝"
].join("\n");
// ---- boot log -----------------------------------------------------------
const BOOT = [
["info", "starting clovesh..."],
["info", "mounting /dev/estrogen..."],
["ok", "estrogen levels nominal"],
["info", "loading kernel modules (catppuccin)..."],
["ok", "modules loaded"],
["info", "summoning cats..."],
["ok", "oneko ready"],
["info", "connecting to discord via lanyard..."],
["ok", "presence online"],
["info", "mounting button wall..."],
["ok", "88x31 buttons hung"],
["info", "starting terminal..."],
["ok", "ready — type 'help'"]
];
// ---- build DOM ----------------------------------------------------------
root.innerHTML =
'<pre class="t-boot" id="t-boot" aria-hidden="true"></pre>' +
'<div class="t-main" id="t-main" hidden>' +
'<pre class="t-banner">' + esc(BANNER) + "</pre>" +
'<div class="t-greet">Type <b>help</b> for commands, or <b>socials</b> to browse.</div>' +
'<div class="t-inputline">' +
'<span class="t-prompt">arch@arch<span class="t-path">:[~]$</span></span>' +
'<input class="t-input" id="t-input" type="text" aria-label="Terminal command input" autocomplete="off" autocapitalize="off" spellcheck="false">' +
"</div>" +
'<div class="t-output" id="t-output"></div>' +
"</div>";
const bootEl = root.querySelector("#t-boot");
const mainEl = root.querySelector("#t-main");
const input = root.querySelector("#t-input");
const output = root.querySelector("#t-output");
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function stamp() {
return new Date().toLocaleTimeString("en-GB", { hour12: false });
}
// ---- command handlers ---------------------------------------------------
const COMMANDS = {
help() {
const rows = [
["help", "show this list"],
["code", "Shows the website source code"],
["socials", "list all socials"],
["<social>", "show a social & ask to open it (append -open to do directly)"],
["system", "open my system website (append a person's name to open their page)"],
["about", "a little about me"],
["hyfetch", "system info, with flair"]
];
let out = "Available commands:\n";
out += rows.map((r) => " " + r[0].padEnd(12) + r[1]).join("\n");
out += "\n\nTip: type a social's name (try 'socials') to open it.";
return { text: out };
},
ls() {
const rows = [
["help", "show this list"],
["code", "Shows the website source code"],
["socials", "list all socials"],
["<social>", "show a social & ask to open it (append -open to do directly)"],
["system", "open my system website (append a person's name to open their page)"],
["about", "a little about me"],
["hyfetch", "system info, with flair"]
];
let out = "Available commands:\n";
out += rows.map((r) => " " + r[0].padEnd(12) + r[1]).join("\n");
out += "\n\nTip: type a social's name (try 'socials') to open it.";
return { text: out };
},
code() {
window.open("https://git.gay/doughmination/clove-is-a-dev", "_blank");
return { text: "Opening site source code..." }
},
async system(args) {
const who = (args[0] || "").toLowerCase();
if (!who) {
window.open("https://system.doughmination.co.uk/", "_blank");
return { text: "Opening system site..." };
}
try {
const response = await fetch(
`https://system.doughmination.co.uk/api/member/${encodeURIComponent(who)}`
);
if (response.status === 200) {
window.open(`https://system.doughmination.co.uk/${encodeURIComponent(who)}`, "_blank");
return { text: `Opening ${who}'s profile...` };
}
if (response.status === 404) {
return { text: "That person doesn't exist." };
}
if (response.status === 502) {
return { text: "The server is currently having issues." };
}
return { text: `Unexpected response (${response.status}).` };
} catch (error) {
return { text: "Failed to contact the server." };
}
},
socials() {
const items = Object.keys(SOCIALS)
.map((k) => '<span class="t-ls-item">' + esc(k) + "</span>").join("");
return {
html: '<div class="t-ls">' + items + "</div>" +
'<div class="t-dim t-ls-foot">type one to view it, or <b>&lt;name&gt; -open</b> to open</div>'
};
},
about() {
return {
text:
"Clove Twilight — fae/faer\n" +
"Transfem developer from Southampton, UK. I make Discord bots,\n" +
"personal-site nonsense, and run a small corner of the internet\n" +
"under the trade mark 'doughmination system'. Big on Linux, Catppuccin, and cats.\n\n" +
"This site is the beta playground for clove.is-a.dev — expect things\n" +
"to break in funny ways. Type 'socials' to find me elsewhere."
};
},
hyfetch() {
const info = [
'<b class="t-accent">arch</b>@<b class="t-accent">arch</b>',
"-----------------------",
"OS........ Arch Linux x86_64",
"GPU....... AMD ATI SPEEDSTER MERC 310 RX 7900 XTX",
"CPU....... AMD Ryzen 9 9950X3D (8) @ 5.7GHz",
"Host...... B850M AORUS ELITE WIFI6E ICE -CF-WCP-ADO",
"Kernel.... 7.0.11-arch1-1",
"Shell..... bash 5.3.12",
"Theme..... Breeze-Dark [GTK2/3]",
"Pronouns.. fae/faer",
"Uptime.... " + uptime(),
].join("\n");
if (!archLines || !archLines.length) {
return { html: '<pre class="hf-info">' + info + "</pre>" };
}
const colors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
const n = archLines.length;
const logo = archLines.map(function (ln, i) {
const c = colors[Math.min(colors.length - 1, Math.floor((i / n) * colors.length))];
return '<span style="color:' + c + '">' + esc(ln) + "</span>";
}).join("\n");
return {
html: '<div class="hf"><pre class="hf-logo">' + logo + "</pre>" +
'<pre class="hf-info">' + info + "</pre></div>"
};
},
async isadotdev(parts) {
const arg = (parts[0] || "").toLowerCase().replace(/^https?:\/\//, "").replace(/\/+$/, "");
if (!arg || !arg.endsWith(".is-a.dev")) {
return { text: "usage: isadotdev <subdomain>.is-a.dev", error: true };
}
const sub = arg.replace(/\.is-a\.dev$/, "");
if (["clove", "doughmination"].indexOf(sub) >= 0) {
return { text: "nice try 👀", error: true };
}
showResult({ text: "checking " + arg + "…" });
let found;
try { found = await checkDomain(sub); }
catch (e) { return { text: "couldn't reach the is-a.dev registry — try again later.", error: true }; }
if (!found) return { text: arg + " isn't registered on is-a.dev.", error: true };
window.open("https://" + arg, "_blank", "noopener");
return { html: 'opening <b class="t-accent">' + esc(arg) + "</b> …" };
},
};
// ---- runtime ------------------------------------------------------------
const startedAt = Date.now();
function uptime() {
let s = Math.floor((Date.now() - startedAt) / 1000);
const h = Math.floor(s / 3600); s -= h * 3600;
const m = Math.floor(s / 60); s -= m * 60;
const parts = [];
if (h) parts.push(h + "h");
if (m) parts.push(m + "m");
parts.push(s + "s");
return parts.join(" ");
}
const history = [];
let histIdx = -1;
let pendingSocial = null;
function showResult(result) {
output.innerHTML = "";
if (!result) return;
const box = document.createElement("div");
box.className = "t-result";
if (result.error) box.classList.add("is-error");
if (result.html != null) box.innerHTML = result.html;
else if (result.text != null) box.textContent = result.text;
output.appendChild(box);
output.scrollTop = 0;
}
function runCommand(fn, args) {
let r;
try { r = fn(args); }
catch (e) { showResult({ text: "error running that command.", error: true }); return; }
if (r && typeof r.then === "function") {
r.then(showResult).catch(function () { showResult({ text: "something went wrong.", error: true }); });
} else {
showResult(r);
}
}
function run(raw) {
const cmd = raw.trim();
output.innerHTML = "";
if (!cmd) { pendingSocial = null; return; }
history.push(cmd); histIdx = history.length;
const parts = cmd.split(/\s+/);
const name = parts[0].toLowerCase();
const flags = parts.slice(1).map((p) => p.toLowerCase());
const wantsOpen = flags.indexOf("-open") >= 0 || flags.indexOf("--open") >= 0 || flags.indexOf("-o") >= 0;
if (pendingSocial) {
if (["y", "yes", "open", "o"].indexOf(name) >= 0) { openSocial(pendingSocial); return; }
if (["n", "no"].indexOf(name) >= 0) { pendingSocial = null; showResult({ text: "okay, leaving it closed." }); return; }
pendingSocial = null;
}
const socialKey = resolveSocial(name === "open" ? flags[0] : name);
if (socialKey) {
if (wantsOpen || name === "open") openSocial(socialKey);
else promptSocial(socialKey);
return;
}
if (name.endsWith(".is-a.dev")) {
runCommand(COMMANDS.isadotdev, [name]);
return;
}
if (COMMANDS[name]) { runCommand(COMMANDS[name], parts.slice(1)); return; }
showResult({ text: "clovesh: command not found: " + name + "\nType 'help' for a list, or 'socials' to browse.", error: true });
}
function promptSocial(key) {
const s = SOCIALS[key];
pendingSocial = key;
showResult({
html:
'<div class="t-social-card">' +
'<div class="t-sc-head">' + iconImg(key) +
'<span><b class="t-accent">' + esc(s.label) + "</b> " +
'<span class="t-dim">' + esc(s.sub) + "</span></span></div>" +
'<a class="t-sc-url" href="' + esc(s.url) + '"' +
(s.url.startsWith("mailto:") ? "" : ' target="_blank" rel="noopener"') + ">" + esc(s.url) + "</a>" +
'<div class="t-sc-ask t-dim">open it? type <b>y</b> · or run <b>' + esc(key) + " -open</b> · <b>n</b> to cancel</div>" +
"</div>"
});
}
function resolveSocial(key) {
if (!key) return null;
if (SOCIALS[key]) return key;
if (ALIASES[key]) return ALIASES[key];
return null;
}
function openSocial(key) {
pendingSocial = null;
const s = SOCIALS[key];
showResult({
html: '<a class="t-social-open" href="' + esc(s.url) + '"' +
(s.url.startsWith("mailto:") ? "" : ' target="_blank" rel="noopener"') + ">" +
iconImg(key) + "opening <b class=\"t-accent\">" + esc(s.label) + "</b> " +
'<span class="t-dim">' + esc(s.url) + "</span> …</a>"
});
if (s.url.startsWith("mailto:")) { window.location.href = s.url; }
else { window.open(s.url, "_blank", "noopener"); }
}
// ---- tab-complete + history --------------------------------------------
const COMPLETIONS = Object.keys(COMMANDS).concat(["open", "socials", "isadotdev"], Object.keys(SOCIALS), Object.keys(ALIASES));
function complete(prefix) {
if (!prefix) return null;
const hits = COMPLETIONS.filter((c) => c.indexOf(prefix) === 0);
if (hits.length === 1) return hits[0];
return null;
}
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const v = input.value;
input.value = "";
run(v);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (histIdx > 0) { histIdx--; input.value = history[histIdx] || ""; moveCaretEnd(); }
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (histIdx < history.length - 1) { histIdx++; input.value = history[histIdx] || ""; }
else { histIdx = history.length; input.value = ""; }
moveCaretEnd();
} else if (e.key === "Tab") {
e.preventDefault();
const c = complete(input.value.trim().toLowerCase());
if (c) input.value = c;
}
});
function moveCaretEnd() {
requestAnimationFrame(() => { input.selectionStart = input.selectionEnd = input.value.length; });
}
root.addEventListener("click", () => {
if ((window.getSelection() + "") === "") input.focus();
});
// ---- boot then reveal ---------------------------------------------------
document.body.classList.add("term-booting");
let booted = false;
function finishBoot() {
if (booted) return;
booted = true;
bootEl.hidden = true;
mainEl.hidden = false;
document.body.classList.remove("term-booting");
document.body.classList.add("term-ready");
input.focus();
}
function streamBoot(i) {
if (booted) return;
if (i >= BOOT.length) { setTimeout(finishBoot, 350); return; }
const [kind, msg] = BOOT[i];
const tag = kind === "ok"
? '<span class="b-ok"> OK </span>'
: '<span class="b-info"> INFO </span>';
bootEl.insertAdjacentHTML("beforeend",
'<span class="b-line">[<span class="b-time">' + stamp() + "</span>] [" + tag + "] " + esc(msg) + "</span>\n");
bootEl.scrollTop = bootEl.scrollHeight;
setTimeout(() => streamBoot(i + 1), 120 + Math.random() * 120);
}
function skipHandler(e) {
if (e.type === "keydown" || e.type === "click") finishBoot();
}
document.addEventListener("keydown", skipHandler, { once: false });
loadArt();
requestAnimationFrame(() => document.body.classList.add("term-chrome-in"));
streamBoot(0);
})();