Merge pull request 'Adding Terminal' (#2) from terminal into main

Reviewed-on: atridad/atri.dad#2
This commit is contained in:
2025-06-04 06:48:24 +00:00
4 changed files with 829 additions and 1 deletions

View File

@ -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
View 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(/&nbsp;/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;

View 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
View 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>