Add interactive terminal commands
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
onHoverButton: (idx: number) => void;
|
||||
onLinkClick: (idx: number) => void;
|
||||
terminalSettings: { showCursor: boolean };
|
||||
interactiveInput?: boolean;
|
||||
onCommand?: (command: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -27,8 +29,81 @@
|
||||
onHoverButton,
|
||||
onLinkClick,
|
||||
terminalSettings,
|
||||
interactiveInput = false,
|
||||
onCommand,
|
||||
}: Props = $props();
|
||||
|
||||
let inputEl = $state<HTMLInputElement>();
|
||||
let commandValue = $state("");
|
||||
let history = $state<string[]>([]);
|
||||
let historyIndex = $state(-1);
|
||||
let inputFocused = $state(false);
|
||||
let caretPos = $state(0);
|
||||
|
||||
const beforeText = $derived(commandValue.slice(0, caretPos));
|
||||
const cursorChar = $derived(commandValue.slice(caretPos, caretPos + 1) || " ");
|
||||
const afterRest = $derived(commandValue.slice(caretPos + 1));
|
||||
const afterFull = $derived(commandValue.slice(caretPos));
|
||||
|
||||
function syncCaret() {
|
||||
caretPos = inputEl?.selectionStart ?? commandValue.length;
|
||||
}
|
||||
|
||||
function recall(value: string) {
|
||||
commandValue = value;
|
||||
caretPos = value.length;
|
||||
}
|
||||
|
||||
function submitCommand() {
|
||||
const value = commandValue;
|
||||
commandValue = "";
|
||||
caretPos = 0;
|
||||
if (value.trim()) history = [...history, value];
|
||||
historyIndex = -1;
|
||||
onCommand?.(value);
|
||||
queueMicrotask(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
submitCommand();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (history.length === 0) return;
|
||||
const nextIndex =
|
||||
historyIndex === -1
|
||||
? history.length - 1
|
||||
: Math.max(0, historyIndex - 1);
|
||||
historyIndex = nextIndex;
|
||||
recall(history[nextIndex]);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (historyIndex === -1) return;
|
||||
const nextIndex = historyIndex + 1;
|
||||
if (nextIndex >= history.length) {
|
||||
historyIndex = -1;
|
||||
recall("");
|
||||
} else {
|
||||
historyIndex = nextIndex;
|
||||
recall(history[nextIndex]);
|
||||
}
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function focusCommandInput(event: MouseEvent) {
|
||||
if (!interactiveInput) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("a, button, input, textarea, .tui-button, .tui-link"))
|
||||
return;
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
// Group consecutive inline items together
|
||||
type ProcessedGroup =
|
||||
| { kind: "single"; index: number; displayed: DisplayedLine }
|
||||
@@ -76,7 +151,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tui-body" bind:this={ref}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="tui-body" bind:this={ref} onclick={focusCommandInput}>
|
||||
{#each processedGroups as group, gi (gi)}
|
||||
{#if group.kind === "inline"}
|
||||
<div class="tui-inline-group">
|
||||
@@ -127,7 +203,41 @@
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
|
||||
{#if interactiveInput}
|
||||
{#if !isTyping}
|
||||
<div class="tui-line prompt tui-command-line">
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}</span><span class="at"
|
||||
>@</span
|
||||
><span class="host">{user.hostname}</span>
|
||||
<span class="separator">:</span><span class="path">~</span><span
|
||||
class="symbol">$</span
|
||||
>
|
||||
</span>
|
||||
<span class="tui-input-wrap">
|
||||
<input
|
||||
class="tui-command-input"
|
||||
bind:this={inputEl}
|
||||
bind:value={commandValue}
|
||||
onkeydown={handleInputKeydown}
|
||||
onkeyup={syncCaret}
|
||||
oninput={syncCaret}
|
||||
onclick={syncCaret}
|
||||
onfocus={() => {
|
||||
inputFocused = true;
|
||||
syncCaret();
|
||||
}}
|
||||
onblur={() => (inputFocused = false)}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
aria-label="Terminal command input"
|
||||
/><span class="tui-input-mirror" aria-hidden="true">{beforeText}{#if inputFocused}<span class="cursor-block blink">{cursorChar}</span>{afterRest}{:else}{afterFull}{/if}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
|
||||
<div class="tui-line prompt">
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}</span><span class="at"
|
||||
@@ -141,3 +251,63 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-command-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tui-input-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tui-command-input {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: transparent;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.tui-command-input:focus,
|
||||
.tui-command-input:focus-visible {
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tui-input-mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.cursor-block {
|
||||
display: inline-block;
|
||||
height: 1.2em;
|
||||
line-height: 1.2em;
|
||||
color: var(--terminal-bg);
|
||||
background: var(--terminal-accent, var(--terminal-primary));
|
||||
}
|
||||
|
||||
.cursor-block.blink {
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { TerminalAPI, TerminalLine } from './types';
|
||||
import { user } from '$lib/config';
|
||||
|
||||
export interface CommandContext {
|
||||
terminal: TerminalAPI;
|
||||
navigate: (path: string, external?: boolean) => void;
|
||||
toggleMode: () => void;
|
||||
setColorTheme: (name: string) => void;
|
||||
getMode: () => string;
|
||||
getColorTheme: () => string;
|
||||
themeNames: string[];
|
||||
user: typeof user;
|
||||
skills: Record<string, string[]>;
|
||||
navigation: Array<{ name: string; path: string; external?: boolean }>;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
name: string;
|
||||
description: string;
|
||||
usage?: string;
|
||||
aliases?: string[];
|
||||
hidden?: boolean;
|
||||
run: (args: string[], ctx: CommandContext) => void;
|
||||
}
|
||||
|
||||
const out = (content: string): TerminalLine => ({ type: 'output', content });
|
||||
const ok = (content: string): TerminalLine => ({ type: 'success', content });
|
||||
const err = (content: string): TerminalLine => ({ type: 'error', content });
|
||||
const info = (content: string): TerminalLine => ({ type: 'info', content });
|
||||
const blank = (): TerminalLine => ({ type: 'blank', content: '' });
|
||||
|
||||
const pageRoutes: Record<string, string> = {
|
||||
home: '/',
|
||||
about: '/about',
|
||||
portfolio: '/portfolio',
|
||||
projects: '/projects',
|
||||
models: '/models',
|
||||
components: '/components',
|
||||
links: '/links'
|
||||
};
|
||||
|
||||
const baseCommands: Command[] = [
|
||||
{
|
||||
name: 'help',
|
||||
aliases: ['h', '?'],
|
||||
description: 'List all available commands',
|
||||
run: (_args, ctx) => {
|
||||
const rows = commands
|
||||
.filter((command) => !command.hidden)
|
||||
.map((command) => [
|
||||
`(&primary,bold)${command.name}(&)` +
|
||||
(command.aliases?.length ? ` (&muted)(${command.aliases.join(', ')})(&)` : ''),
|
||||
command.description
|
||||
]);
|
||||
ctx.terminal.write({
|
||||
type: 'table',
|
||||
content: 'Available Commands',
|
||||
tableHeaders: ['Command', 'Description'],
|
||||
tableRows: rows,
|
||||
style: 'accent'
|
||||
} as TerminalLine);
|
||||
ctx.terminal.write(info('Tip: try (&accent)neofetch(&), (&accent)cd projects(&), or (&accent)theme(&)'));
|
||||
ctx.terminal.write(blank());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
aliases: ['cls'],
|
||||
description: 'Clear the terminal',
|
||||
run: (_args, ctx) => ctx.terminal.clear()
|
||||
},
|
||||
{
|
||||
name: 'whoami',
|
||||
description: 'Show a short bio',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.writeLines([
|
||||
out(`(&primary,bold)${ctx.user.name}(&) (&muted)— ${ctx.user.title}(&)`),
|
||||
out(`(&muted)${ctx.user.bio}(&)`),
|
||||
blank()
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'neofetch',
|
||||
aliases: ['banner'],
|
||||
description: 'Display system info',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.writeLines([
|
||||
out(`(&accent,bold)${ctx.user.username}(&)(&muted)@(&)(&primary,bold)${ctx.user.hostname}(&)`),
|
||||
out('(&muted)------------------------------(&)'),
|
||||
out(`(&blue,bold)Name(&): ${ctx.user.name}`),
|
||||
out(`(&blue,bold)Title(&): ${ctx.user.title}`),
|
||||
out(`(&blue,bold)Location(&): ${ctx.user.location}`),
|
||||
out(`(&blue,bold)Shell(&): tui-terminal`),
|
||||
out(`(&blue,bold)Languages(&): ${ctx.skills.languages.slice(0, 6).join(', ')}`),
|
||||
blank()
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
description: 'List skills by category',
|
||||
run: (_args, ctx) => {
|
||||
const order = ['languages', 'frameworks', 'tools', 'platforms', 'applications', 'databases', 'interests'];
|
||||
const lines: TerminalLine[] = [];
|
||||
for (const key of order) {
|
||||
const values = ctx.skills[key];
|
||||
if (!values?.length) continue;
|
||||
lines.push(info(`(&blue,bold)${key}(&)`));
|
||||
lines.push(out(' ' + values.map((value) => `(&cyan)${value}(&)`).join(' (&muted)•(&) ')));
|
||||
}
|
||||
lines.push(blank());
|
||||
ctx.terminal.writeLines(lines);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'contact',
|
||||
aliases: ['socials'],
|
||||
description: 'Show social links',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.write(info('(&accent,bold)Find me online:(&)'));
|
||||
ctx.terminal.writeLines(
|
||||
ctx.user.socials.map((social) => ({
|
||||
type: 'button',
|
||||
content: social.name,
|
||||
icon: social.icon,
|
||||
href: social.link,
|
||||
style: 'primary',
|
||||
inline: true
|
||||
})) as TerminalLine[]
|
||||
);
|
||||
ctx.terminal.write(blank());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
description: 'Open a mail draft',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.write(ok(`Opening mail to (&accent)${ctx.user.email}(&)`));
|
||||
ctx.navigate(`mailto:${ctx.user.email}`, true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cd',
|
||||
aliases: ['goto', 'open'],
|
||||
description: 'Navigate to a page',
|
||||
usage: 'cd <page>',
|
||||
run: (args, ctx) => {
|
||||
const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||
const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target);
|
||||
if (navItem) {
|
||||
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||
ctx.navigate(navItem.path, navItem.external);
|
||||
return;
|
||||
}
|
||||
if (pageRoutes[target] !== undefined) {
|
||||
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||
ctx.navigate(pageRoutes[target]);
|
||||
return;
|
||||
}
|
||||
ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ls',
|
||||
description: 'List the pages you can visit',
|
||||
run: (_args, ctx) => {
|
||||
const names = Object.keys(pageRoutes);
|
||||
ctx.navigation.forEach((item) => {
|
||||
if (item.external && !names.includes(item.name)) names.push(item.name);
|
||||
});
|
||||
ctx.terminal.write(out(names.map((name) => `(&blue)${name}(&)`).join(' ')));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
description: 'List or set the color theme',
|
||||
usage: 'theme [name]',
|
||||
run: (args, ctx) => {
|
||||
if (!args.length) {
|
||||
ctx.terminal.write(out('Themes: ' + ctx.themeNames.map((name) => `(&blue)${name}(&)`).join(' ')));
|
||||
ctx.terminal.write(info(`Current: (&accent)${ctx.getColorTheme()}(&)`));
|
||||
return;
|
||||
}
|
||||
const name = args[0].toLowerCase();
|
||||
if (ctx.themeNames.includes(name)) {
|
||||
ctx.setColorTheme(name);
|
||||
ctx.terminal.write(ok(`Theme set to (&accent)${name}(&)`));
|
||||
} else {
|
||||
ctx.terminal.write(err(`Unknown theme: ${name}. Available: ${ctx.themeNames.join(', ')}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
description: 'Toggle or set dark/light mode',
|
||||
usage: 'mode [dark|light]',
|
||||
run: (args, ctx) => {
|
||||
const current = ctx.getMode();
|
||||
if (!args.length) {
|
||||
ctx.toggleMode();
|
||||
ctx.terminal.write(ok(`Switched to ${current === 'dark' ? 'light' : 'dark'} mode`));
|
||||
return;
|
||||
}
|
||||
const requested = args[0].toLowerCase();
|
||||
if (requested === 'dark' || requested === 'light') {
|
||||
if (requested !== current) ctx.toggleMode();
|
||||
ctx.terminal.write(ok(`${requested} mode active`));
|
||||
} else {
|
||||
ctx.terminal.write(err('Usage: mode [dark|light]'));
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'Print text back',
|
||||
usage: 'echo <text>',
|
||||
run: (args, ctx) => ctx.terminal.write(out(args.join(' ')))
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
description: 'Show the current date and time',
|
||||
run: (_args, ctx) => ctx.terminal.write(out(new Date().toString()))
|
||||
},
|
||||
{
|
||||
name: 'sudo',
|
||||
hidden: true,
|
||||
description: 'Elevated privileges',
|
||||
run: (_args, ctx) =>
|
||||
ctx.terminal.write(
|
||||
err(`(&error)Nice try.(&) ${ctx.user.username} is not in the sudoers file. This incident will be reported.`)
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'blog',
|
||||
hidden: true,
|
||||
description: 'Open the blog',
|
||||
run: (_args, ctx) => {
|
||||
const blogItem = ctx.navigation.find((item) => item.name.toLowerCase() === 'blog');
|
||||
if (blogItem) ctx.navigate(blogItem.path, blogItem.external);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const pageCommands: Command[] = Object.entries(pageRoutes).map(([name, path]) => ({
|
||||
name,
|
||||
description: `Go to the ${name} page`,
|
||||
hidden: true,
|
||||
run: (_args, ctx) => ctx.navigate(path)
|
||||
}));
|
||||
|
||||
const socialCommands: Command[] = user.socials.map((social) => ({
|
||||
name: social.name.toLowerCase(),
|
||||
description: `Open ${social.name}`,
|
||||
hidden: true,
|
||||
run: (_args, ctx) => ctx.navigate(social.link, true)
|
||||
}));
|
||||
|
||||
export const commands: Command[] = [...baseCommands, ...pageCommands, ...socialCommands];
|
||||
|
||||
function findCommand(name: string): Command | undefined {
|
||||
const lower = name.toLowerCase();
|
||||
return commands.find(
|
||||
(command) => command.name === lower || command.aliases?.some((alias) => alias.toLowerCase() === lower)
|
||||
);
|
||||
}
|
||||
|
||||
export function runCommand(input: string, ctx: CommandContext): void {
|
||||
const raw = input.trim();
|
||||
ctx.terminal.write({ type: 'command', content: raw });
|
||||
if (!raw) return;
|
||||
|
||||
const parts = raw.split(/\s+/);
|
||||
const name = parts[0];
|
||||
const args = parts.slice(1);
|
||||
const command = findCommand(name);
|
||||
|
||||
if (!command) {
|
||||
ctx.terminal.write(err(`command not found: (&bold)${name}(&) — type (&accent)help(&) for a list.`));
|
||||
return;
|
||||
}
|
||||
|
||||
command.run(args, ctx);
|
||||
}
|
||||
@@ -87,6 +87,12 @@ export function createKeyboardHandler(options: KeyboardHandlerOptions) {
|
||||
} = options;
|
||||
|
||||
return function handleKeydown(event: KeyboardEvent): void {
|
||||
// Ignore key events originating from editable fields (e.g. command input)
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip animation on Y key
|
||||
if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) {
|
||||
event.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user