a49603890b
Generalized the doom launcher into launchGame(name) keyed by archive.org identifier; each cds into a fake .<name> folder and runs <name>.sh, same as doom.
404 lines
18 KiB
JavaScript
404 lines
18 KiB
JavaScript
/* header prompt → a tiny playful shell.
|
|
type a command + Enter: known targets navigate, anything else is an
|
|
easter egg (a fake shell error). the menu below is always "open" and
|
|
clickable, so typing is optional. */
|
|
(function () {
|
|
"use strict";
|
|
var bar = document.querySelector(".topbar");
|
|
var input = document.querySelector(".prompt__input");
|
|
var out = document.querySelector(".prompt__out");
|
|
var menu = document.querySelector(".menu");
|
|
var promptEl = document.querySelector(".prompt");
|
|
if (!bar || !input) return;
|
|
|
|
// keep the caret/right end in view: when the line overflows, scroll it fully
|
|
// right so the left (older) part clips off terminal-style instead of wrapping.
|
|
function keepEnd() { if (promptEl) promptEl.scrollLeft = promptEl.scrollWidth; }
|
|
|
|
// the lang-switch (de/en) is position:absolute top-right over the same row.
|
|
// reserving space for it (or clipping the line early to dodge it) wastes
|
|
// room on short lines — simpler to just hide it once a long line would
|
|
// reach it, and let it reappear the moment the line is short again. wired
|
|
// to a MutationObserver below so it re-checks on every change to the
|
|
// prompt text, no matter what set it (ghost, real typing, history, …).
|
|
function syncLangSwitch() {
|
|
var ls = document.querySelector(".lang-switch");
|
|
if (!ls || !promptEl) return;
|
|
var lsRect = ls.getBoundingClientRect();
|
|
var inRect = input.getBoundingClientRect();
|
|
ls.style.visibility = (lsRect.width && inRect.right > lsRect.left - 8) ? "hidden" : "";
|
|
}
|
|
new MutationObserver(syncLangSwitch).observe(input, { characterData: true, childList: true, subtree: true });
|
|
|
|
// hard guarantee: the prompt is ALWAYS one line. CSS (nowrap + overflow:hidden)
|
|
// is normally enough, but a long ghost command (long path + filename) can still
|
|
// force the line to wrap on some viewports/browsers, which then bugs out the
|
|
// layout below it. so on top of the CSS, actually shorten the text in the DOM:
|
|
// clip from the FRONT (oldest part, same side as the terminal-scroll above) and
|
|
// mark the cut with "…" — the leading verb ("cat "/"cd ") is allowed to go first.
|
|
function fitOneLine(text) {
|
|
if (!promptEl) return text;
|
|
promptEl.scrollLeft = 0; // measure from a clean, unscrolled state
|
|
input.textContent = text;
|
|
if (promptEl.scrollWidth <= promptEl.clientWidth) return text;
|
|
var shown = text;
|
|
while (shown.length > 1 && promptEl.scrollWidth > promptEl.clientWidth) {
|
|
shown = shown.slice(1);
|
|
input.textContent = "…" + shown;
|
|
}
|
|
return input.textContent;
|
|
}
|
|
|
|
var home = bar.getAttribute("data-home") || "/";
|
|
|
|
// command → url: the always-open menu, plus any context routes (e.g. the
|
|
// portfolio project slugs, only present in the DOM while inside /portfolio/).
|
|
// rebuilt after each pjax navigation since .routes changes per section.
|
|
var routes = {};
|
|
function buildRoutes() {
|
|
routes = {};
|
|
Array.prototype.forEach.call(document.querySelectorAll(".menu a, .routes a, footer a[data-name], .lang-switch"), function (a) {
|
|
var key = (a.getAttribute("data-name") || a.textContent).trim().toLowerCase().replace(/\/+$/, "");
|
|
if (key) routes[key] = a.getAttribute("href");
|
|
});
|
|
}
|
|
buildRoutes();
|
|
document.addEventListener("pjax:load", buildRoutes);
|
|
|
|
function pwd() {
|
|
var p = document.querySelector(".prompt__path");
|
|
return p ? p.textContent.replace(/^:/, "").replace(/\$$/, "") : "~";
|
|
}
|
|
|
|
function say(msg) { if (out) out.textContent = msg; }
|
|
function navigate(u) { clearGhost(); input.textContent = ""; if (window.pjax) window.pjax(u); else window.location.href = u; }
|
|
|
|
// ghost command: the file browser "types" e.g. `cat 03.webp` into the prompt as
|
|
// you hover/freeze a file, so you always see where you are. it's only a hint —
|
|
// any real keystroke (or navigation) discards it and the line is yours again.
|
|
var ghostActive = false;
|
|
function setGhost(text) {
|
|
if (!ghostActive && input.textContent) return; // never clobber a real command in progress
|
|
fitOneLine(text);
|
|
ghostActive = true;
|
|
input.classList.add("is-ghost");
|
|
caretToEnd();
|
|
}
|
|
function clearGhost() {
|
|
if (!ghostActive) return;
|
|
input.textContent = "";
|
|
ghostActive = false;
|
|
input.classList.remove("is-ghost");
|
|
}
|
|
document.addEventListener("shell:ghost", function (e) { setGhost(e.detail); });
|
|
document.addEventListener("shell:ghost-clear", clearGhost);
|
|
|
|
function pad(n) { return (n < 10 ? "0" : "") + n; }
|
|
function dateStr() {
|
|
var d = new Date();
|
|
var days = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
var mons = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
return days[d.getDay()] + " " + mons[d.getMonth()] + " " + pad(d.getDate()) + " " + d.getFullYear();
|
|
}
|
|
function timeStr() {
|
|
var d = new Date();
|
|
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
|
}
|
|
function infoStr() {
|
|
var lines = [];
|
|
var info = bar.getAttribute("data-info");
|
|
var email = bar.getAttribute("data-email");
|
|
if (info) lines.push(info);
|
|
if (email) lines.push(email);
|
|
lines.push("type: portfolio · about");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// command history, kept for the whole tab session (survives navigation)
|
|
var hist = [];
|
|
try { hist = JSON.parse(sessionStorage.getItem("kgv_hist") || "[]"); } catch (e) { hist = []; }
|
|
var hpos = hist.length;
|
|
function remember(cmd) {
|
|
if (hist[hist.length - 1] !== cmd) hist.push(cmd);
|
|
hpos = hist.length;
|
|
try { sessionStorage.setItem("kgv_hist", JSON.stringify(hist.slice(-100))); } catch (e) {}
|
|
}
|
|
function historyStr() {
|
|
if (!hist.length) return "(empty)";
|
|
return hist.map(function (c, i) { return (" " + (i + 1)).slice(-4) + " " + c; }).join("\n");
|
|
}
|
|
|
|
function unameStr(lc) {
|
|
return lc === "uname -a" ? "kgv-sh 1.0 architecture lucerne ch" : "kgv-sh";
|
|
}
|
|
|
|
// ls -a reveals the hidden dotfiles
|
|
function lsa() {
|
|
var items = ["./", "../"];
|
|
Array.prototype.forEach.call(document.querySelectorAll(".menu a"), function (a) {
|
|
items.push((a.getAttribute("data-name") || "") + "/");
|
|
});
|
|
items.push(".colophon", ".legal-notice");
|
|
return items.join(" ");
|
|
}
|
|
|
|
function tree() {
|
|
var work = [];
|
|
Array.prototype.forEach.call(document.querySelectorAll(".routes a"), function (a) {
|
|
work.push(a.getAttribute("data-name"));
|
|
});
|
|
var l = ["."];
|
|
l.push("├── portfolio/");
|
|
work.forEach(function (s, i) { l.push("│ " + (i === work.length - 1 ? "└── " : "├── ") + s + "/"); });
|
|
l.push("├── about/");
|
|
l.push("├── .colophon");
|
|
l.push("└── .legal-notice");
|
|
return l.join("\n");
|
|
}
|
|
|
|
function fetchStr() {
|
|
var up = Math.max(1, Math.round((window.performance && performance.now ? performance.now() : 0) / 1000));
|
|
return [
|
|
" _ karimgabrielevarano",
|
|
" | | __ -------------------",
|
|
" | |/ / os: kgv-sh 1.0",
|
|
" | < location: lucerne",
|
|
" |_|\\_\\ degree: ba architecture",
|
|
" status: ma in progress",
|
|
" uptime: " + up + "s",
|
|
" ~: portfolio · about"
|
|
].join("\n");
|
|
}
|
|
|
|
function setTheme(mode) {
|
|
var dark = mode === "dark";
|
|
document.documentElement.classList.toggle("dark", dark);
|
|
try { localStorage.setItem("kgv_theme", dark ? "dark" : "light"); } catch (e) {}
|
|
}
|
|
|
|
// `sl` — a steam locomotive rolls across the screen (the classic `ls` typo gag)
|
|
var TRAIN = [
|
|
" ==== ________ ___________ ",
|
|
" _D _| |_______/ \\__I_I_____===__|_________| ",
|
|
" |(_)--- | H\\________/ | | =|___ ___| ",
|
|
" / | | H | | | | ||_| |_|| ",
|
|
" | | | H |__--------------------| [___] | ",
|
|
" | ________|___H__/__|_____/[][]~\\_______| | ",
|
|
" |/ | |-----------I_____I [][] [] D |=======|__ ",
|
|
"__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ",
|
|
" |/-=|___|= || || || |_____/~\\___/ ",
|
|
" \\_/ \\O=====O=====O=====O_/ \\_/ "
|
|
].join("\n");
|
|
function sl() {
|
|
if (document.querySelector(".sl")) return;
|
|
var pre = document.createElement("pre");
|
|
pre.className = "sl";
|
|
pre.textContent = TRAIN;
|
|
document.body.appendChild(pre);
|
|
pre.style.transform = "translateX(" + window.innerWidth + "px)";
|
|
requestAnimationFrame(function () {
|
|
pre.style.transition = "transform 4s linear";
|
|
pre.style.transform = "translateX(-" + (pre.offsetWidth + 40) + "px)";
|
|
});
|
|
setTimeout(function () { pre.remove(); }, 4200);
|
|
}
|
|
|
|
// secret: retro games (doom, simcity, pacman, tetris) cd into a fake
|
|
// ".<name>" folder and run "<name>.sh" — archive.org DOSBox emulators
|
|
// embedded inline in the same file-listing chrome as any other page (not a
|
|
// floating window). ".." is a real link back to wherever you typed the
|
|
// command from, so leaving is just normal navigation — nav.js's pjax swap
|
|
// restores the real page (and real breadcrumb) on its own. archive.org
|
|
// still gates each one behind its own "click to begin" — that's the
|
|
// emulator requiring a real user gesture before it can play audio, a
|
|
// cross-origin iframe we can't click into or configure around.
|
|
var GAMES = {
|
|
doom: "DoomsharewareEpisode",
|
|
simcity: "msdos_SimCity_1989",
|
|
pacman: "msdos_Pac-Man_1983",
|
|
tetris: "msdos_Tetris_1987"
|
|
};
|
|
function launchGame(name) {
|
|
var archiveId = GAMES[name];
|
|
var main = document.getElementById("main-content");
|
|
if (!archiveId || !main || document.querySelector(".game-embed")) return;
|
|
var back = window.location.pathname + window.location.search;
|
|
var file = name + ".sh";
|
|
|
|
var pathEl = document.querySelector(".prompt__path");
|
|
if (pathEl && /\$$/.test(pathEl.innerHTML)) {
|
|
pathEl.innerHTML = pathEl.innerHTML.replace(/\$$/, '/<span class="prompt__seg">.' + name + '</span>$');
|
|
}
|
|
|
|
main.innerHTML =
|
|
'<div class="tui tui--files filebrowser game-embed">' +
|
|
'<p class="tui__cmd"><span class="tui__sym">$</span> ls</p>' +
|
|
'<p class="tui__cmd tui__total">total 1</p>' +
|
|
'<ul class="tui__ls">' +
|
|
'<li><a href="' + back + '" data-name="..">' +
|
|
'<span class="tui__perm">drwxr-xr-x</span><span class="tui__name">..</span>' +
|
|
'</a></li>' +
|
|
'<li><a data-name="' + file + '" class="is-selected">' +
|
|
'<span class="tui__perm">-rwxr-xr-x</span><span class="tui__name">' + file + '</span>' +
|
|
'</a></li>' +
|
|
'</ul>' +
|
|
'<figure class="tui__preview">' +
|
|
'<div class="game-player">' +
|
|
'<iframe src="https://archive.org/embed/' + archiveId + '" allowfullscreen allow="fullscreen; autoplay; gamepad"></iframe>' +
|
|
'</div>' +
|
|
'<figcaption class="tui__status">' + file + ' — running</figcaption>' +
|
|
'</figure>' +
|
|
'</div>';
|
|
|
|
// re-run the same wiring a real pjax navigation would (file-row hover/
|
|
// click, route rebuilding) against this injected markup
|
|
document.dispatchEvent(new CustomEvent("pjax:load"));
|
|
|
|
// the home page's always-open menu lives in the header, not #main-content
|
|
// — hide it too, otherwise its own "$ ls" sits right above the game's and
|
|
// the listing looks doubled. restored on the next real navigation
|
|
// (leaving the game), which also fires pjax:load — registered after the
|
|
// dispatch above so it doesn't just catch its own event.
|
|
if (menu && !menu.hidden) {
|
|
menu.hidden = true;
|
|
document.addEventListener("pjax:load", function unhide() {
|
|
menu.hidden = false;
|
|
document.removeEventListener("pjax:load", unhide);
|
|
}, { once: true });
|
|
}
|
|
}
|
|
|
|
function egg(lc, raw) {
|
|
var arg0 = raw.split(/\s+/)[0];
|
|
if (/^sudo\s+make\s+me\s+a\s+sandwich$/.test(lc)) return "okay.";
|
|
if (/^make\s+me\s+a\s+sandwich$/.test(lc)) return "what? make it yourself.";
|
|
if (/^sudo\b/.test(lc) || lc === "su") return "permission denied: nice try.";
|
|
if (/^rm\b/.test(lc)) return "rm: some things you keep.";
|
|
if (lc === "whoami") return "karim";
|
|
if (lc === "pwd") return pwd();
|
|
if (lc === "help" || /^man\b/.test(lc)) return "nav: portfolio · about · cv\nbasics: info · contact · date · time · tree · fetch · history\ntheme: dark · light";
|
|
if (lc === "hello" || lc === "hi" || lc === "hey") return "hi :)";
|
|
if (lc === "exit" || lc === "logout") return "there is no exit. only architecture.";
|
|
if (lc === "coffee" || lc === "brew") return "☕ brewing… (418: i'm a teapot)";
|
|
if (/^(cat|open|vim|nano|emacs|less)\b/.test(lc)) return arg0 + ": permission denied";
|
|
return "zsh: command not found: " + arg0;
|
|
}
|
|
|
|
// tab-completion over known commands + navigable routes
|
|
var COMMANDS = ["help", "man", "info", "contact", "date", "time", "tree", "fetch", "neofetch",
|
|
"uname", "history", "clear", "echo", "pwd", "whoami", "ls", "cd", "cat", "open",
|
|
"dark", "light", "theme", "sl", "doom", "simcity", "pacman", "tetris", "coffee"];
|
|
function complete() {
|
|
var val = input.textContent;
|
|
var parts = val.split(" ");
|
|
var last = (parts[parts.length - 1] || "").toLowerCase();
|
|
if (!last) return;
|
|
var pool = parts.length > 1 ? Object.keys(routes) : COMMANDS.concat(Object.keys(routes));
|
|
var matches = [];
|
|
for (var i = 0; i < pool.length; i++) {
|
|
if (pool[i].indexOf(last) === 0 && matches.indexOf(pool[i]) < 0) matches.push(pool[i]);
|
|
}
|
|
if (!matches.length) return;
|
|
var common = matches.reduce(function (a, b) {
|
|
var n = 0; while (n < a.length && a.charAt(n) === b.charAt(n)) n++; return a.slice(0, n);
|
|
});
|
|
parts[parts.length - 1] = matches.length === 1 ? matches[0] : common;
|
|
input.textContent = parts.join(" ");
|
|
caretToEnd();
|
|
say(matches.length > 1 ? matches.join(" ") : ""); // list options when ambiguous
|
|
}
|
|
|
|
function run(raw) {
|
|
var cmd = raw.replace(/\s+/g, " ").trim();
|
|
if (!cmd) return;
|
|
var lc = cmd.toLowerCase();
|
|
remember(cmd);
|
|
|
|
if (lc === "clear") { say(""); input.textContent = ""; return; }
|
|
if (lc === "doom" || lc === "simcity" || lc === "tetris") { launchGame(lc); say(""); input.textContent = ""; return; }
|
|
if (lc === "pacman" || lc === "pac-man") { launchGame("pacman"); say(""); input.textContent = ""; return; }
|
|
if (lc === "sl") { sl(); say(""); input.textContent = ""; return; }
|
|
if (lc === "dark" || lc === "light") { setTheme(lc); say(""); input.textContent = ""; return; }
|
|
if (lc === "theme") { var next = document.documentElement.classList.contains("dark") ? "light" : "dark"; setTheme(next); say("theme: " + next); input.textContent = ""; return; }
|
|
|
|
// strip a leading verb, leading ./ and trailing slash
|
|
var t = lc.replace(/^(cd|ls|cat|open)\b\s*/, "").replace(/^\.\//, "").replace(/\/+$/, "").trim();
|
|
|
|
// `ls` of the current/home dir — the listing is already on screen
|
|
if (/^ls\b/.test(lc) && (t === "" || t === "~" || t === ".")) { say(""); input.textContent = ""; return; }
|
|
// home / up
|
|
if (t === "" || t === "~" || t === ".." || t === "." || t === "home" || t === "/") { navigate(home); return; }
|
|
// a known menu target
|
|
if (routes[t]) { navigate(routes[t]); return; }
|
|
|
|
// basics that just print
|
|
if (lc === "info") { say(infoStr()); input.textContent = ""; return; }
|
|
if (lc === "contact") { say(bar.getAttribute("data-email") || ""); input.textContent = ""; return; }
|
|
if (lc === "date") { say(dateStr()); input.textContent = ""; return; }
|
|
if (lc === "time") { say(timeStr()); input.textContent = ""; return; }
|
|
if (lc === "history") { say(historyStr()); input.textContent = ""; return; }
|
|
if (lc === "tree") { say(tree()); input.textContent = ""; return; }
|
|
if (lc === "fetch" || lc === "neofetch") { say(fetchStr()); input.textContent = ""; return; }
|
|
if (lc === "uname" || lc === "uname -a") { say(unameStr(lc)); input.textContent = ""; return; }
|
|
if (/^ls\s+-(la|al|a)$/.test(lc) || lc === "ls --all") { say(lsa()); input.textContent = ""; return; }
|
|
if (lc === "echo" || lc.indexOf("echo ") === 0) { say(cmd.slice(4).trim()); input.textContent = ""; return; }
|
|
|
|
say(egg(lc, cmd)); // unknown → shell error, fresh empty prompt
|
|
input.textContent = "";
|
|
}
|
|
|
|
input.addEventListener("keydown", function (e) {
|
|
// a real keystroke wins over the file-browser's ghost command
|
|
if (ghostActive && !/^(Shift|Control|Alt|Meta|CapsLock)$/.test(e.key)) clearGhost();
|
|
if (e.key === "Enter") { e.preventDefault(); run(input.textContent); return; }
|
|
if (e.key === "Tab") { e.preventDefault(); complete(); return; }
|
|
if (e.key === "ArrowUp" && hist.length) {
|
|
e.preventDefault();
|
|
hpos = Math.max(0, hpos - 1);
|
|
input.textContent = hist[hpos] || "";
|
|
caretToEnd(); say("");
|
|
} else if (e.key === "ArrowDown" && hist.length) {
|
|
e.preventDefault();
|
|
hpos = Math.min(hist.length, hpos + 1);
|
|
input.textContent = hist[hpos] || "";
|
|
caretToEnd(); say("");
|
|
}
|
|
});
|
|
input.addEventListener("input", function () { say(""); keepEnd(); });
|
|
|
|
function caretToEnd() {
|
|
var r = document.createRange();
|
|
r.selectNodeContents(input);
|
|
r.collapse(false);
|
|
var s = window.getSelection();
|
|
s.removeAllRanges();
|
|
s.addRange(r);
|
|
keepEnd();
|
|
}
|
|
|
|
// start typing anywhere → the keystroke goes straight into the command line
|
|
document.addEventListener("keydown", function (e) {
|
|
if (document.querySelector(".doom-window")) return; // playing doom
|
|
var t = e.target;
|
|
if (t === input) return; // already typing
|
|
if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
if (e.key && e.key.length === 1) { // a printable character
|
|
e.preventDefault();
|
|
if (ghostActive) clearGhost(); // discard the ghost, type for real
|
|
input.focus();
|
|
input.textContent += e.key;
|
|
caretToEnd();
|
|
say("");
|
|
}
|
|
});
|
|
|
|
// clicking anywhere on the bar focuses the prompt (but let links + text selection through)
|
|
bar.addEventListener("click", function (e) {
|
|
if (e.target.closest("a")) return;
|
|
if (window.getSelection && String(window.getSelection())) return;
|
|
input.focus();
|
|
caretToEnd();
|
|
});
|
|
})();
|