268 lines
7.9 KiB
TypeScript
268 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { QUOTES } from "@/lib/quotes";
|
|
|
|
const cdRoutes: Record<string, string> = {
|
|
about: "/About",
|
|
learning: "/Learning",
|
|
selfhosted: "/SelfHosted",
|
|
home: "/",
|
|
"~": "/",
|
|
};
|
|
|
|
export default function TerminalBox({ children }: { children: React.ReactNode }) {
|
|
const router = useRouter();
|
|
// ---- BOOT SEQUENCE STATES ----
|
|
const [showFirstPrompt, setShowFirstPrompt] = useState(false);
|
|
const [showEnterEcho, setShowEnterEcho] = useState(false);
|
|
const [typedCommand1, setTypedCommand1] = useState("");
|
|
const [showQuote, setShowQuote] = useState(false);
|
|
const [quote, setQuote] = useState("");
|
|
const [typedCommand2, setTypedCommand2] = useState("");
|
|
const [showLoader, setShowLoader] = useState(false);
|
|
const [loaderProgress, setLoaderProgress] = useState(0);
|
|
const [showTree, setShowTree] = useState(false);
|
|
const [showIntroDone, setShowIntroDone] = useState(false);
|
|
|
|
// ---- INTERACTIVE STATES ----
|
|
const [history, setHistory] = useState<{ type: "input" | "output"; text: any }[]>([]);
|
|
const [currentInput, setCurrentInput] = useState("");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// ---------------------------------------------------------
|
|
// BOOT SEQUENCE
|
|
// ---------------------------------------------------------
|
|
useEffect(() => {
|
|
setQuote(QUOTES[Math.floor(Math.random() * QUOTES.length)]);
|
|
|
|
setTimeout(() => setShowFirstPrompt(true), 400);
|
|
setTimeout(() => setShowEnterEcho(true), 1500);
|
|
setTimeout(() => typeCommand1(), 1800);
|
|
}, []);
|
|
|
|
// TYPE ONLY COMMAND, NOT PROMPT
|
|
const typeCommand1 = () => {
|
|
const cmd = "quoteOfTheDay";
|
|
let i = 0;
|
|
|
|
const interval = setInterval(() => {
|
|
setTypedCommand1(cmd.slice(0, i + 1));
|
|
i++;
|
|
|
|
if (i >= cmd.length) {
|
|
clearInterval(interval);
|
|
setTimeout(() => setShowQuote(true), 300);
|
|
setTimeout(() => typeCommand2(), 1200);
|
|
}
|
|
}, 50);
|
|
};
|
|
|
|
const typeCommand2 = () => {
|
|
const cmd = "tree .";
|
|
let i = 0;
|
|
|
|
const interval = setInterval(() => {
|
|
setTypedCommand2(cmd.slice(0, i + 1));
|
|
i++;
|
|
|
|
if (i >= cmd.length) {
|
|
clearInterval(interval);
|
|
setTimeout(() => startLoader(), 400);
|
|
}
|
|
}, 50);
|
|
};
|
|
|
|
const startLoader = () => {
|
|
setShowLoader(true);
|
|
let progress = 0;
|
|
|
|
const interval = setInterval(() => {
|
|
progress += Math.random() * 20;
|
|
|
|
if (progress >= 100) {
|
|
progress = 100;
|
|
clearInterval(interval);
|
|
|
|
setTimeout(() => {
|
|
setShowTree(true);
|
|
setShowLoader(false);
|
|
setShowIntroDone(true);
|
|
setTimeout(() => inputRef.current?.focus(), 200);
|
|
}, 500);
|
|
}
|
|
|
|
setLoaderProgress(Math.floor(progress));
|
|
}, 200);
|
|
};
|
|
|
|
// ---------------------------------------------------------
|
|
// COMMAND REGISTRY
|
|
// ---------------------------------------------------------
|
|
const commands: Record<string, (args: string[]) => any> = {
|
|
quoteOfTheDay: () => QUOTES[Math.floor(Math.random() * QUOTES.length)],
|
|
|
|
help: () => `
|
|
Available commands:
|
|
quoteOfTheDay Show a random quote
|
|
tree . Show the file structure
|
|
ls, ls -la Minimal directory listing
|
|
cd <path> Navigate to a page (about, learning, selfhosted, home)
|
|
pwd Print working directory
|
|
whoami Identify yourself
|
|
motivation Generate motivation
|
|
vibecheck Check the vibe
|
|
clear Clears the screen
|
|
history Look into the past
|
|
exit You can't exit, it's a website
|
|
`,
|
|
|
|
pwd: () => "/home/juntekim/site",
|
|
whoami: () => "I suspect you know who you are.",
|
|
|
|
history: () => "If we keep looking back, we won't see the glory of now",
|
|
|
|
clear: () => "no takebacks, face your past commands.",
|
|
|
|
motivation: () => [
|
|
"Compiling motivation…",
|
|
setTimeout(() => {
|
|
setHistory(h => [...h, { type: "output", text: "Segmentation fault (core dumped)" }]);
|
|
}, 500),
|
|
],
|
|
|
|
vibecheck: () => {
|
|
const vibes = [
|
|
"vibes immaculate ✨",
|
|
"vibes questionable 🧐",
|
|
"vibes under maintenance 🚧",
|
|
"vibes loading… 0%",
|
|
"vibes not found",
|
|
];
|
|
return vibes[Math.floor(Math.random() * vibes.length)];
|
|
},
|
|
|
|
exit: () => "bro it's a website. chill.",
|
|
ls: () => "nothing to see here 👀",
|
|
"ls -la": () => "nothing to see here 👀",
|
|
|
|
cd: (args: string[]) => {
|
|
const dest = cdRoutes[args[0]?.toLowerCase()];
|
|
if (dest) {
|
|
setTimeout(() => router.push(dest), 300);
|
|
return `navigating to ${args[0]}…`;
|
|
}
|
|
return `cd: ${args[0] ?? ""}: no such directory`;
|
|
},
|
|
|
|
tree: () => [
|
|
"Compiling…",
|
|
setTimeout(() => {
|
|
setHistory(h => [...h, { type: "output", text: "/" }]);
|
|
setHistory(h => [...h, { type: "output", text: children }]);
|
|
}, 400),
|
|
],
|
|
};
|
|
|
|
// ---------------------------------------------------------
|
|
// COMMAND EXECUTION
|
|
// ---------------------------------------------------------
|
|
const handleCommand = (cmd: string) => {
|
|
const parts = cmd.trim().split(" ");
|
|
const base = parts[0];
|
|
const args = parts.slice(1);
|
|
|
|
setHistory(h => [...h, { type: "input", text: cmd }]);
|
|
|
|
if (cmd === "tree .") return commands["tree"](args);
|
|
|
|
if (commands[base]) {
|
|
const output = commands[base](args);
|
|
if (typeof output === "string") {
|
|
setHistory(h => [...h, { type: "output", text: output }]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setHistory(h => [...h, { type: "output", text: `command not found: ${cmd}` }]);
|
|
};
|
|
|
|
const onKeyDown = (e: any) => {
|
|
if (e.key === "Enter") {
|
|
handleCommand(currentInput);
|
|
setCurrentInput("");
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------
|
|
// RENDER
|
|
// ---------------------------------------------------------
|
|
return (
|
|
<div
|
|
className="rounded-lg border border-zinc-700 bg-black text-zinc-100 p-4 font-mono text-sm shadow-xl w-full max-w-2xl"
|
|
style={{ whiteSpace: "pre-wrap" }}
|
|
>
|
|
{/* BOOT SEQUENCE */}
|
|
{showFirstPrompt && <div className="text-green-400">juntekim@site:~$</div>}
|
|
{showEnterEcho && <div className="text-green-400">juntekim@site:~$</div>}
|
|
|
|
{typedCommand1 && (
|
|
<div className="flex">
|
|
<span className="text-green-400">juntekim@site:~$ </span>
|
|
<span className="text-zinc-300">{typedCommand1}</span>
|
|
</div>
|
|
)}
|
|
|
|
{showQuote && <div className="text-zinc-300 italic my-2">{quote}</div>}
|
|
|
|
{typedCommand2 && (
|
|
<div className="flex">
|
|
<span className="text-green-400">juntekim@site:~$ </span>
|
|
<span className="text-zinc-300">{typedCommand2}</span>
|
|
</div>
|
|
)}
|
|
|
|
{showLoader && (
|
|
<div className="text-green-400 mt-2">compiling… {loaderProgress}%</div>
|
|
)}
|
|
|
|
{showTree && (
|
|
<div className="text-zinc-300 mt-2">
|
|
/
|
|
{children}
|
|
</div>
|
|
)}
|
|
|
|
{/* INTERACTIVE MODE */}
|
|
{showIntroDone && (
|
|
<>
|
|
{history.map((item, idx) => (
|
|
<div key={idx} className="flex">
|
|
{item.type === "input" ? (
|
|
<>
|
|
<span className="text-green-400">juntekim@site:~$ </span>
|
|
<span className="text-zinc-300">{item.text}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-zinc-300">{item.text}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex items-center mt-1">
|
|
<span className="text-green-400">juntekim@site:~$ </span>
|
|
<input
|
|
ref={inputRef}
|
|
value={currentInput}
|
|
onChange={(e) => setCurrentInput(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
className="bg-black text-zinc-300 outline-none w-full"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|