diff --git a/src/components/Cli/Terminal.tsx b/src/components/Cli/Terminal.tsx new file mode 100644 index 0000000..4af731f --- /dev/null +++ b/src/components/Cli/Terminal.tsx @@ -0,0 +1,326 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { useAppLayout } from "contexts/AppLayoutContext"; +import { cJoin, cIf } from "helpers/className"; +import { useTerminalContext } from "contexts/TerminalContext"; +import { isDefined, isDefinedAndNotEmpty } from "helpers/others"; + +/* + * ╭─────────────╮ + * ────────────────────────────────────────╯ CONSTANTS ╰────────────────────────────────────────── + */ + +const LINE_PREFIX = "root@accords-library.com:"; + +/* + * ╭─────────────╮ + * ───────────────────────────────────────╯ COMPONENT ╰─────────────────────────────────────────── + */ + +interface Props { + childrenPaths: string[]; + parentPath: string; + content?: string; +} + +// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +export const Terminal = ({ + parentPath, + childrenPaths: propsChildrenPaths, + content, +}: Props): JSX.Element => { + const [childrenPaths, setChildrenPaths] = useState(propsChildrenPaths); + const { darkMode } = useAppLayout(); + const { previousCommands, previousLines, setPreviousCommands, setPreviousLines } = + useTerminalContext(); + const [line, setLine] = useState(""); + const [displayCurrentLine, setDisplayCurrentLine] = useState(true); + const [previousCommandIndex, setPreviousCommandIndex] = useState(0); + const [carretPosition, setCarretPosition] = useState(0); + const router = useRouter(); + const { setPlayerName } = useAppLayout(); + const [isTextAreaFocused, setIsTextAreaFocused] = useState(false); + + const terminalInputRef = useRef(null); + const terminalWindowRef = useRef(null); + + router.events.on("routeChangeComplete", () => { + terminalInputRef.current?.focus(); + setDisplayCurrentLine(true); + }); + + const onRouteChangeRequest = useCallback( + (newPath: string) => { + if (newPath !== router.asPath) { + setDisplayCurrentLine(false); + router.push(newPath); + } + }, + [router] + ); + + const prependLine = useCallback( + (text: string) => `${LINE_PREFIX}${router.asPath}# ${text}`, + [router.asPath] + ); + + type Command = { + key: string; + description: string; + handle: (currentLine: string, parameters: string) => string[]; + }; + const commands = useMemo(() => { + const result: Command[] = [ + { + key: "ls", + description: "List directory contents", + handle: (currentLine) => [ + ...previousLines, + prependLine(currentLine), + childrenPaths.join(" "), + ], + }, + + { + key: "clear", + description: "Clear the terminal screen", + handle: () => [], + }, + + { + key: "cat", + description: "Concatenate files and print on the standard output", + handle: (currentLine) => [ + ...previousLines, + prependLine(currentLine), + isDefinedAndNotEmpty(content) ? `\n${content}\n` : `-bash: cat: Nothing to display`, + ], + }, + + { + key: "reboot", + description: "Reboot the machine", + handle: () => { + setPlayerName(""); + return []; + }, + }, + + { + key: "rm", + description: "Remove files or directories", + handle: (currentLine, parameters) => { + console.log(parameters); + if (parameters.startsWith("-r ")) { + const folder = parameters.slice("-r ".length); + if (childrenPaths.includes(folder)) { + setChildrenPaths((current) => current.filter((path) => path !== folder)); + return [...previousLines, prependLine(currentLine)]; + } else if (folder === "*") { + setChildrenPaths([]); + return [...previousLines, prependLine(currentLine)]; + } else if (folder === "") { + return [ + ...previousLines, + prependLine(currentLine), + `rm: missing operand\nTry 'rm -r ' to remove a folder`, + ]; + } + return [ + ...previousLines, + prependLine(currentLine), + `rm: cannot remove '${folder}': No such file or directory`, + ]; + } + return [ + ...previousLines, + prependLine(currentLine), + `rm: missing operand\nTry 'rm -r ' to remove a folder`, + ]; + }, + }, + + { + key: "help", + description: "Display this list", + handle: (currentLine) => [ + ...previousLines, + prependLine(currentLine), + ` + GNU bash, version 5.1.4(1)-release (x86_64-pc-linux-gnu) + These shell commands are defined internally. Type 'help' to see this list. + + ${result.map((command) => `${command.key}: ${command.description}`).join("\n")} + + `, + ], + }, + + { + key: "cd", + description: "Change the shell working directory", + handle: (currentLine, parameters) => { + const newLines = []; + switch (parameters) { + case "..": { + onRouteChangeRequest(parentPath); + break; + } + case "/": { + onRouteChangeRequest("/"); + break; + } + case ".": { + break; + } + default: { + if (childrenPaths.includes(parameters)) { + onRouteChangeRequest(`${router.asPath === "/" ? "" : router.asPath}/${parameters}`); + } else { + newLines.push(`-bash: cd: ${parameters}: No such file or directory`); + } + break; + } + } + return [...previousLines, prependLine(currentLine), ...newLines]; + }, + }, + ]; + + return [ + ...result, + { + key: "", + description: "Unhandled command", + handle: (currentLine) => [ + ...previousLines, + prependLine(currentLine), + `-bash: ${currentLine}: command not found`, + ], + }, + ]; + }, [ + childrenPaths, + parentPath, + content, + onRouteChangeRequest, + prependLine, + previousLines, + router.asPath, + setPlayerName, + ]); + + const onNewLine = useCallback( + (newLine: string) => { + for (const command of commands) { + if (newLine.startsWith(command.key)) { + setPreviousLines(command.handle(newLine, newLine.slice(command.key.length + 1))); + setPreviousCommands([newLine, ...previousCommands]); + return; + } + } + }, + [commands, previousCommands, setPreviousCommands, setPreviousLines] + ); + + useEffect(() => { + if (terminalWindowRef.current) { + terminalWindowRef.current.scrollTo({ + top: terminalWindowRef.current.scrollHeight, + }); + } + }, [line]); + + return ( +
+
+ {previousLines.map((previousLine, index) => ( +

+ {previousLine} +

+ ))} + +
+