Merge pull request 'Adding Terminal' (#2) from terminal into main
Reviewed-on: atridad/atri.dad#2
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
import { useComputed, useSignal } from "@preact/signals";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { Home, NotebookPen, FileText, CodeXml } from 'lucide-preact';
|
||||
import { Home, NotebookPen, FileText, CodeXml, Terminal as TerminalIcon } from 'lucide-preact';
|
||||
|
||||
interface NavigationBarProps {
|
||||
currentPath: string;
|
||||
@ -132,6 +132,17 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mx-0.5 sm:mx-1">
|
||||
<a
|
||||
href="/terminal"
|
||||
class={normalizedPath === "/terminal" ? "menu-active" : ""}
|
||||
>
|
||||
<div class="tooltip" data-tip="Terminal">
|
||||
<TerminalIcon size={18} class="sm:w-5 sm:h-5" />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
750
src/components/Terminal.tsx
Normal file
750
src/components/Terminal.tsx
Normal file
@ -0,0 +1,750 @@
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { JSX } from 'preact';
|
||||
|
||||
interface FileSystemNode {
|
||||
type: 'directory' | 'file';
|
||||
name: string;
|
||||
content?: string;
|
||||
children?: { [key: string]: FileSystemNode };
|
||||
}
|
||||
|
||||
interface ResumeData {
|
||||
basics: {
|
||||
name: string;
|
||||
email: string;
|
||||
url?: { href: string };
|
||||
};
|
||||
sections: {
|
||||
summary: { name: string; content: string };
|
||||
profiles: { name: string; items: { network: string; username: string; url: { href: string } }[] };
|
||||
skills: { name: string; items: { id: string; name: string; level: number }[] };
|
||||
experience: { name: string; items: { id: string; company: string; position: string; date: string; location: string; summary: string; url?: { href: string } }[] };
|
||||
education: { name: string; items: { id: string; institution: string; studyType: string; area: string; date: string; summary: string }[] };
|
||||
volunteer: { name: string; items: { id: string; organization: string; position: string; date: string }[] };
|
||||
};
|
||||
}
|
||||
|
||||
interface Command {
|
||||
input: string;
|
||||
output: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
const Terminal = () => {
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [commandHistory, setCommandHistory] = useState<Command[]>([
|
||||
{
|
||||
input: '',
|
||||
output: 'Welcome to Atridad\'s Shell!\nType "help" to see available commands.\n',
|
||||
timestamp: new Date()
|
||||
}
|
||||
]);
|
||||
const [currentInput, setCurrentInput] = useState('');
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [fileSystem, setFileSystem] = useState<{ [key: string]: FileSystemNode }>({});
|
||||
const [lastTabInput, setLastTabInput] = useState('');
|
||||
const [showCompletions, setShowCompletions] = useState(false);
|
||||
const [completionsList, setCompletionsList] = useState<string[]>([]);
|
||||
const [isTrainRunning, setIsTrainRunning] = useState(false);
|
||||
const [trainPosition, setTrainPosition] = useState(100);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
||||
}
|
||||
}, [commandHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeFileSystem = async () => {
|
||||
try {
|
||||
const response = await fetch('/files/resume.json');
|
||||
const resumeData: ResumeData = await response.json();
|
||||
|
||||
// Fetch blog posts
|
||||
const postsResponse = await fetch('/api/posts.json');
|
||||
let postsData = [];
|
||||
try {
|
||||
postsData = await postsResponse.json();
|
||||
} catch (error) {
|
||||
console.log('Could not fetch posts data:', error);
|
||||
}
|
||||
|
||||
// Build resume files from actual data
|
||||
const resumeFiles: { [key: string]: FileSystemNode } = {};
|
||||
|
||||
if (resumeData.sections.summary) {
|
||||
resumeFiles['summary.txt'] = {
|
||||
type: 'file',
|
||||
name: 'summary.txt',
|
||||
content: resumeData.sections.summary.content.replace(/<[^>]*>/g, '')
|
||||
};
|
||||
}
|
||||
|
||||
if (resumeData.sections.skills?.items) {
|
||||
const skillsContent = resumeData.sections.skills.items
|
||||
.map(skill => `${skill.name} (Level: ${skill.level}/5)`)
|
||||
.join('\n');
|
||||
resumeFiles['skills.txt'] = {
|
||||
type: 'file',
|
||||
name: 'skills.txt',
|
||||
content: skillsContent
|
||||
};
|
||||
}
|
||||
|
||||
if (resumeData.sections.experience?.items) {
|
||||
const experienceContent = resumeData.sections.experience.items
|
||||
.map(exp => {
|
||||
const summary = exp.summary.replace(/<[^>]*>/g, '').replace(/ /g, ' ');
|
||||
return `${exp.position} at ${exp.company}\n${exp.date} | ${exp.location}\n${summary}\n${exp.url?.href ? `URL: ${exp.url.href}` : ''}\n`;
|
||||
})
|
||||
.join('\n---\n\n');
|
||||
resumeFiles['experience.txt'] = {
|
||||
type: 'file',
|
||||
name: 'experience.txt',
|
||||
content: experienceContent
|
||||
};
|
||||
}
|
||||
|
||||
if (resumeData.sections.education?.items) {
|
||||
const educationContent = resumeData.sections.education.items
|
||||
.map(edu => `${edu.institution}\n${edu.studyType} - ${edu.area}\n${edu.date}\n${edu.summary ? edu.summary.replace(/<[^>]*>/g, '') : ''}`)
|
||||
.join('\n\n---\n\n');
|
||||
resumeFiles['education.txt'] = {
|
||||
type: 'file',
|
||||
name: 'education.txt',
|
||||
content: educationContent
|
||||
};
|
||||
}
|
||||
|
||||
if (resumeData.sections.volunteer?.items) {
|
||||
const volunteerContent = resumeData.sections.volunteer.items
|
||||
.map(vol => `${vol.organization}\n${vol.position}\n${vol.date}`)
|
||||
.join('\n\n---\n\n');
|
||||
resumeFiles['volunteer.txt'] = {
|
||||
type: 'file',
|
||||
name: 'volunteer.txt',
|
||||
content: volunteerContent
|
||||
};
|
||||
}
|
||||
|
||||
// Build posts files from actual blog posts
|
||||
const postsFiles: { [key: string]: FileSystemNode } = {
|
||||
'README.txt': {
|
||||
type: 'file',
|
||||
name: 'README.txt',
|
||||
content: 'Blog posts and articles.\n\nUse "open /posts" to see the full list on the website.\n\nAvailable posts:\n' +
|
||||
postsData.map((post: any) => `- ${post.slug}.md`).join('\n')
|
||||
}
|
||||
};
|
||||
|
||||
// Add each blog post as a markdown file
|
||||
postsData.forEach((post: any) => {
|
||||
const fileName = `${post.slug}.md`;
|
||||
let content = `---
|
||||
title: "${post.title}"
|
||||
description: "${post.description}"
|
||||
pubDate: "${post.pubDate}"
|
||||
tags: [${post.tags.map((tag: string) => `"${tag}"`).join(', ')}]
|
||||
---
|
||||
|
||||
${post.content}`;
|
||||
|
||||
postsFiles[fileName] = {
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
content
|
||||
};
|
||||
});
|
||||
|
||||
// Build contact info from resume profiles
|
||||
const contactContent = [
|
||||
`Email: ${resumeData.basics.email}`,
|
||||
'',
|
||||
'Social Profiles:',
|
||||
...resumeData.sections.profiles.items.map(profile =>
|
||||
`${profile.network}: ${profile.url.href}`
|
||||
)
|
||||
].join('\n');
|
||||
|
||||
const fs: { [key: string]: FileSystemNode } = {
|
||||
'/': {
|
||||
type: 'directory',
|
||||
name: '/',
|
||||
children: {
|
||||
'about.txt': {
|
||||
type: 'file',
|
||||
name: 'about.txt',
|
||||
content: `${resumeData.basics.name}\nResearcher, Full-Stack Developer, and IT Professional.\n\nA passionate technologist who loves building things and solving problems.\n\nVisit other sections to learn more about my work and experience.`
|
||||
},
|
||||
'resume': {
|
||||
type: 'directory',
|
||||
name: 'resume',
|
||||
children: resumeFiles
|
||||
},
|
||||
'posts': {
|
||||
type: 'directory',
|
||||
name: 'posts',
|
||||
children: postsFiles
|
||||
},
|
||||
'projects': {
|
||||
type: 'directory',
|
||||
name: 'projects',
|
||||
children: {
|
||||
'README.txt': {
|
||||
type: 'file',
|
||||
name: 'README.txt',
|
||||
content: 'Personal and professional projects.\n\nUse "open /projects" to see the full portfolio on the website.'
|
||||
}
|
||||
}
|
||||
},
|
||||
'contact.txt': {
|
||||
type: 'file',
|
||||
name: 'contact.txt',
|
||||
content: contactContent
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setFileSystem(fs);
|
||||
} catch (error) {
|
||||
console.error('Error loading resume data:', error);
|
||||
// Fallback to basic file system
|
||||
setFileSystem({
|
||||
'/': {
|
||||
type: 'directory',
|
||||
name: '/',
|
||||
children: {
|
||||
'about.txt': {
|
||||
type: 'file',
|
||||
name: 'about.txt',
|
||||
content: 'Atridad Lahiji\nResearcher, Full-Stack Developer, and IT Professional.\n\nError loading detailed information. Please check the website directly.'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
initializeFileSystem();
|
||||
}, []);
|
||||
|
||||
const getCurrentDirectory = (): FileSystemNode => {
|
||||
const pathParts = currentPath.split('/').filter((part: string) => part !== '');
|
||||
let current = fileSystem['/'];
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (current?.children && current.children[part] && current.children[part].type === 'directory') {
|
||||
current = current.children[part];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const resolvePath = (path: string): string => {
|
||||
if (path.startsWith('/')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const currentParts = currentPath.split('/').filter((part: string) => part !== '');
|
||||
const pathParts = path.split('/');
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (part === '..') {
|
||||
currentParts.pop();
|
||||
} else if (part !== '.' && part !== '') {
|
||||
currentParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return '/' + currentParts.join('/');
|
||||
};
|
||||
|
||||
const getCommonPrefix = (strings: string[]): string => {
|
||||
if (strings.length === 0) return '';
|
||||
if (strings.length === 1) return strings[0];
|
||||
|
||||
let commonPrefix = strings[0];
|
||||
for (let i = 1; i < strings.length; i++) {
|
||||
let j = 0;
|
||||
while (j < commonPrefix.length && j < strings[i].length && commonPrefix[j] === strings[i][j]) {
|
||||
j++;
|
||||
}
|
||||
commonPrefix = commonPrefix.substring(0, j);
|
||||
if (commonPrefix === '') break;
|
||||
}
|
||||
return commonPrefix;
|
||||
};
|
||||
|
||||
const getCompletions = (input: string): { completions: string[], prefix: string, replaceFrom: number } => {
|
||||
const parts = input.trim().split(' ');
|
||||
const command = parts[0];
|
||||
const partialPath = parts[parts.length - 1] || '';
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Command completion
|
||||
const commands = ['ls', 'cd', 'cat', 'pwd', 'clear', 'tree', 'whoami', 'open', 'help'];
|
||||
const matches = commands.filter(cmd => cmd.startsWith(command));
|
||||
return { completions: matches, prefix: command, replaceFrom: 0 };
|
||||
}
|
||||
|
||||
if (['ls', 'cd', 'cat', 'open'].includes(command)) {
|
||||
// Path completion
|
||||
const isAbsolute = partialPath.startsWith('/');
|
||||
const pathToComplete = isAbsolute ? partialPath : resolvePath(partialPath);
|
||||
|
||||
// Find the directory to search in and the prefix to match
|
||||
let dirPath: string;
|
||||
let searchPrefix: string;
|
||||
|
||||
if (pathToComplete.endsWith('/')) {
|
||||
// Path ends with slash - complete inside this directory
|
||||
dirPath = pathToComplete;
|
||||
searchPrefix = '';
|
||||
} else {
|
||||
// Base case - find directory and prefix
|
||||
const lastSlash = pathToComplete.lastIndexOf('/');
|
||||
if (lastSlash >= 0) {
|
||||
dirPath = pathToComplete.substring(0, lastSlash + 1);
|
||||
searchPrefix = pathToComplete.substring(lastSlash + 1);
|
||||
} else {
|
||||
dirPath = currentPath.endsWith('/') ? currentPath : currentPath + '/';
|
||||
searchPrefix = pathToComplete;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate where to start replacement in the original input
|
||||
const spaceBeforeArg = input.lastIndexOf(' ');
|
||||
const replaceFrom = spaceBeforeArg >= 0 ? spaceBeforeArg + 1 : 0;
|
||||
|
||||
// Navigate to the directory
|
||||
const dirParts = dirPath.split('/').filter((part: string) => part !== '');
|
||||
let current = fileSystem['/'];
|
||||
|
||||
for (const part of dirParts) {
|
||||
if (current?.children && current.children[part] && current.children[part].type === 'directory') {
|
||||
current = current.children[part];
|
||||
} else {
|
||||
return { completions: [], prefix: searchPrefix, replaceFrom };
|
||||
}
|
||||
}
|
||||
|
||||
if (!current?.children) {
|
||||
return { completions: [], prefix: searchPrefix, replaceFrom };
|
||||
}
|
||||
|
||||
// Get matching items
|
||||
const matches = Object.keys(current.children)
|
||||
.filter(name => name.startsWith(searchPrefix))
|
||||
.map(name => {
|
||||
const item = current.children![name];
|
||||
return item.type === 'directory' ? `${name}/` : name;
|
||||
});
|
||||
|
||||
return { completions: matches, prefix: searchPrefix, replaceFrom };
|
||||
}
|
||||
|
||||
return { completions: [], prefix: '', replaceFrom: input.length };
|
||||
};
|
||||
|
||||
const executeCommand = (input: string): string => {
|
||||
const trimmedInput = input.trim();
|
||||
if (!trimmedInput) return '';
|
||||
|
||||
const [command, ...args] = trimmedInput.split(' ');
|
||||
|
||||
switch (command.toLowerCase()) {
|
||||
case 'help':
|
||||
return `Available commands:
|
||||
ls [path] - List directory contents
|
||||
cd <path> - Change directory
|
||||
cat <file> - Display file contents
|
||||
pwd - Show current directory
|
||||
clear - Clear terminal
|
||||
tree - Show directory structure
|
||||
whoami - Display user info
|
||||
open <path> - Open page in browser (simulated)
|
||||
help - Show this help message
|
||||
|
||||
Navigation:
|
||||
Use "cd .." to go up one directory
|
||||
Use "cd /" to go to root directory
|
||||
File paths can be relative or absolute
|
||||
Use TAB for auto-completion
|
||||
|
||||
Examples:
|
||||
ls
|
||||
cd resume
|
||||
cat about.txt
|
||||
open /resume`;
|
||||
|
||||
case 'ls': {
|
||||
const targetPath = args[0] ? resolvePath(args[0]) : currentPath;
|
||||
const pathParts = targetPath.split('/').filter((part: string) => part !== '');
|
||||
let target = fileSystem['/'];
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (target?.children && target.children[part] && target.children[part].type === 'directory') {
|
||||
target = target.children[part];
|
||||
} else if (pathParts.length > 0) {
|
||||
return `ls: cannot access '${targetPath}': No such file or directory`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target?.children) {
|
||||
return `ls: cannot access '${targetPath}': Not a directory`;
|
||||
}
|
||||
|
||||
const items = Object.values(target.children)
|
||||
.map(item => {
|
||||
const color = item.type === 'directory' ? '\x1b[34m' : '\x1b[0m';
|
||||
const suffix = item.type === 'directory' ? '/' : '';
|
||||
return `${color}${item.name}${suffix}\x1b[0m`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
return items || 'Directory is empty';
|
||||
}
|
||||
|
||||
case 'cd': {
|
||||
const targetPath = args[0] ? resolvePath(args[0]) : '/';
|
||||
const pathParts = targetPath.split('/').filter((part: string) => part !== '');
|
||||
let current = fileSystem['/'];
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (current?.children && current.children[part] && current.children[part].type === 'directory') {
|
||||
current = current.children[part];
|
||||
} else {
|
||||
return `cd: no such file or directory: ${targetPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentPath(targetPath || '/');
|
||||
return '';
|
||||
}
|
||||
|
||||
case 'pwd':
|
||||
return currentPath || '/';
|
||||
|
||||
case 'cat': {
|
||||
if (!args[0]) {
|
||||
return 'cat: missing file argument';
|
||||
}
|
||||
|
||||
const filePath = resolvePath(args[0]);
|
||||
const pathParts = filePath.split('/').filter((part: string) => part !== '');
|
||||
const fileName = pathParts.pop();
|
||||
|
||||
let current = fileSystem['/'];
|
||||
for (const part of pathParts) {
|
||||
if (current?.children && current.children[part] && current.children[part].type === 'directory') {
|
||||
current = current.children[part];
|
||||
} else {
|
||||
return `cat: ${filePath}: No such file or directory`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileName || !current?.children || !current.children[fileName]) {
|
||||
return `cat: ${filePath}: No such file or directory`;
|
||||
}
|
||||
|
||||
const file = current.children[fileName];
|
||||
if (file.type !== 'file') {
|
||||
return `cat: ${filePath}: Is a directory`;
|
||||
}
|
||||
|
||||
return file.content || '';
|
||||
}
|
||||
|
||||
case 'tree': {
|
||||
const buildTree = (node: FileSystemNode, prefix: string = '', isLast: boolean = true): string => {
|
||||
let result = '';
|
||||
if (!node.children) return result;
|
||||
|
||||
const entries = Object.entries(node.children);
|
||||
entries.forEach(([name, child], index) => {
|
||||
const isLastChild = index === entries.length - 1;
|
||||
const connector = isLastChild ? '└── ' : '├── ';
|
||||
const color = child.type === 'directory' ? '\x1b[34m' : '\x1b[0m';
|
||||
const suffix = child.type === 'directory' ? '/' : '';
|
||||
|
||||
result += `${prefix}${connector}${color}${name}${suffix}\x1b[0m\n`;
|
||||
|
||||
if (child.type === 'directory') {
|
||||
const newPrefix = prefix + (isLastChild ? ' ' : '│ ');
|
||||
result += buildTree(child, newPrefix, isLastChild);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return '.\n' + buildTree(fileSystem['/']);
|
||||
}
|
||||
|
||||
case 'clear':
|
||||
setCommandHistory([]);
|
||||
return '';
|
||||
|
||||
case 'whoami':
|
||||
return 'guest@atri.dad';
|
||||
|
||||
case 'open': {
|
||||
const path = args[0];
|
||||
if (!path) {
|
||||
return 'open: missing path argument';
|
||||
}
|
||||
|
||||
let url = '';
|
||||
if (path === '/resume' || path.startsWith('/resume')) {
|
||||
url = '/resume';
|
||||
} else if (path === '/projects' || path.startsWith('/projects')) {
|
||||
url = '/projects';
|
||||
} else if (path === '/posts' || path.startsWith('/posts')) {
|
||||
url = '/posts';
|
||||
} else if (path === '/' || path === '/about.txt') {
|
||||
url = '/';
|
||||
} else {
|
||||
return `open: cannot open '${path}': No associated page`;
|
||||
}
|
||||
|
||||
window.open(url, '_blank');
|
||||
return `Opening ${url} in new tab...`;
|
||||
}
|
||||
|
||||
case 'sl':
|
||||
setIsTrainRunning(true);
|
||||
setTrainPosition(100);
|
||||
|
||||
const animateTrain = () => {
|
||||
let position = 100;
|
||||
const interval = setInterval(() => {
|
||||
position -= 1.5;
|
||||
setTrainPosition(position);
|
||||
|
||||
if (position < -20) {
|
||||
clearInterval(interval);
|
||||
setIsTrainRunning(false);
|
||||
}
|
||||
}, 60);
|
||||
};
|
||||
|
||||
setTimeout(animateTrain, 100);
|
||||
return '';
|
||||
|
||||
default:
|
||||
return `${command}: command not found. Type 'help' for available commands.`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: JSX.TargetedEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const output = executeCommand(currentInput);
|
||||
const newCommand: Command = {
|
||||
input: currentInput,
|
||||
output,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
if (currentInput.trim().toLowerCase() === 'clear') {
|
||||
setCommandHistory([]);
|
||||
} else {
|
||||
setCommandHistory((prev: Command[]) => [...prev, newCommand]);
|
||||
}
|
||||
|
||||
setCurrentInput('');
|
||||
setHistoryIndex(-1);
|
||||
setShowCompletions(false);
|
||||
setLastTabInput('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: JSX.TargetedKeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
|
||||
const { completions, prefix, replaceFrom } = getCompletions(currentInput);
|
||||
|
||||
if (completions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (completions.length === 1) {
|
||||
// Single completion - replace from the correct position
|
||||
const beforeReplacement = currentInput.substring(0, replaceFrom);
|
||||
const completion = completions[0];
|
||||
const newInput = beforeReplacement + completion;
|
||||
|
||||
setCurrentInput(newInput + (completion.endsWith('/') ? '' : ' '));
|
||||
setShowCompletions(false);
|
||||
setLastTabInput('');
|
||||
} else {
|
||||
// Multiple completions - find common prefix
|
||||
const commonPrefix = getCommonPrefix(completions);
|
||||
|
||||
if (commonPrefix.length > prefix.length) {
|
||||
// Complete to common prefix
|
||||
const beforeReplacement = currentInput.substring(0, replaceFrom);
|
||||
setCurrentInput(beforeReplacement + commonPrefix);
|
||||
setShowCompletions(false);
|
||||
setLastTabInput(currentInput);
|
||||
} else {
|
||||
// Show completions if we hit tab twice on the same input
|
||||
if (currentInput === lastTabInput && !showCompletions) {
|
||||
setCompletionsList(completions);
|
||||
setShowCompletions(true);
|
||||
|
||||
// Add completions to command history for display
|
||||
const output = completions.length > 8
|
||||
? completions.slice(0, 8).join(' ') + ` ... (${completions.length - 8} more)`
|
||||
: completions.join(' ');
|
||||
|
||||
const newCommand: Command = {
|
||||
input: '',
|
||||
output,
|
||||
timestamp: new Date()
|
||||
};
|
||||
setCommandHistory((prev: Command[]) => [...prev, newCommand]);
|
||||
} else {
|
||||
setLastTabInput(currentInput);
|
||||
setShowCompletions(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const inputCommands = commandHistory.filter((cmd: Command) => cmd.input.trim() !== '');
|
||||
if (inputCommands.length > 0) {
|
||||
const newIndex = historyIndex === -1 ? inputCommands.length - 1 : Math.max(0, historyIndex - 1);
|
||||
setHistoryIndex(newIndex);
|
||||
setCurrentInput(inputCommands[newIndex].input);
|
||||
}
|
||||
setShowCompletions(false);
|
||||
setLastTabInput('');
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const inputCommands = commandHistory.filter((cmd: Command) => cmd.input.trim() !== '');
|
||||
if (historyIndex !== -1) {
|
||||
const newIndex = Math.min(inputCommands.length - 1, historyIndex + 1);
|
||||
if (newIndex === inputCommands.length - 1 && historyIndex === newIndex) {
|
||||
setHistoryIndex(-1);
|
||||
setCurrentInput('');
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setCurrentInput(inputCommands[newIndex].input);
|
||||
}
|
||||
}
|
||||
setShowCompletions(false);
|
||||
setLastTabInput('');
|
||||
} else {
|
||||
// Reset completion state on any other key
|
||||
setShowCompletions(false);
|
||||
setLastTabInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const formatOutput = (text: string): string => {
|
||||
return text
|
||||
.replace(/\x1b\[34m/g, '<span class="text-primary">')
|
||||
.replace(/\x1b\[0m/g, '</span>');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-base-100 text-base-content font-mono text-sm h-full flex flex-col rounded-lg border-2 border-primary shadow-2xl">
|
||||
<div className="bg-base-200 px-4 py-2 rounded-t-lg border-b border-base-300">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-error rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-warning rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-success rounded-full"></div>
|
||||
<span className="ml-4 text-base-content/70 text-xs">guest@atri.dad: {currentPath}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 p-4 overflow-hidden scrollbar-thin scrollbar-thumb-base-300 scrollbar-track-base-100 relative"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{isTrainRunning && (
|
||||
<div
|
||||
className="absolute top-1/2 transform -translate-y-1/2 z-10 pointer-events-none text-base-content font-mono text-xs whitespace-nowrap"
|
||||
style={{
|
||||
left: `${trainPosition}%`,
|
||||
transition: 'none'
|
||||
}}
|
||||
>
|
||||
<pre className="leading-none">{`
|
||||
==== ________ ___________
|
||||
_D _| |_______/ \\__I_I_____===__|_________|
|
||||
|(_)--- | H\\________/ | | =|___ ___| _________________
|
||||
/ | | H | | | | ||_| |_|| _| \\_____A
|
||||
| | | H |__--------------------| [___] | =| |
|
||||
| ________|___H__/__|_____/[][]~\\_______| | -| |
|
||||
|/ | |-----------I_____I [][] [] D |=======|____|________________________|_
|
||||
__/ =| o |=-O=====O=====O=====O \\ ____Y___________|__|__________________________|_
|
||||
|/-=|___|= || || || |_____/~\\___/ |_D__D__D_| |_D__D__D_|
|
||||
\\_/ \\__/ \\__/ \\__/ \\__/ \\_/ \\_/ \\_/ \\_/ \\_/`}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isTrainRunning && (
|
||||
<div className="overflow-y-auto h-full">
|
||||
{commandHistory.map((command: Command, index: number) => (
|
||||
<div key={index} className="mb-2">
|
||||
{command.input && (
|
||||
<div className="flex items-center">
|
||||
<span className="text-primary font-semibold">guest@atri.dad</span>
|
||||
<span className="text-base-content">:</span>
|
||||
<span className="text-secondary font-semibold">{currentPath}</span>
|
||||
<span className="text-base-content">$ </span>
|
||||
<span className="text-accent">{command.input}</span>
|
||||
</div>
|
||||
)}
|
||||
{command.output && (
|
||||
<div
|
||||
className="whitespace-pre-wrap text-base-content/80 mt-1"
|
||||
dangerouslySetInnerHTML={{ __html: formatOutput(command.output) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showCompletions && (
|
||||
<div className="text-secondary text-sm mb-2">
|
||||
Tab completions: {completionsList.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-center">
|
||||
<span className="text-primary font-semibold">guest@atri.dad</span>
|
||||
<span className="text-base-content">:</span>
|
||||
<span className="text-secondary font-semibold">{currentPath}</span>
|
||||
<span className="text-base-content">$ </span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={currentInput}
|
||||
onInput={(e) => setCurrentInput((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 bg-transparent border-none outline-none text-accent ml-1"
|
||||
autoFocus
|
||||
spellcheck={false}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Terminal;
|
42
src/pages/api/posts.json.ts
Normal file
42
src/pages/api/posts.json.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
try {
|
||||
const posts = await getCollection('posts');
|
||||
|
||||
// Get the raw content from each post
|
||||
const postsWithContent = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
const { Content } = await post.render();
|
||||
|
||||
// Get the raw markdown content by reading the file
|
||||
const rawContent = post.body;
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
pubDate: post.data.pubDate.toISOString().split('T')[0],
|
||||
tags: post.data.tags || [],
|
||||
content: rawContent
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(postsWithContent), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error);
|
||||
return new Response(JSON.stringify([]), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
25
src/pages/terminal.astro
Normal file
25
src/pages/terminal.astro
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import TerminalComponent from '../components/Terminal.tsx';
|
||||
import '../styles/global.css';
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="container mx-auto p-4 max-w-6xl w-full">
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="text-2xl font-bold mb-2">Shell Mode</h1>
|
||||
<p class="text-base-content/70">Type 'help' to get started.</p>
|
||||
</div>
|
||||
<div class="h-[60vh] max-h-[500px] min-h-[400px]">
|
||||
<TerminalComponent client:load />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* Remove overflow hidden from body to allow normal scrolling */
|
||||
html, body {
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user