Files
MingdaOS/src/apps/Terminal.tsx

180 lines
5.6 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import type { KeyboardEvent } from 'react';
interface Line { type: 'input' | 'output'; text: string; key: number }
const COMMANDS: Record<string, string[]> = {
help: [
'Available commands:',
' help — show this list',
' about — who is mingda',
' projects — list projects',
' skills — tech stack',
' contact — contact info',
' neofetch — system info',
' clear — clear terminal',
],
about: [
'Mingda Xie (解明达)',
'Backend Developer · CS Master\'s @ Northeastern University',
'Shenzhen-bound · Class of 2026',
'',
'Runs a personal k3s cluster. Writes Go and Java at 2am.',
'LeetCode 400+. Pixel art. Stardew Valley. 土屋アンナ.',
'',
'"I am the dragon scroll, bitch."',
],
projects: [
'Projects:',
' CampusNest — Spring Cloud housing platform',
' Alfred Bot — Self-hosted AI agent',
' Warden/Bastion — k3s auth + proxy layer',
' Collab Platform— Real-time collaborative workspace',
' 成语填空 — Daily Chinese idiom game',
],
skills: [
'Tech Stack:',
' Languages : Java, Go, TypeScript, Python',
' Frameworks : Spring Cloud, Bun, React',
' Infra : k3s, Nginx, Redis, Docker',
' Cloud : Vultr, Let\'s Encrypt',
' Other : Microservices, WebSocket, LLM APIs',
],
contact: [
'Contact:',
' GitHub : github.com/m1ngdaxie',
' Web : m1ngdaxie.com',
' Game : chengyu.m1ngdaxie.com',
' Email : mingda@m1ngdaxie.com',
],
neofetch: [
' . mingda@doitou-sv',
' .:. ----------------',
' .:!:. OS: MingdaOS 1.0',
' .:!!!:. Host: Vultr VPS',
' .::!!!::. Kernel: Ubuntu 24.04',
' .:::!!!:::. RAM: 4GB',
' .::::!!!::::. Shell: zsh',
' .:::::!!!:::::. Services: Alfred, Chengyu, k3s, VLESS',
'',
' nginx + k3s + let\'s encrypt',
],
};
const PROMPT = 'mingda@doitou-sv ~ % ';
let lineCounter = 0; // module-level counter for stable unique keys
function nextKey() { return ++lineCounter; }
export default function Terminal() {
const [lines, setLines] = useState<Line[]>([
{ type: 'output', text: 'MingdaOS Terminal v1.0', key: nextKey() },
{ type: 'output', text: 'Type "help" to see available commands.', key: nextKey() },
{ type: 'output', text: '', key: nextKey() },
]);
const [input, setInput] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [lines]);
useEffect(() => {
inputRef.current?.focus();
}, []);
function handleSubmit() {
const cmd = input.trim().toLowerCase();
const inputLine: Line = { type: 'input', text: PROMPT + input, key: nextKey() };
if (cmd === 'clear') {
setLines([]);
} else if (cmd === '') {
setLines(prev => [...prev, inputLine, { type: 'output', text: '', key: nextKey() }]);
} else if (COMMANDS[cmd]) {
const outputLines: Line[] = COMMANDS[cmd].map(l => ({ type: 'output' as const, text: l, key: nextKey() }));
outputLines.push({ type: 'output', text: '', key: nextKey() });
setLines(prev => [...prev, inputLine, ...outputLines]);
} else {
setLines(prev => [...prev, inputLine,
{ type: 'output', text: `zsh: command not found: ${cmd}`, key: nextKey() },
{ type: 'output', text: '', key: nextKey() }
]);
}
if (cmd) setHistory(h => [cmd, ...h].slice(0, 50));
setHistIdx(-1);
setInput('');
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
handleSubmit();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const next = Math.min(histIdx + 1, history.length - 1);
setHistIdx(next);
if (history[next] !== undefined) setInput(history[next]);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.max(histIdx - 1, -1);
setHistIdx(next);
setInput(next === -1 ? '' : history[next]);
}
}
return (
<div
style={{
fontFamily: 'var(--font-mono)',
fontSize: 13,
color: '#e2e8f0',
background: 'transparent',
padding: '12px 16px',
height: '100%',
display: 'flex',
flexDirection: 'column',
cursor: 'text',
}}
onClick={() => inputRef.current?.focus()}
>
<div style={{ flex: 1, overflowY: 'auto' }}>
{lines.map((line) => (
<div key={line.key} style={{
color: line.type === 'input' ? '#00ffff' : '#e2e8f0',
whiteSpace: 'pre',
lineHeight: 1.6,
}}>
{line.text}
</div>
))}
<div ref={bottomRef} />
</div>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 4 }}>
<span style={{ color: '#00ffff', whiteSpace: 'nowrap' }}>{PROMPT}</span>
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#e2e8f0',
fontFamily: 'var(--font-mono)',
fontSize: 13,
caretColor: '#00ffff',
}}
spellCheck={false}
autoComplete="off"
/>
</div>
</div>
);
}