Added terminal stuff

This commit is contained in:
DrMint 2022-09-05 17:02:22 +02:00
parent 7b303f81ad
commit 1b347ad357
16 changed files with 562 additions and 7 deletions

View File

@ -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<HTMLTextAreaElement>(null);
const terminalWindowRef = useRef<HTMLDivElement>(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<Command[]>(() => {
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 <path>' 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 <path>' 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 (
<div
className={cJoin(
"h-screen overflow-hidden bg-light text-black set-theme-font-standard",
cIf(darkMode, "set-theme-dark", "set-theme-light")
)}>
<div
ref={terminalWindowRef}
className="h-full overflow-scroll scroll-auto p-6
[scrollbar-width:none] webkit-scrollbar:w-0">
{previousLines.map((previousLine, index) => (
<p key={index} className="whitespace-pre-line font-realmono">
{previousLine}
</p>
))}
<div className="relative">
<textarea
className="absolute -top-1 -left-6 -right-6 w-screen rounded-none opacity-0"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
placeholder="placeholder"
ref={terminalInputRef}
value={line}
onSelect={() => {
if (terminalInputRef.current) {
setCarretPosition(terminalInputRef.current.selectionStart);
terminalInputRef.current.selectionEnd = terminalInputRef.current.selectionStart;
}
}}
onBlur={() => setIsTextAreaFocused(false)}
onFocus={() => setIsTextAreaFocused(true)}
onKeyDown={(event) => {
if (event.key === "ArrowUp") {
event.preventDefault();
let newPreviousCommandIndex = previousCommandIndex;
if (previousCommandIndex < previousCommands.length - 1) {
newPreviousCommandIndex += 1;
}
setPreviousCommandIndex(newPreviousCommandIndex);
const previousCommand = previousCommands[newPreviousCommandIndex];
if (isDefined(previousCommand)) {
setLine(previousCommand);
setCarretPosition(previousCommand.length);
}
}
if (event.key === "ArrowDown") {
event.preventDefault();
let newPreviousCommandIndex = previousCommandIndex;
if (previousCommandIndex > 0) {
newPreviousCommandIndex -= 1;
}
setPreviousCommandIndex(newPreviousCommandIndex);
const previousCommand = previousCommands[newPreviousCommandIndex];
if (isDefined(previousCommand)) {
setLine(previousCommand);
setCarretPosition(previousCommand.length);
}
}
}}
onInput={() => {
if (terminalInputRef.current) {
if (terminalInputRef.current.value.includes("\n")) {
setLine("");
onNewLine(line);
} else {
setLine(terminalInputRef.current.value);
}
setCarretPosition(terminalInputRef.current.selectionStart);
}
}}
/>
{displayCurrentLine && (
<p className="whitespace-normal font-realmono">
{prependLine("")}
{line.slice(0, carretPosition)}
<span
className={cJoin(
"whitespace-pre font-realmono",
cIf(isTextAreaFocused, "animation-carret border-b-2 border-black")
)}>
{line[carretPosition] ?? " "}
</span>
{line.slice(carretPosition + 1)}
</p>
)}
</div>
</div>
</div>
);
};

View File

@ -267,7 +267,10 @@ export const AppContextProvider = (props: Props): JSX.Element => {
}, [router.events, setConfigPanelOpen, setMainPanelOpen, setSubPanelOpen]);
useLayoutEffect(() => {
document.getElementsByTagName("html")[0].style.fontSize = `${fontSize * 100}%`;
const html = document.getElementsByTagName("html")[0];
if (isDefined(html)) {
html.style.fontSize = `${fontSize * 100}%`;
}
}, [fontSize]);
useScrollIntoView();

View File

@ -0,0 +1,35 @@
import React, { ReactNode, useContext, useState } from "react";
import { RequiredNonNullable } from "types/types";
interface TerminalState {
previousLines: string[];
previousCommands: string[];
setPreviousLines: React.Dispatch<React.SetStateAction<TerminalState["previousLines"]>>;
setPreviousCommands: React.Dispatch<React.SetStateAction<TerminalState["previousCommands"]>>;
}
const initialState: RequiredNonNullable<TerminalState> = {
previousLines: [],
previousCommands: [],
setPreviousLines: () => null,
setPreviousCommands: () => null,
};
const TerminalContext = React.createContext<TerminalState>(initialState);
export const useTerminalContext = (): TerminalState => useContext(TerminalContext);
interface Props {
children: ReactNode;
}
export const TerminalContextProvider = ({ children }: Props): JSX.Element => {
const [previousLines, setPreviousLines] = useState(initialState.previousLines);
const [previousCommands, setPreviousCommands] = useState(initialState.previousCommands);
return (
<TerminalContext.Provider
value={{ previousCommands, previousLines, setPreviousCommands, setPreviousLines }}>
{children}
</TerminalContext.Provider>
);
};

View File

@ -25,7 +25,7 @@ export const getDescription = (
return result;
};
const prettyMarkdown = (markdown: string): string =>
export const prettyMarkdown = (markdown: string): string =>
markdown.replace(/[*]/gu, "").replace(/[_]/gu, "");
const prettyChip = (items: (string | undefined)[]): string =>

18
src/helpers/terminal.ts Normal file
View File

@ -0,0 +1,18 @@
import { isDefinedAndNotEmpty } from "./others";
export const prettyTerminalUnderlinedTitle = (string: string | null | undefined): string =>
isDefinedAndNotEmpty(string)
? `\n\n${string}
${"‾".repeat(string.length)}
`
: "";
export const prettyTerminalBoxedTitle = (string: string | null | undefined): string =>
isDefinedAndNotEmpty(string)
? `${"─".repeat(string.length + 2)}
${string}
${"─".repeat(string.length + 2)}`
: "";
export const prettyTerminalTitle = (title: string | null | undefined): string =>
`\n\n-= ${title?.toUpperCase()} =-`;

View File

@ -0,0 +1,6 @@
import { useAppLayout } from "contexts/AppLayoutContext";
export const useIsTerminalMode = (): boolean => {
const { playerName } = useAppLayout();
return playerName === "root";
};

View File

@ -8,10 +8,13 @@ import "@fontsource/zen-maru-gothic/900.css";
import type { AppProps } from "next/app";
import { AppContextProvider } from "contexts/AppLayoutContext";
import "tailwind.css";
import { TerminalContextProvider } from "contexts/TerminalContext";
const AccordsLibraryApp = (props: AppProps): JSX.Element => (
<AppContextProvider>
<TerminalContextProvider>
<props.Component {...props.pageProps} />
</TerminalContextProvider>
</AppContextProvider>
);
export default AccordsLibraryApp;

View File

@ -2,6 +2,8 @@ import { PostPage } from "components/PostPage";
import { useAppLayout } from "contexts/AppLayoutContext";
import { getPostStaticProps, PostStaticProps } from "graphql/getPostStaticProps";
import { getOpenGraph } from "helpers/openGraph";
import { Terminal } from "components/Cli/Terminal";
import { useIsTerminalMode } from "hooks/useIsTerminalMode";
/*
*
@ -10,6 +12,26 @@ import { getOpenGraph } from "helpers/openGraph";
const Home = ({ ...otherProps }: PostStaticProps): JSX.Element => {
const { langui } = useAppLayout();
const isTerminalMode = useIsTerminalMode();
if (isTerminalMode) {
return (
<Terminal
parentPath="/"
childrenPaths={[
"library",
"contents",
"wiki",
"chronicles",
"news",
"gallery",
"archives",
"about-us",
]}
/>
);
}
return (
<PostPage
{...otherProps}

View File

@ -1,9 +1,16 @@
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { NextRouter, useRouter } from "next/router";
import { PostPage } from "components/PostPage";
import { getPostStaticProps, PostStaticProps } from "graphql/getPostStaticProps";
import { getReadySdk } from "graphql/sdk";
import { filterHasAttributes, isDefined } from "helpers/others";
import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/others";
import { useAppLayout } from "contexts/AppLayoutContext";
import { useIsTerminalMode } from "hooks/useIsTerminalMode";
import { Terminal } from "components/Cli/Terminal";
import { PostWithTranslations } from "types/types";
import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales";
import { prettyTerminalBoxedTitle } from "helpers/terminal";
import { prettyMarkdown } from "helpers/description";
/*
*
@ -14,6 +21,19 @@ interface Props extends PostStaticProps {}
const LibrarySlug = (props: Props): JSX.Element => {
const { langui } = useAppLayout();
const isTerminalMode = useIsTerminalMode();
const router = useRouter();
if (isTerminalMode) {
return (
<Terminal
parentPath={"/news"}
childrenPaths={[]}
content={terminalPostPage(props.post, router)}
/>
);
}
return (
<PostPage
returnHref="/news"
@ -55,3 +75,30 @@ export const getStaticPaths: GetStaticPaths = async (context) => {
fallback: "blocking",
};
};
const terminalPostPage = (post: PostWithTranslations, router: NextRouter): string => {
let result = "";
if (router.locales && router.locale) {
const selectedTranslation = staticSmartLanguage({
items: filterHasAttributes(post.translations, ["language.data.attributes.code"] as const),
languageExtractor: (item) => item.language.data.attributes.code,
preferredLanguages: getDefaultPreferredLanguages(router.locale, router.locales),
});
if (selectedTranslation) {
result += prettyTerminalBoxedTitle(selectedTranslation.title);
if (isDefinedAndNotEmpty(selectedTranslation.excerpt)) {
result += "\n\n";
result += prettyMarkdown(selectedTranslation.excerpt);
}
if (isDefinedAndNotEmpty(selectedTranslation.body)) {
result += "\n\n";
result += prettyMarkdown(selectedTranslation.body);
}
}
}
result += "\n\n";
return result;
};

View File

@ -25,6 +25,8 @@ import { useIsContentPanelAtLeast } from "hooks/useContainerQuery";
import { useAppLayout } from "contexts/AppLayoutContext";
import { getLangui } from "graphql/fetchLocalData";
import { sendAnalytics } from "helpers/analytics";
import { useIsTerminalMode } from "hooks/useIsTerminalMode";
import { Terminal } from "components/Cli/Terminal";
/*
*
@ -55,6 +57,7 @@ const News = ({ posts, ...otherProps }: Props): JSX.Element => {
toggle: toggleKeepInfoVisible,
setValue: setKeepInfoVisible,
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
const isTerminalMode = useIsTerminalMode();
const subPanel = useMemo(
() => (
@ -153,6 +156,17 @@ const News = ({ posts, ...otherProps }: Props): JSX.Element => {
[keepInfoVisible, posts, searchName, isContentPanelAtLeast4xl]
);
if (isTerminalMode) {
return (
<Terminal
parentPath="/"
childrenPaths={filterHasAttributes(posts, ["attributes"] as const).map(
(post) => post.attributes.slug
)}
/>
);
}
return (
<AppLayout
subPanel={subPanel}

View File

@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Chip } from "components/Chip";
import { HorizontalLine } from "components/HorizontalLine";
@ -22,6 +23,9 @@ import { cIf, cJoin } from "helpers/className";
import { useIs3ColumnsLayout } from "hooks/useContainerQuery";
import { useAppLayout } from "contexts/AppLayoutContext";
import { getLangui } from "graphql/fetchLocalData";
import { Terminal } from "components/Cli/Terminal";
import { prettyTerminalBoxedTitle, prettyTerminalUnderlinedTitle } from "helpers/terminal";
import { useIsTerminalMode } from "hooks/useIsTerminalMode";
/*
*
@ -34,6 +38,8 @@ interface Props extends AppLayoutRequired {
const WikiPage = ({ page, ...otherProps }: Props): JSX.Element => {
const { langui } = useAppLayout();
const router = useRouter();
const isTerminalMode = useIsTerminalMode();
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
items: page.translations,
languageExtractor: useCallback(
@ -190,6 +196,48 @@ const WikiPage = ({ page, ...otherProps }: Props): JSX.Element => {
]
);
if (isTerminalMode) {
return (
<Terminal
childrenPaths={[]}
parentPath={"/wiki"}
content={`${prettyTerminalBoxedTitle(
`${selectedTranslation?.title}${
selectedTranslation?.aliases && selectedTranslation.aliases.length > 0
? ` (${selectedTranslation.aliases.map((alias) => alias?.alias).join(", ")})`
: ""
}`
)}${
isDefinedAndNotEmpty(selectedTranslation?.summary)
? `${prettyTerminalUnderlinedTitle(langui.summary)}${selectedTranslation?.summary}`
: ""
}${
page.definitions && page.definitions.length > 0
? `${filterHasAttributes(page.definitions, ["translations"] as const).map(
(definition, index) =>
`${prettyTerminalUnderlinedTitle(`${langui.definition} ${index + 1}`)}${
staticSmartLanguage({
items: filterHasAttributes(definition.translations, [
"language.data.attributes.code",
] as const),
languageExtractor: (item) => item.language.data.attributes.code,
preferredLanguages: getDefaultPreferredLanguages(
router.locale ?? "en",
router.locales ?? ["en"]
),
})?.definition
}`
)}`
: ""
}${
isDefinedAndNotEmpty(selectedTranslation?.body?.body)
? `\n\n${selectedTranslation?.body?.body}`
: "\n"
}`}
/>
);
}
return <AppLayout subPanel={subPanel} contentPanel={contentPanel} {...otherProps} />;
};
export default WikiPage;
@ -208,7 +256,7 @@ export const getStaticProps: GetStaticProps = async (context) => {
language_code: context.locale ?? "en",
slug: slug,
});
if (!page.wikiPages?.data[0].attributes?.translations) return { notFound: true };
if (!page.wikiPages?.data[0]?.attributes?.translations) return { notFound: true };
const { title, description } = (() => {
const chipsGroups = {

View File

@ -27,6 +27,8 @@ import { cIf } from "helpers/className";
import { useAppLayout } from "contexts/AppLayoutContext";
import { getLangui } from "graphql/fetchLocalData";
import { sendAnalytics } from "helpers/analytics";
import { Terminal } from "components/Cli/Terminal";
import { useIsTerminalMode } from "hooks/useIsTerminalMode";
/*
*
@ -52,6 +54,7 @@ const Wiki = ({ pages, ...otherProps }: Props): JSX.Element => {
const hoverable = useDeviceSupportsHover();
const { langui } = useAppLayout();
const isContentPanelAtLeast4xl = useIsContentPanelAtLeast("4xl");
const isTerminalMode = useIsTerminalMode();
const [searchName, setSearchName] = useState(DEFAULT_FILTERS_STATE.searchName);
@ -230,6 +233,17 @@ const Wiki = ({ pages, ...otherProps }: Props): JSX.Element => {
[groupingFunction, keepInfoVisible, pages, searchName, isContentPanelAtLeast4xl]
);
if (isTerminalMode) {
return (
<Terminal
parentPath="/"
childrenPaths={filterHasAttributes(pages, ["attributes"] as const).map(
(page) => page.attributes.slug
)}
/>
);
}
return (
<AppLayout
subPanel={subPanel}

View File

@ -172,6 +172,22 @@ input[type="submit"] {
[background-blend-mode:var(--theme-texture-dots-blend)];
}
/* ANIMATION */
.animation-carret {
animation: blink 1s step-end infinite;
}
@keyframes blink {
from,
to {
border-bottom-style: solid;
}
50% {
border-bottom-style: none;
}
}
/* DEBUGGING */
.false {
@apply border-2 border-[red] text-[red] outline-dotted outline-2 outline-[red];

View File

@ -20,6 +20,7 @@ module.exports = {
body: "var(--theme-font-body)",
headers: "var(--theme-font-headers)",
mono: "var(--theme-font-mono)",
realmono: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace`,
...fonts,
},
screens: {

View File

@ -15,7 +15,9 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "src"
"baseUrl": "src",
"noUncheckedIndexedAccess": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]