/* ===================================================================== * 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 ''; } 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 = '' + '"; 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, """); } 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"], ["", "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"], ["", "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://doughmination.co.uk/api/member/${encodeURIComponent(who)}` ); if (response.status === 200) { window.open(`https://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) => '' + esc(k) + "").join(""); return { html: '
' + items + "
" + '
type one to view it, or <name> -open to open
' }; }, 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 = [ 'arch@arch', "-----------------------", "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: '
' + info + "
" }; } 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 '' + esc(ln) + ""; }).join("\n"); return { html: '
" + '
' + info + "
" }; }, async isadotdev(parts) { const arg = (parts[0] || "").toLowerCase().replace(/^https?:\/\//, "").replace(/\/+$/, ""); if (!arg || !arg.endsWith(".is-a.dev")) { return { text: "usage: isadotdev .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 ' + esc(arg) + " …" }; }, }; // ---- 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: '
' + '
' + iconImg(key) + '' + esc(s.label) + " " + '' + esc(s.sub) + "
" + '" + esc(s.url) + "" + '
open it? type y · or run ' + esc(key) + " -open · n to cancel
" + "
" }); } 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: '" + iconImg(key) + "opening " + esc(s.label) + " " + '' + esc(s.url) + "" }); 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" ? ' OK ' : ' INFO '; bootEl.insertAdjacentHTML("beforeend", '[' + stamp() + "] [" + tag + "] " + esc(msg) + "\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); })();