personal_website/src/components/TerminalCommand.tsx
JorySeverijnse cde5f34858 Command History
- Up/Down Arrow Navigation: Users can now press ↑/↓ to navigate through previously executed commands
- History Storage: Commands are stored in state and persist during the session
- Duplicate Prevention: Avoids adding the same command multiple times consecutively
- Reset on Typing: Manual typing resets the history navigation position
 Tab Completion
- Auto-complete: Pressing Tab completes partial commands that match available commands
- Single Match: If only one command matches, it completes the full command
- Multiple Matches: If multiple commands match, displays all possible completions in the terminal output
- Case-insensitive: Works regardless of input case
2025-12-21 13:57:47 +01:00

281 lines
10 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Terminal, X } from 'lucide-react';
import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext';
const commands: Record<string, string> = {
'/home': '/',
'/about': '/about',
'/me': '/about',
'/projects': '/projects',
'/proj': '/projects',
'/resources': '/resources',
'/res': '/resources',
'/links': '/links',
'/faq': '/faq',
'/games': '/games',
'/arcade': '/games',
'/tetris': '/games/tetris',
'/pacman': '/games/pacman',
'/snake': '/games/snake',
'/breakout': '/games/breakout',
'/music': '/music',
'/m': '/music',
'/ai': '/ai',
'/chat': '/ai',
'/achievements': '/achievements',
'/ach': '/achievements',
'/a': '/achievements',
'/credits': '/credits',
'/cred': '/credits',
'/oscilloscope': '/oscilloscope',
'/oscope': '/oscilloscope',
'/o': '/oscilloscope',
};
const helpText = `Available commands:
/home - Navigate to Home
/about, /me - Navigate to About Me
/projects, /proj - Navigate to Projects
/resources, /res - Navigate to Resources
/links - Navigate to Links
/faq - Navigate to FAQ
/games, /arcade - Browse Arcade games
/tetris - Play Tetris
/pacman - Play Pac-Man
/snake - Play Snake
/breakout - Play Breakout
/music, /m - Navigate to Music Player
/ai, /chat - Navigate to AI Chat
/achievements /a - View achievements
/oscilloscope /o - Audio oscilloscope
/credits /cred - View credits
/help, /h - Show this help message
/clear, /c - Clear terminal output`;
const helpHint = `
╔══════════════════════════════════════════╗
║ ...there may be other commands. ║
║ Perhaps a ꃅꀤꈤ꓄ to an easter egg exists? ║
╚══════════════════════════════════════════╝`;
const TerminalCommand = () => {
const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState('');
const [output, setOutput] = useState<string[]>(['Type /help for available commands']);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const outputRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { playSound } = useSettings();
const { unlockAchievement } = useAchievements();
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [output]);
// Keyboard shortcut to toggle terminal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger if already in an input field
const isInputFocused = document.activeElement?.tagName === 'INPUT' ||
document.activeElement?.tagName === 'TEXTAREA';
if (e.key === '`' || (e.ctrlKey && e.key === '/')) {
e.preventDefault();
setIsOpen(prev => !prev);
playSound('beep');
}
// Open terminal when pressing "/" (only if not in input)
if (e.key === '/' && !isOpen && !isInputFocused) {
e.preventDefault();
setIsOpen(true);
playSound('beep');
// Pre-fill with "/" so user can continue typing command
setTimeout(() => setInput('/'), 50);
}
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, playSound]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput) return;
playSound('click');
setOutput(prev => [...prev, `> ${input}`]);
// Add to command history (avoid duplicates of the last command)
setCommandHistory(prev => {
const newHistory = prev.filter(cmd => cmd !== trimmedInput);
return [...newHistory, trimmedInput];
});
setHistoryIndex(-1);
// Unlock terminal user achievement
unlockAchievement('terminal_user');
const lowerInput = trimmedInput.toLowerCase();
if (lowerInput === '/help' || lowerInput === '/h') {
setOutput(prev => [...prev, helpText, '---HINT---' + helpHint]);
} else if (lowerInput === '/hint') {
unlockAchievement('hint_seeker');
setOutput(prev => [...prev,
'Hidden feature detected in system...',
'Hint: Old-school gamers know a certain cheat code.',
'Think NES, 1986, Contra... 30 lives anyone?',
'The sequence uses arrow keys and two letters.'
]);
} else if (lowerInput === '/clear' || lowerInput === '/c') {
setOutput(['Terminal cleared. Type /help for commands.']);
} else if (commands[lowerInput]) {
setOutput(prev => [...prev, `Navigating to ${lowerInput.slice(1)}...`]);
playSound('beep');
setTimeout(() => {
navigate(commands[lowerInput]);
setIsOpen(false);
}, 300);
} else {
setOutput(prev => [...prev, `Command not found: ${lowerInput}`, 'Type /help for available commands']);
}
setInput('');
};
return (
<>
{/* Terminal Toggle Button - aligned with music player */}
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
onClick={() => {
setIsOpen(true);
playSound('beep');
}}
className="fixed bottom-4 right-4 z-[60] p-3 border-2 border-primary bg-background text-primary hover:bg-primary hover:text-background transition-all duration-300 box-glow"
title="Open Terminal (` or Ctrl+/)"
>
<Terminal className="w-5 h-5" />
</motion.button>
{/* Terminal Window */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="fixed bottom-24 right-4 z-[200] w-[90vw] max-w-md border-2 border-primary bg-background/95 backdrop-blur-sm box-glow-strong"
>
{/* Terminal Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-primary bg-primary/10">
<div className="flex items-center gap-2">
<Terminal size={16} className="text-primary" />
<span className="font-minecraft text-sm text-primary text-glow">terminal@fbi.gov</span>
</div>
<button
onClick={() => setIsOpen(false)}
aria-label="Close terminal"
className="text-primary hover:text-primary/70 transition-colors"
>
<X size={16} />
</button>
</div>
{/* Terminal Output */}
<div
ref={outputRef}
className="h-48 overflow-y-auto p-4 font-mono text-sm text-primary/90"
>
{output.map((line, i) => (
<div key={i} className={`whitespace-pre-wrap mb-1 ${line.startsWith('---HINT---') ? 'text-yellow-400 animate-pulse' : ''}`}>
{line.startsWith('---HINT---') ? line.replace('---HINT---', '') : line}
</div>
))}
</div>
{/* Terminal Input */}
<form onSubmit={handleSubmit} className="border-t border-primary/50">
<div className="flex items-center px-4 py-3">
<span className="text-primary font-mono mr-2">{'>'}</span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setHistoryIndex(-1); // Reset history navigation when typing
}}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
if (commandHistory.length > 0) {
const newIndex = historyIndex === -1 ? commandHistory.length - 1 : Math.max(0, historyIndex - 1);
setHistoryIndex(newIndex);
setInput(commandHistory[newIndex]);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex >= 0) {
const newIndex = historyIndex + 1;
if (newIndex >= commandHistory.length) {
setHistoryIndex(-1);
setInput('');
} else {
setHistoryIndex(newIndex);
setInput(commandHistory[newIndex]);
}
}
} else if (e.key === 'Tab') {
e.preventDefault();
const currentInput = input.trim().toLowerCase();
if (currentInput) {
// Find commands that start with current input
const matches = Object.keys(commands).filter(cmd => cmd.startsWith(currentInput));
if (matches.length === 1) {
setInput(matches[0]);
} else if (matches.length > 1) {
setOutput(prev => [...prev, `Possible completions: ${matches.join(', ')}`]);
}
}
}
}}
className="flex-1 bg-transparent border-none outline-none font-mono text-primary placeholder-primary/40"
placeholder="Enter command..."
autoComplete="off"
spellCheck={false}
/>
<span className="text-primary animate-pulse">_</span>
</div>
</form>
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default TerminalCommand;