diff --git a/src/components/NavigationBar.tsx b/src/components/NavigationBar.tsx index 3e44656..59bccb7 100644 --- a/src/components/NavigationBar.tsx +++ b/src/components/NavigationBar.tsx @@ -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) { + +
  • + +
    + +
    +
    +
  • diff --git a/src/components/Terminal.tsx b/src/components/Terminal.tsx new file mode 100644 index 0000000..9eb8d17 --- /dev/null +++ b/src/components/Terminal.tsx @@ -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([ + { + 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([]); + const [isTrainRunning, setIsTrainRunning] = useState(false); + const [trainPosition, setTrainPosition] = useState(100); + const inputRef = useRef(null); + const terminalRef = useRef(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 - Change directory + cat - Display file contents + pwd - Show current directory + clear - Clear terminal + tree - Show directory structure + whoami - Display user info + open - 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) => { + 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) => { + 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, '') + .replace(/\x1b\[0m/g, ''); + }; + + return ( +
    +
    +
    +
    +
    +
    + guest@atri.dad: {currentPath} +
    +
    + +
    inputRef.current?.focus()} + > + {isTrainRunning && ( +
    +
    {`
    +      ====        ________                ___________
    +  _D _|  |_______/        \\__I_I_____===__|_________|
    +   |(_)---  |   H\\________/ |   |        =|___ ___|      _________________
    +   /     |  |   H  |  |     |   |         ||_| |_||     _|                \\_____A
    +  |      |  |   H  |__--------------------| [___] |   =|                        |
    +  | ________|___H__/__|_____/[][]~\\_______|       |   -|                        |
    +  |/ |   |-----------I_____I [][] []  D   |=======|____|________________________|_
    +__/ =| o |=-O=====O=====O=====O \\ ____Y___________|__|__________________________|_
    + |/-=|___|=    ||    ||    ||    |_____/~\\___/          |_D__D__D_|  |_D__D__D_|
    +  \\_/      \\__/  \\__/  \\__/  \\__/      \\_/               \\_/   \\_/    \\_/   \\_/`}
    +
    + )} + + {!isTrainRunning && ( +
    + {commandHistory.map((command: Command, index: number) => ( +
    + {command.input && ( +
    + guest@atri.dad + : + {currentPath} + $ + {command.input} +
    + )} + {command.output && ( +
    + )} +
    + ))} + + {showCompletions && ( +
    + Tab completions: {completionsList.join(' ')} +
    + )} + +
    + guest@atri.dad + : + {currentPath} + $ + setCurrentInput((e.target as HTMLInputElement).value)} + onKeyDown={handleKeyDown} + className="flex-1 bg-transparent border-none outline-none text-accent ml-1" + autoFocus + spellcheck={false} + /> +
    +
    + )} +
    +
    + ); +}; + +export default Terminal; \ No newline at end of file diff --git a/src/pages/api/posts.json.ts b/src/pages/api/posts.json.ts new file mode 100644 index 0000000..c117fbf --- /dev/null +++ b/src/pages/api/posts.json.ts @@ -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' + } + }); + } +}; \ No newline at end of file diff --git a/src/pages/terminal.astro b/src/pages/terminal.astro new file mode 100644 index 0000000..9782f4a --- /dev/null +++ b/src/pages/terminal.astro @@ -0,0 +1,25 @@ +--- +import Layout from '../layouts/Layout.astro'; +import TerminalComponent from '../components/Terminal.tsx'; +import '../styles/global.css'; +--- + + +
    +
    +

    Shell Mode

    +

    Type 'help' to get started.

    +
    +
    + +
    +
    +
    + + \ No newline at end of file