433 lines
18 KiB
JavaScript
433 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="">';
|
|
}
|
|
|
|
// ---- friends (keyword -> who they are) ---------------------------------
|
|
const FRIENDS = {
|
|
ari: { name: "Ari", desc: "🩵 My wifey 🩵 the best 🩵 Her corner of the web:", url: "https://ariare.es", urlLabel: "ariare.es 🩵" },
|
|
saphie: { name: "Saphie", desc: "🩷 Cammy's partner, loves linguistics🩷", url: "" },
|
|
camilla: { name: "Camillia (Cammy)", desc: "🖤 Close friend who shares a passion for coding 🖤", url: "https://cammy-the-cat.com", urlLabel: "cammy-the-cat.com 🖤" },
|
|
ria: { name: "Ria", desc: "🤍 Close friend/platonic daughter who means a lot to me 🤍", url: "" },
|
|
lilly: { name: "Lilly (Lils)", desc: "💖 Pookie, really cool person 💖", url: "" },
|
|
primrose: { name: "Nimnose", desc: "💜 Lil's partner 💜", url: "" },
|
|
fin: { name: "Fin", desc: "💛 Ari's friend who is really nice and who I can be unfilered with 💛", url: "" }
|
|
};
|
|
|
|
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" 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
function stamp() {
|
|
return new Date().toLocaleTimeString("en-GB", { hour12: false });
|
|
}
|
|
|
|
// ---- command handlers ---------------------------------------------------
|
|
const COMMANDS = {
|
|
help() {
|
|
const rows = [
|
|
["help", "show this list"],
|
|
["socials", "list all socials"],
|
|
["<social> [-open]", "show a social & ask to open it (append -open to do directly)"],
|
|
["system [person]", "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"],
|
|
["socials", "list all socials"],
|
|
["<social>", "show a social & ask to open it (append -open to do directly)"],
|
|
["system [person]", "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 };
|
|
},
|
|
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/member/${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><name> -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'. 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);
|
|
})(); |