Files
freno-dev/src/components/TerminalErrorPage.tsx
2026-01-01 02:22:33 -05:00

252 lines
7.4 KiB
TypeScript

import {
createSignal,
createEffect,
onMount,
onCleanup,
For,
Show,
JSX
} from "solid-js";
import {
CommandHistoryItem,
createTerminalCommands,
executeTerminalCommand,
CommandContext
} from "~/lib/terminal-commands";
import { Btop } from "~/components/Btop";
interface TerminalErrorPageProps {
glitchText: string;
glitchChars: string;
glitchSpeed?: number;
glitchThreshold?: number;
glitchIntensity?: number;
navigate: (path: string) => void;
location: { pathname: string };
errorContent: JSX.Element;
quickActions: JSX.Element;
footer: JSX.Element;
onGlitchTextChange?: (text: string) => void;
commandContext?: Partial<CommandContext>;
}
export function TerminalErrorPage(props: TerminalErrorPageProps) {
const [glitchText, setGlitchText] = createSignal(props.glitchText);
const [command, setCommand] = createSignal("");
const [history, setHistory] = createSignal<CommandHistoryItem[]>([]);
const [historyIndex, setHistoryIndex] = createSignal(-1);
const [btopOpen, setBtopOpen] = createSignal(false);
let inputRef: HTMLInputElement | undefined;
let footerRef: HTMLDivElement | undefined;
// Auto-scroll to bottom when history changes
createEffect(() => {
if (history().length > 0) {
setTimeout(() => {
footerRef?.scrollIntoView({ behavior: "smooth", block: "end" });
}, 0);
}
});
const addToHistory = (
cmd: string,
output: string,
type: "success" | "error" | "info"
) => {
if (cmd === "clear") {
setHistory([]);
} else {
setHistory([...history(), { command: cmd, output, type }]);
}
};
const commands = createTerminalCommands({
navigate: props.navigate,
location: props.location,
addToHistory,
openBtop: () => setBtopOpen(true),
...props.commandContext
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
executeTerminalCommand(command(), commands, addToHistory);
setCommand("");
setHistoryIndex(-1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
const allCommands = history().map((h) => h.command);
if (allCommands.length > 0) {
const newIndex =
historyIndex() === -1
? allCommands.length - 1
: Math.max(0, historyIndex() - 1);
setHistoryIndex(newIndex);
setCommand(allCommands[newIndex]);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
const allCommands = history().map((h) => h.command);
if (historyIndex() !== -1) {
const newIndex = Math.min(allCommands.length - 1, historyIndex() + 1);
setHistoryIndex(newIndex);
setCommand(allCommands[newIndex]);
}
} else if (e.key === "Tab") {
e.preventDefault();
const typed = command().toLowerCase();
const matches = Object.keys(commands).filter((cmd) =>
cmd.startsWith(typed)
);
if (matches.length === 1) {
setCommand(matches[0]);
} else if (matches.length > 1) {
addToHistory(command(), matches.join(" "), "info");
}
} else if (e.key === "l" && e.ctrlKey) {
e.preventDefault();
setHistory([]);
}
};
onMount(() => {
const originalText = props.glitchText;
const glitchChars = props.glitchChars;
const glitchSpeed = props.glitchSpeed || 300;
const glitchThreshold = props.glitchThreshold || 0.85;
const glitchIntensity = props.glitchIntensity || 0.7;
const glitchInterval = setInterval(() => {
if (Math.random() > glitchThreshold) {
let glitched = "";
for (let i = 0; i < originalText.length; i++) {
if (Math.random() > glitchIntensity) {
glitched +=
glitchChars[Math.floor(Math.random() * glitchChars.length)];
} else {
glitched += originalText[i];
}
}
setGlitchText(glitched);
props.onGlitchTextChange?.(glitched);
setTimeout(() => {
setGlitchText(originalText);
props.onGlitchTextChange?.(originalText);
}, 100);
}
}, glitchSpeed);
inputRef?.focus();
onCleanup(() => {
clearInterval(glitchInterval);
});
});
return (
<div
class={`relative min-h-screen w-full overflow-hidden`}
onClick={() => inputRef?.focus()}
>
{/* Scanline effect */}
<div class="pointer-events-none absolute inset-0 z-20 opacity-5">
<div
class="h-full w-full"
style={{
"background-image":
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.2) 2px, rgba(0,0,0,0.2) 4px)",
animation: "scanline 8s linear infinite"
}}
/>
</div>
{/* Main content */}
<div class="relative z-10 flex min-h-screen flex-col items-start justify-start px-4 py-16 lg:px-16">
{/* Terminal header */}
{/* Error Content - passed as prop */}
{props.errorContent}
{/* Quick Actions - passed as prop */}
{props.quickActions}
{/* Command history */}
<Show when={history().length > 0}>
<div class="mb-4 w-full max-w-4xl font-mono text-sm">
<For each={history()}>
{(item) => (
<div class="mb-3">
<div class="text-subtext0 flex items-center gap-2">
<span class="text-green">freno@terminal</span>
<span class="text-subtext1">:</span>
<span class="text-blue">~</span>
<span class="text-subtext1">$</span>
<span class="text-text">{item.command}</span>
</div>
<div
class="mt-1 whitespace-pre-wrap"
classList={{
"text-text": item.type === "success",
"text-red": item.type === "error",
"text-blue": item.type === "info"
}}
>
{item.output}
</div>
</div>
)}
</For>
</div>
</Show>
{/* Interactive input */}
<div class="w-full max-w-4xl font-mono text-sm">
<div class="flex items-center gap-2">
<span class="text-green">freno@terminal</span>
<span class="text-subtext1">:</span>
<span class="text-blue">~</span>
<span class="text-subtext1">$</span>
<input
ref={inputRef}
type="text"
value={command()}
onInput={(e) => setCommand(e.currentTarget.value)}
onKeyDown={handleKeyDown}
class="text-text caret-text ml-1 flex-1 border-none bg-transparent outline-none"
autocomplete="off"
autocapitalize="off"
spellcheck={false}
/>
</div>
</div>
{/* Footer */}
<div
ref={footerRef}
class="text-subtext1 absolute right-4 bottom-4 font-mono text-xs"
>
{props.footer}
</div>
</div>
{/* Btop overlay */}
<Show when={btopOpen()}>
<Btop onClose={() => setBtopOpen(false)} />
</Show>
{/* Custom styles */}
<style>{`
@keyframes scanline {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(100%);
}
}
`}</style>
</div>
);
}