This commit is contained in:
@ -3,11 +3,12 @@ import { useEffect } from "preact/hooks";
|
|||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
FileText,
|
BriefcaseBusiness,
|
||||||
CodeXml,
|
CodeXml,
|
||||||
Terminal as TerminalIcon,
|
Terminal as TerminalIcon,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
} from "lucide-preact";
|
} from "lucide-preact";
|
||||||
|
import { navigationItems } from '../config/data';
|
||||||
|
|
||||||
interface NavigationBarProps {
|
interface NavigationBarProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
@ -66,8 +67,13 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||||||
? activePath.slice(0, -1)
|
? activePath.slice(0, -1)
|
||||||
: activePath;
|
: activePath;
|
||||||
|
|
||||||
const isPostsPath = (path: string) => {
|
const iconMap = {
|
||||||
return path.startsWith("/posts") || path.startsWith("/post/");
|
Home,
|
||||||
|
NotebookPen,
|
||||||
|
BriefcaseBusiness,
|
||||||
|
CodeXml,
|
||||||
|
TerminalIcon,
|
||||||
|
Megaphone,
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -100,70 +106,22 @@ export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|||||||
>
|
>
|
||||||
<div class="overflow-visible">
|
<div class="overflow-visible">
|
||||||
<ul class="menu menu-horizontal bg-base-200 rounded-box p-1.5 sm:p-2 shadow-lg flex flex-nowrap whitespace-nowrap">
|
<ul class="menu menu-horizontal bg-base-200 rounded-box p-1.5 sm:p-2 shadow-lg flex flex-nowrap whitespace-nowrap">
|
||||||
<li class="mx-0.5 sm:mx-1">
|
{navigationItems.map((item) => {
|
||||||
<a href="/" class={normalizedPath === "/" ? "menu-active" : ""}>
|
const Icon = iconMap[item.icon as keyof typeof iconMap];
|
||||||
<div class="tooltip" data-tip="Home">
|
const isActive = item.isActive
|
||||||
<Home size={18} class="sm:w-5 sm:h-5" />
|
? item.isActive(normalizedPath)
|
||||||
</div>
|
: normalizedPath === item.path;
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="mx-0.5 sm:mx-1">
|
return (
|
||||||
<a
|
<li key={item.id} class="mx-0.5 sm:mx-1">
|
||||||
href="/posts"
|
<a href={item.path} class={isActive ? "menu-active" : ""}>
|
||||||
class={isPostsPath(normalizedPath) ? "menu-active" : ""}
|
<div class="tooltip" data-tip={item.tooltip}>
|
||||||
>
|
<Icon size={18} class="sm:w-5 sm:h-5" />
|
||||||
<div class="tooltip" data-tip="Posts">
|
</div>
|
||||||
<NotebookPen size={18} class="sm:w-5 sm:h-5" />
|
</a>
|
||||||
</div>
|
</li>
|
||||||
</a>
|
);
|
||||||
</li>
|
})}
|
||||||
|
|
||||||
<li class="mx-0.5 sm:mx-1">
|
|
||||||
<a
|
|
||||||
href="/resume"
|
|
||||||
class={normalizedPath === "/resume" ? "menu-active" : ""}
|
|
||||||
>
|
|
||||||
<div class="tooltip" data-tip="Resume">
|
|
||||||
<FileText size={18} class="sm:w-5 sm:h-5" />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="mx-0.5 sm:mx-1">
|
|
||||||
<a
|
|
||||||
href="/projects"
|
|
||||||
class={
|
|
||||||
normalizedPath.startsWith("/projects") ? "menu-active" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="tooltip" data-tip="Projects">
|
|
||||||
<CodeXml size={18} class="sm:w-5 sm:h-5" />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="mx-0.5 sm:mx-1">
|
|
||||||
<a
|
|
||||||
href="/talks"
|
|
||||||
class={normalizedPath.startsWith("/talks") ? "menu-active" : ""}
|
|
||||||
>
|
|
||||||
<div class="tooltip" data-tip="Talks">
|
|
||||||
<Megaphone size={18} class="sm:w-5 sm:h-5" />
|
|
||||||
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,12 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import type { Project } from '../config/data';
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
@ -1,44 +1,23 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import SpotifyIcon from './SpotifyIcon';
|
import SpotifyIcon from './SpotifyIcon';
|
||||||
|
import { socialLinks } from '../config/data';
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="flex flex-row gap-1 sm:gap-4 text-3xl">
|
<div class="flex flex-row gap-1 sm:gap-4 text-3xl">
|
||||||
<a
|
{socialLinks.map((link) => (
|
||||||
href="mailto:me@atri.dad"
|
link.id === 'spotify' ? (
|
||||||
aria-label="Email me"
|
<SpotifyIcon profileUrl={link.url} client:load />
|
||||||
class="hover:text-primary transition-colors"
|
) : (
|
||||||
>
|
<a
|
||||||
<Icon name="mdi:email" />
|
href={link.url}
|
||||||
</a>
|
target={link.url.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={link.url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
<a
|
aria-label={link.ariaLabel}
|
||||||
href="/feed"
|
class="hover:text-primary transition-colors"
|
||||||
aria-label="RSS Feed"
|
>
|
||||||
class="hover:text-primary transition-colors"
|
<Icon name={link.icon} />
|
||||||
>
|
</a>
|
||||||
<Icon name="mdi:rss" />
|
)
|
||||||
</a>
|
))}
|
||||||
|
</div>
|
||||||
<a
|
|
||||||
href="https://git.atri.dad/atridad"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Forgejo (Git)"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Icon name="simple-icons:gitea" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://bsky.app/profile/atri.dad"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Bluesky Profile"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Icon name="simple-icons:bluesky" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<SpotifyIcon profileUrl="https://open.spotify.com/user/31pjwuuqwnn5zr7fnhfjjmi7c4bi?si=1be2bfdc844c4d85" client:load />
|
|
||||||
</div>
|
|
||||||
|
@ -1,12 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
|
import type { Talk } from '../config/data';
|
||||||
interface Talk {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
talk: Talk;
|
talk: Talk;
|
||||||
|
@ -1,74 +1,17 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { techLinks } from '../config/data';
|
||||||
---
|
---
|
||||||
<div class="flex flex-row gap-1 sm:gap-4 text-3xl">
|
<div class="flex flex-row gap-1 sm:gap-4 text-3xl">
|
||||||
|
{techLinks.map((link) => (
|
||||||
<a
|
<a
|
||||||
href="https://react.dev/"
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label="React"
|
aria-label={link.ariaLabel}
|
||||||
class="hover:text-primary transition-colors"
|
class="hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<Icon name="simple-icons:react" />
|
<Icon name={link.icon} />
|
||||||
</a>
|
</a>
|
||||||
|
))}
|
||||||
<a
|
</div>
|
||||||
href="https://www.typescriptlang.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="TypeScript"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Icon name="simple-icons:typescript" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://astro.build/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Deno"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Icon name="simple-icons:astro" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://go.dev/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Go"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Icon name="simple-icons:go" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://www.postgresql.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="PostgreSQL"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Icon name="simple-icons:postgresql" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://redis.io/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Redis"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Icon name="simple-icons:redis" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://www.docker.com/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Docker"
|
|
||||||
class="hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Icon name="simple-icons:docker" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
@ -1,49 +1,29 @@
|
|||||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
import { useState, useEffect, useRef } from "preact/hooks";
|
||||||
import type { JSX } from 'preact';
|
import type { JSX } from "preact";
|
||||||
|
import type { Command } from "../lib/terminal/types";
|
||||||
interface FileSystemNode {
|
import { buildFileSystem } from "../lib/terminal/fileSystem";
|
||||||
type: 'directory' | 'file';
|
import { executeCommand, type CommandContext } from "../lib/terminal/commands";
|
||||||
name: string;
|
import {
|
||||||
content?: string;
|
getCompletions,
|
||||||
children?: { [key: string]: FileSystemNode };
|
formatOutput,
|
||||||
}
|
saveCommandToHistory,
|
||||||
|
loadCommandHistory,
|
||||||
interface ResumeData {
|
} from "../lib/terminal/utils";
|
||||||
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;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Terminal = () => {
|
const Terminal = () => {
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
const [currentPath, setCurrentPath] = useState("/");
|
||||||
const [commandHistory, setCommandHistory] = useState<Command[]>([
|
const [commandHistory, setCommandHistory] = useState<Command[]>([
|
||||||
{
|
{
|
||||||
input: '',
|
input: "",
|
||||||
output: 'Welcome to Atridad\'s Shell!\nType "help" to see available commands.\n',
|
output:
|
||||||
|
'Welcome to Atridad\'s Shell!\nType "help" to see available commands.\n',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
path: '/'
|
path: "/",
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
const [currentInput, setCurrentInput] = useState('');
|
const [currentInput, setCurrentInput] = useState("");
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
const [fileSystem, setFileSystem] = useState<{ [key: string]: FileSystemNode }>({});
|
const [fileSystem, setFileSystem] = useState<{ [key: string]: any }>({});
|
||||||
const [isTrainRunning, setIsTrainRunning] = useState(false);
|
const [isTrainRunning, setIsTrainRunning] = useState(false);
|
||||||
const [trainPosition, setTrainPosition] = useState(100);
|
const [trainPosition, setTrainPosition] = useState(100);
|
||||||
const [persistentHistory, setPersistentHistory] = useState<string[]>([]);
|
const [persistentHistory, setPersistentHistory] = useState<string[]>([]);
|
||||||
@ -62,540 +42,91 @@ const Terminal = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load persistent command history from localStorage
|
// Load command history from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedHistory = localStorage.getItem('terminal-history');
|
const history = loadCommandHistory();
|
||||||
if (savedHistory) {
|
setPersistentHistory(history);
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedHistory);
|
|
||||||
setPersistentHistory(parsed);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading command history:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Save command history to localStorage
|
// Initialize file system
|
||||||
const saveCommandToHistory = (command: string) => {
|
|
||||||
if (command.trim()) {
|
|
||||||
const updatedHistory = [...persistentHistory, command].slice(-100); // Keep last 100 commands
|
|
||||||
setPersistentHistory(updatedHistory);
|
|
||||||
localStorage.setItem('terminal-history', JSON.stringify(updatedHistory));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeFileSystem = async () => {
|
buildFileSystem().then(setFileSystem);
|
||||||
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\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 getCompletions = (input: string): { completion: string | null, replaceFrom: number } => {
|
|
||||||
const parts = input.trim().split(' ');
|
|
||||||
const command = parts[0];
|
|
||||||
const partialPath = parts[parts.length - 1] || '';
|
|
||||||
|
|
||||||
// Only complete paths for these commands, not the commands themselves
|
|
||||||
if (parts.length > 1 && ['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 { completion: null, replaceFrom };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!current?.children) {
|
|
||||||
return { completion: null, replaceFrom };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get first matching item
|
|
||||||
const match = Object.keys(current.children)
|
|
||||||
.find(name => name.startsWith(searchPrefix));
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const item = current.children[match];
|
|
||||||
const completion = item.type === 'directory' ? `${match}/` : match;
|
|
||||||
return { completion, replaceFrom };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { completion: null, 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 < -50) {
|
|
||||||
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>) => {
|
const handleSubmit = (e: JSX.TargetedEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const output = executeCommand(currentInput);
|
const commandContext: CommandContext = {
|
||||||
|
currentPath,
|
||||||
|
fileSystem,
|
||||||
|
setCurrentPath,
|
||||||
|
setIsTrainRunning,
|
||||||
|
setTrainPosition,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = executeCommand(currentInput, commandContext);
|
||||||
const newCommand: Command = {
|
const newCommand: Command = {
|
||||||
input: currentInput,
|
input: currentInput,
|
||||||
output,
|
output,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
path: currentPath
|
path: currentPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save command to persistent history
|
// Save command to persistent history
|
||||||
saveCommandToHistory(currentInput);
|
const updatedHistory = saveCommandToHistory(
|
||||||
|
currentInput,
|
||||||
|
persistentHistory,
|
||||||
|
);
|
||||||
|
setPersistentHistory(updatedHistory);
|
||||||
|
|
||||||
if (currentInput.trim().toLowerCase() === 'clear') {
|
if (currentInput.trim().toLowerCase() === "clear") {
|
||||||
setCommandHistory([]);
|
setCommandHistory([]);
|
||||||
} else {
|
} else {
|
||||||
setCommandHistory((prev: Command[]) => [...prev, newCommand]);
|
setCommandHistory((prev: Command[]) => [...prev, newCommand]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentInput('');
|
setCurrentInput("");
|
||||||
setHistoryIndex(-1);
|
setHistoryIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: JSX.TargetedKeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: JSX.TargetedKeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Tab') {
|
if (e.key === "Tab") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const { completion, replaceFrom } = getCompletions(currentInput);
|
const { completion, replaceFrom } = getCompletions(
|
||||||
|
currentInput,
|
||||||
|
currentPath,
|
||||||
|
fileSystem,
|
||||||
|
);
|
||||||
|
|
||||||
if (completion) {
|
if (completion) {
|
||||||
// Replace from the correct position with the completion
|
|
||||||
const beforeReplacement = currentInput.substring(0, replaceFrom);
|
const beforeReplacement = currentInput.substring(0, replaceFrom);
|
||||||
const newInput = beforeReplacement + completion;
|
const newInput = beforeReplacement + completion;
|
||||||
setCurrentInput(newInput + (completion.endsWith('/') ? '' : ' '));
|
setCurrentInput(newInput + (completion.endsWith("/") ? "" : " "));
|
||||||
}
|
}
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (persistentHistory.length > 0) {
|
if (persistentHistory.length > 0) {
|
||||||
const newIndex = historyIndex === -1 ? persistentHistory.length - 1 : Math.max(0, historyIndex - 1);
|
const newIndex =
|
||||||
|
historyIndex === -1
|
||||||
|
? persistentHistory.length - 1
|
||||||
|
: Math.max(0, historyIndex - 1);
|
||||||
setHistoryIndex(newIndex);
|
setHistoryIndex(newIndex);
|
||||||
setCurrentInput(persistentHistory[newIndex]);
|
setCurrentInput(persistentHistory[newIndex]);
|
||||||
}
|
}
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (historyIndex !== -1) {
|
if (historyIndex !== -1) {
|
||||||
const newIndex = Math.min(persistentHistory.length - 1, historyIndex + 1);
|
const newIndex = Math.min(
|
||||||
if (newIndex === persistentHistory.length - 1 && historyIndex === newIndex) {
|
persistentHistory.length - 1,
|
||||||
|
historyIndex + 1,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
newIndex === persistentHistory.length - 1 &&
|
||||||
|
historyIndex === newIndex
|
||||||
|
) {
|
||||||
setHistoryIndex(-1);
|
setHistoryIndex(-1);
|
||||||
setCurrentInput('');
|
setCurrentInput("");
|
||||||
} else {
|
} else {
|
||||||
setHistoryIndex(newIndex);
|
setHistoryIndex(newIndex);
|
||||||
setCurrentInput(persistentHistory[newIndex]);
|
setCurrentInput(persistentHistory[newIndex]);
|
||||||
@ -604,12 +135,6 @@ ${post.content}`;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatOutput = (text: string): string => {
|
|
||||||
return text
|
|
||||||
.replace(/\x1b\[34m/g, '<span class="text-primary">')
|
|
||||||
.replace(/\x1b\[0m/g, '</span>');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
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-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="bg-base-200 px-4 py-2 rounded-t-lg border-b border-base-300">
|
||||||
@ -617,7 +142,9 @@ ${post.content}`;
|
|||||||
<div className="w-3 h-3 bg-error rounded-full"></div>
|
<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-warning rounded-full"></div>
|
||||||
<div className="w-3 h-3 bg-success 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>
|
<span className="ml-4 text-base-content/70 text-xs">
|
||||||
|
guest@atri.dad: {currentPath}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -631,7 +158,7 @@ ${post.content}`;
|
|||||||
className="absolute top-1/2 transform -translate-y-1/2 z-10 pointer-events-none text-base-content font-mono text-xs whitespace-nowrap"
|
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={{
|
style={{
|
||||||
left: `${trainPosition}%`,
|
left: `${trainPosition}%`,
|
||||||
transition: 'none'
|
transition: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<pre className="leading-none">{`
|
<pre className="leading-none">{`
|
||||||
@ -651,19 +178,25 @@ __/ =| o |=-O=====O=====O=====O \\ ____Y___________|__|_________________________
|
|||||||
<div className="min-h-full">
|
<div className="min-h-full">
|
||||||
{commandHistory.map((command: Command, index: number) => (
|
{commandHistory.map((command: Command, index: number) => (
|
||||||
<div key={index} className="mb-2">
|
<div key={index} className="mb-2">
|
||||||
{command.input && (
|
{command.input && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-primary font-semibold">guest@atri.dad</span>
|
<span className="text-primary font-semibold">
|
||||||
<span className="text-base-content">:</span>
|
guest@atri.dad
|
||||||
<span className="text-secondary font-semibold">{command.path}</span>
|
</span>
|
||||||
<span className="text-base-content">$ </span>
|
<span className="text-base-content">:</span>
|
||||||
<span className="text-accent">{command.input}</span>
|
<span className="text-secondary font-semibold">
|
||||||
</div>
|
{command.path}
|
||||||
)}
|
</span>
|
||||||
|
<span className="text-base-content">$ </span>
|
||||||
|
<span className="text-accent">{command.input}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{command.output && (
|
{command.output && (
|
||||||
<div
|
<div
|
||||||
className="whitespace-pre-wrap text-base-content/80 mt-1"
|
className="whitespace-pre-wrap text-base-content/80 mt-1"
|
||||||
dangerouslySetInnerHTML={{ __html: formatOutput(command.output) }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: formatOutput(command.output),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -673,13 +206,17 @@ __/ =| o |=-O=====O=====O=====O \\ ____Y___________|__|_________________________
|
|||||||
<form onSubmit={handleSubmit} className="flex items-center">
|
<form onSubmit={handleSubmit} className="flex items-center">
|
||||||
<span className="text-primary font-semibold">guest@atri.dad</span>
|
<span className="text-primary font-semibold">guest@atri.dad</span>
|
||||||
<span className="text-base-content">:</span>
|
<span className="text-base-content">:</span>
|
||||||
<span className="text-secondary font-semibold">{currentPath}</span>
|
<span className="text-secondary font-semibold">
|
||||||
|
{currentPath}
|
||||||
|
</span>
|
||||||
<span className="text-base-content">$ </span>
|
<span className="text-base-content">$ </span>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={currentInput}
|
value={currentInput}
|
||||||
onInput={(e) => setCurrentInput((e.target as HTMLInputElement).value)}
|
onInput={(e) =>
|
||||||
|
setCurrentInput((e.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="flex-1 bg-transparent border-none outline-none text-accent ml-1"
|
className="flex-1 bg-transparent border-none outline-none text-accent ml-1"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
246
src/config/data.ts
Normal file
246
src/config/data.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
export interface Talk {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
link: string;
|
||||||
|
date?: string;
|
||||||
|
venue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
link: string;
|
||||||
|
technologies?: string[];
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialLink {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TechLink {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
tooltip: string;
|
||||||
|
icon: string;
|
||||||
|
isActive?: (path: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const talks: Talk[] = [
|
||||||
|
{
|
||||||
|
id: "devedmonton-hateoas",
|
||||||
|
name: "Hypermedia as the engine of application state - An Introduction",
|
||||||
|
description: "A basic introduction to the concepts behind HATEOAS or Hypermedia as the engine of application state.",
|
||||||
|
link: "/files/DevEdmonton_Talk_HATEOAS.pdf",
|
||||||
|
venue: "Dev Edmonton Society",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projects: Project[] = [
|
||||||
|
{
|
||||||
|
id: "bluesky-pds-manager",
|
||||||
|
name: "BlueSky PDS Manager",
|
||||||
|
description: "A web-based BlueSky PDS Manager. Manage your invite codes and users with a simple web UI.",
|
||||||
|
link: "https://pdsman.atri.dad",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pollo",
|
||||||
|
name: "Pollo",
|
||||||
|
description: "A dead-simple real-time voting tool.",
|
||||||
|
link: "https://git.atri.dad/atridad/pollo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "goth-stack",
|
||||||
|
name: "GOTH Stack",
|
||||||
|
description: "🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
|
||||||
|
link: "https://git.atri.dad/atridad/goth.stack",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "himbot",
|
||||||
|
name: "Himbot",
|
||||||
|
description: "A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
|
||||||
|
link: "https://git.atri.dad/atridad/himbot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loadr",
|
||||||
|
name: "loadr",
|
||||||
|
description: "A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.",
|
||||||
|
link: "https://git.atri.dad/atridad/loadr",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sections = {
|
||||||
|
resume: {
|
||||||
|
name: "Resume",
|
||||||
|
path: "/resume",
|
||||||
|
description: "Professional experience, skills, and background"
|
||||||
|
},
|
||||||
|
posts: {
|
||||||
|
name: "Blog Posts",
|
||||||
|
path: "/posts",
|
||||||
|
description: "Technical articles and thoughts"
|
||||||
|
},
|
||||||
|
talks: {
|
||||||
|
name: "Talks",
|
||||||
|
path: "/talks",
|
||||||
|
description: "Conference talks and presentations"
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
name: "Projects",
|
||||||
|
path: "/projects",
|
||||||
|
description: "Personal and professional projects"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const socialLinks: SocialLink[] = [
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
name: "Email",
|
||||||
|
url: "mailto:me@atri.dad",
|
||||||
|
icon: "mdi:email",
|
||||||
|
ariaLabel: "Email me"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rss",
|
||||||
|
name: "RSS Feed",
|
||||||
|
url: "/feed",
|
||||||
|
icon: "mdi:rss",
|
||||||
|
ariaLabel: "RSS Feed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gitea",
|
||||||
|
name: "Forgejo (Git)",
|
||||||
|
url: "https://git.atri.dad/atridad",
|
||||||
|
icon: "simple-icons:gitea",
|
||||||
|
ariaLabel: "Forgejo (Git)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bluesky",
|
||||||
|
name: "Bluesky",
|
||||||
|
url: "https://bsky.app/profile/atri.dad",
|
||||||
|
icon: "simple-icons:bluesky",
|
||||||
|
ariaLabel: "Bluesky Profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spotify",
|
||||||
|
name: "Spotify",
|
||||||
|
url: "https://open.spotify.com/user/31pjwuuqwnn5zr7fnhfjjmi7c4bi?si=1be2bfdc844c4d85",
|
||||||
|
icon: "spotify", // Special component
|
||||||
|
ariaLabel: "Spotify Profile"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const techLinks: TechLink[] = [
|
||||||
|
{
|
||||||
|
id: "react",
|
||||||
|
name: "React",
|
||||||
|
url: "https://react.dev/",
|
||||||
|
icon: "simple-icons:react",
|
||||||
|
ariaLabel: "React"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "typescript",
|
||||||
|
name: "TypeScript",
|
||||||
|
url: "https://www.typescriptlang.org/",
|
||||||
|
icon: "simple-icons:typescript",
|
||||||
|
ariaLabel: "TypeScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "astro",
|
||||||
|
name: "Astro",
|
||||||
|
url: "https://astro.build/",
|
||||||
|
icon: "simple-icons:astro",
|
||||||
|
ariaLabel: "Astro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "go",
|
||||||
|
name: "Go",
|
||||||
|
url: "https://go.dev/",
|
||||||
|
icon: "simple-icons:go",
|
||||||
|
ariaLabel: "Go"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "postgresql",
|
||||||
|
name: "PostgreSQL",
|
||||||
|
url: "https://www.postgresql.org/",
|
||||||
|
icon: "simple-icons:postgresql",
|
||||||
|
ariaLabel: "PostgreSQL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redis",
|
||||||
|
name: "Redis",
|
||||||
|
url: "https://redis.io/",
|
||||||
|
icon: "simple-icons:redis",
|
||||||
|
ariaLabel: "Redis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "docker",
|
||||||
|
name: "Docker",
|
||||||
|
url: "https://www.docker.com/",
|
||||||
|
icon: "simple-icons:docker",
|
||||||
|
ariaLabel: "Docker"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const navigationItems: NavigationItem[] = [
|
||||||
|
{
|
||||||
|
id: "home",
|
||||||
|
name: "Home",
|
||||||
|
path: "/",
|
||||||
|
tooltip: "Home",
|
||||||
|
icon: "Home"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "posts",
|
||||||
|
name: "Posts",
|
||||||
|
path: "/posts",
|
||||||
|
tooltip: "Posts",
|
||||||
|
icon: "NotebookPen",
|
||||||
|
isActive: (path: string) => path.startsWith("/posts") || path.startsWith("/post/")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resume",
|
||||||
|
name: "Resume",
|
||||||
|
path: "/resume",
|
||||||
|
tooltip: "Resume",
|
||||||
|
icon: "BriefcaseBusiness"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "projects",
|
||||||
|
name: "Projects",
|
||||||
|
path: "/projects",
|
||||||
|
tooltip: "Projects",
|
||||||
|
icon: "CodeXml",
|
||||||
|
isActive: (path: string) => path.startsWith("/projects")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "talks",
|
||||||
|
name: "Talks",
|
||||||
|
path: "/talks",
|
||||||
|
tooltip: "Talks",
|
||||||
|
icon: "Megaphone",
|
||||||
|
isActive: (path: string) => path.startsWith("/talks")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terminal",
|
||||||
|
name: "Terminal",
|
||||||
|
path: "/terminal",
|
||||||
|
tooltip: "Terminal",
|
||||||
|
icon: "TerminalIcon"
|
||||||
|
}
|
||||||
|
];
|
234
src/lib/terminal/commands.ts
Normal file
234
src/lib/terminal/commands.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import type { FileSystemNode } from './types';
|
||||||
|
import { getCurrentDirectory, resolvePath } from './fileSystem';
|
||||||
|
|
||||||
|
export interface CommandContext {
|
||||||
|
currentPath: string;
|
||||||
|
fileSystem: { [key: string]: FileSystemNode };
|
||||||
|
setCurrentPath: (path: string) => void;
|
||||||
|
setIsTrainRunning: (running: boolean) => void;
|
||||||
|
setTrainPosition: (position: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeCommand(input: string, context: CommandContext): string {
|
||||||
|
const trimmedInput = input.trim();
|
||||||
|
if (!trimmedInput) return '';
|
||||||
|
|
||||||
|
const [command, ...args] = trimmedInput.split(' ');
|
||||||
|
|
||||||
|
switch (command.toLowerCase()) {
|
||||||
|
case 'help':
|
||||||
|
return handleHelp();
|
||||||
|
case 'ls':
|
||||||
|
return handleLs(args, context);
|
||||||
|
case 'cd':
|
||||||
|
return handleCd(args, context);
|
||||||
|
case 'pwd':
|
||||||
|
return handlePwd(context);
|
||||||
|
case 'cat':
|
||||||
|
return handleCat(args, context);
|
||||||
|
case 'tree':
|
||||||
|
return handleTree(context);
|
||||||
|
case 'clear':
|
||||||
|
return '';
|
||||||
|
case 'whoami':
|
||||||
|
return handleWhoami();
|
||||||
|
case 'open':
|
||||||
|
return handleOpen(args);
|
||||||
|
case 'sl':
|
||||||
|
return handleSl(context);
|
||||||
|
default:
|
||||||
|
return `${command}: command not found. Type 'help' for available commands.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHelp(): string {
|
||||||
|
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
|
||||||
|
open /talks
|
||||||
|
cat /talks/README.txt
|
||||||
|
cd social
|
||||||
|
cat /tech/README.txt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLs(args: string[], context: CommandContext): string {
|
||||||
|
const { currentPath, fileSystem } = context;
|
||||||
|
const targetPath = args[0] ? resolvePath(currentPath, 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCd(args: string[], context: CommandContext): string {
|
||||||
|
const { currentPath, fileSystem, setCurrentPath } = context;
|
||||||
|
const targetPath = args[0] ? resolvePath(currentPath, 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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePwd(context: CommandContext): string {
|
||||||
|
return context.currentPath || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCat(args: string[], context: CommandContext): string {
|
||||||
|
const { currentPath, fileSystem } = context;
|
||||||
|
|
||||||
|
if (!args[0]) {
|
||||||
|
return 'cat: missing file argument';
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = resolvePath(currentPath, 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 || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTree(context: CommandContext): string {
|
||||||
|
const { fileSystem } = context;
|
||||||
|
|
||||||
|
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['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWhoami(): string {
|
||||||
|
return 'guest@atri.dad';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen(args: string[]): string {
|
||||||
|
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 === '/talks' || path.startsWith('/talks')) {
|
||||||
|
url = '/talks';
|
||||||
|
} 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...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSl(context: CommandContext): string {
|
||||||
|
const { setIsTrainRunning, setTrainPosition } = context;
|
||||||
|
|
||||||
|
setIsTrainRunning(true);
|
||||||
|
setTrainPosition(100);
|
||||||
|
|
||||||
|
const animateTrain = () => {
|
||||||
|
let position = 100;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
position -= 1.5;
|
||||||
|
setTrainPosition(position);
|
||||||
|
|
||||||
|
if (position < -50) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setIsTrainRunning(false);
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(animateTrain, 100);
|
||||||
|
return '';
|
||||||
|
}
|
367
src/lib/terminal/fileSystem.ts
Normal file
367
src/lib/terminal/fileSystem.ts
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
import type { FileSystemNode, ResumeData } from './types';
|
||||||
|
import { talks, projects, socialLinks, techLinks } from '../../config/data';
|
||||||
|
|
||||||
|
export async function buildFileSystem(): Promise<{ [key: string]: FileSystemNode }> {
|
||||||
|
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 rxresume json
|
||||||
|
const resumeFiles = buildResumeFiles(resumeData);
|
||||||
|
const postsFiles = buildPostsFiles(postsData);
|
||||||
|
const talksFiles = buildTalksFiles();
|
||||||
|
const projectsFiles = buildProjectsFiles();
|
||||||
|
const socialFiles = buildSocialFiles();
|
||||||
|
const techFiles = buildTechFiles();
|
||||||
|
const contactContent = buildContactContent(resumeData);
|
||||||
|
|
||||||
|
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\nExplore the directories:\n- /resume - Professional experience and skills\n- /posts - Blog posts and articles\n- /talks - Conference presentations\n- /projects - Personal and professional projects\n- /social - Social media and contact links\n- /tech - Technologies and tools I use\n\nType "ls" to see all available files and directories.`
|
||||||
|
},
|
||||||
|
'resume': {
|
||||||
|
type: 'directory',
|
||||||
|
name: 'resume',
|
||||||
|
children: resumeFiles
|
||||||
|
},
|
||||||
|
'posts': {
|
||||||
|
type: 'directory',
|
||||||
|
name: 'posts',
|
||||||
|
children: postsFiles
|
||||||
|
},
|
||||||
|
'talks': {
|
||||||
|
type: 'directory',
|
||||||
|
name: 'talks',
|
||||||
|
children: talksFiles
|
||||||
|
},
|
||||||
|
'projects': {
|
||||||
|
type: 'directory',
|
||||||
|
name: 'projects',
|
||||||
|
children: projectsFiles
|
||||||
|
},
|
||||||
|
'social': {
|
||||||
|
type: 'directory',
|
||||||
|
name: 'social',
|
||||||
|
children: socialFiles
|
||||||
|
},
|
||||||
|
'tech': {
|
||||||
|
type: 'directory',
|
||||||
|
name: 'tech',
|
||||||
|
children: techFiles
|
||||||
|
},
|
||||||
|
'contact.txt': {
|
||||||
|
type: 'file',
|
||||||
|
name: 'contact.txt',
|
||||||
|
content: contactContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fs;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading resume data:', error);
|
||||||
|
return buildFallbackFileSystem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResumeFiles(resumeData: ResumeData): { [key: string]: FileSystemNode } {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return resumeFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPostsFiles(postsData: any[]): { [key: string]: FileSystemNode } {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return postsFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTalksFiles(): { [key: string]: FileSystemNode } {
|
||||||
|
const talksFiles: { [key: string]: FileSystemNode } = {
|
||||||
|
'README.txt': {
|
||||||
|
type: 'file',
|
||||||
|
name: 'README.txt',
|
||||||
|
content: 'Conference talks and presentations.\n\nUse "open /talks" to see the full list on the website.\n\nAvailable talks:\n' +
|
||||||
|
talks.map(talk => `- ${talk.id}.md`).join('\n')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
talks.forEach(talk => {
|
||||||
|
const fileName = `${talk.id}.md`;
|
||||||
|
let content = `---
|
||||||
|
title: "${talk.name}"
|
||||||
|
description: "${talk.description}"
|
||||||
|
${talk.venue ? `venue: "${talk.venue}"` : ''}
|
||||||
|
${talk.date ? `date: "${talk.date}"` : ''}
|
||||||
|
link: "${talk.link}"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${talk.name}
|
||||||
|
|
||||||
|
${talk.description}
|
||||||
|
|
||||||
|
${talk.venue ? `**Venue:** ${talk.venue}` : ''}
|
||||||
|
${talk.date ? `**Date:** ${talk.date}` : ''}
|
||||||
|
|
||||||
|
**Download:** [${talk.link}](${talk.link})`;
|
||||||
|
|
||||||
|
talksFiles[fileName] = {
|
||||||
|
type: 'file',
|
||||||
|
name: fileName,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return talksFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectsFiles(): { [key: string]: FileSystemNode } {
|
||||||
|
const projectsFiles: { [key: string]: FileSystemNode } = {
|
||||||
|
'README.txt': {
|
||||||
|
type: 'file',
|
||||||
|
name: 'README.txt',
|
||||||
|
content: 'Personal and professional projects.\n\nUse "open /projects" to see the full portfolio on the website.\n\nAvailable projects:\n' +
|
||||||
|
projects.map(project => `- ${project.id}.md`).join('\n')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
projects.forEach(project => {
|
||||||
|
const fileName = `${project.id}.md`;
|
||||||
|
let content = `---
|
||||||
|
title: "${project.name}"
|
||||||
|
description: "${project.description}"
|
||||||
|
${project.status ? `status: "${project.status}"` : ''}
|
||||||
|
${project.link ? `link: "${project.link}"` : ''}
|
||||||
|
${project.technologies ? `technologies: [${project.technologies.map(tech => `"${tech}"`).join(', ')}]` : ''}
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${project.name}
|
||||||
|
|
||||||
|
${project.description}
|
||||||
|
|
||||||
|
${project.status ? `**Status:** ${project.status}` : ''}
|
||||||
|
${project.technologies ? `**Technologies:** ${project.technologies.join(', ')}` : ''}
|
||||||
|
${project.link ? `**Link:** [${project.link}](${project.link})` : ''}`;
|
||||||
|
|
||||||
|
projectsFiles[fileName] = {
|
||||||
|
type: 'file',
|
||||||
|
name: fileName,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return projectsFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSocialFiles(): { [key: string]: FileSystemNode } {
|
||||||
|
const socialFiles: { [key: string]: FileSystemNode } = {
|
||||||
|
'README.txt': {
|
||||||
|
type: 'file',
|
||||||
|
name: 'README.txt',
|
||||||
|
content: 'Social media profiles and contact information.\n\nAvailable social links:\n' +
|
||||||
|
socialLinks.map(link => `- ${link.id}.txt`).join('\n')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socialLinks.forEach(link => {
|
||||||
|
const fileName = `${link.id}.txt`;
|
||||||
|
let content = `${link.name}
|
||||||
|
|
||||||
|
${link.ariaLabel}
|
||||||
|
|
||||||
|
URL: ${link.url}
|
||||||
|
Icon: ${link.icon}`;
|
||||||
|
|
||||||
|
socialFiles[fileName] = {
|
||||||
|
type: 'file',
|
||||||
|
name: fileName,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return socialFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTechFiles(): { [key: string]: FileSystemNode } {
|
||||||
|
const techFiles: { [key: string]: FileSystemNode } = {
|
||||||
|
'README.txt': {
|
||||||
|
type: 'file',
|
||||||
|
name: 'README.txt',
|
||||||
|
content: 'Technologies and tools I use.\n\nAvailable tech links:\n' +
|
||||||
|
techLinks.map(link => `- ${link.id}.txt`).join('\n')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
techLinks.forEach(link => {
|
||||||
|
const fileName = `${link.id}.txt`;
|
||||||
|
let content = `${link.name}
|
||||||
|
|
||||||
|
${link.ariaLabel}
|
||||||
|
|
||||||
|
URL: ${link.url}
|
||||||
|
Icon: ${link.icon}`;
|
||||||
|
|
||||||
|
techFiles[fileName] = {
|
||||||
|
type: 'file',
|
||||||
|
name: fileName,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return techFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContactContent(resumeData: ResumeData): string {
|
||||||
|
return [
|
||||||
|
`Email: ${resumeData.basics.email}`,
|
||||||
|
'',
|
||||||
|
'Social Profiles:',
|
||||||
|
...resumeData.sections.profiles.items.map(profile =>
|
||||||
|
`${profile.network}: ${profile.url.href}`
|
||||||
|
)
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackFileSystem(): { [key: string]: FileSystemNode } {
|
||||||
|
return {
|
||||||
|
'/': {
|
||||||
|
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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentDirectory(fileSystem: { [key: string]: FileSystemNode }, currentPath: string): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePath(currentPath: string, 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('/');
|
||||||
|
}
|
40
src/lib/terminal/types.ts
Normal file
40
src/lib/terminal/types.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
export interface FileSystemNode {
|
||||||
|
type: 'directory' | 'file';
|
||||||
|
name: string;
|
||||||
|
content?: string;
|
||||||
|
children?: { [key: string]: FileSystemNode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 }[] };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Command {
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
timestamp: Date;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalState {
|
||||||
|
currentPath: string;
|
||||||
|
commandHistory: Command[];
|
||||||
|
currentInput: string;
|
||||||
|
historyIndex: number;
|
||||||
|
fileSystem: { [key: string]: FileSystemNode };
|
||||||
|
isTrainRunning: boolean;
|
||||||
|
trainPosition: number;
|
||||||
|
persistentHistory: string[];
|
||||||
|
}
|
98
src/lib/terminal/utils.ts
Normal file
98
src/lib/terminal/utils.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import type { FileSystemNode } from './types';
|
||||||
|
import { resolvePath } from './fileSystem';
|
||||||
|
|
||||||
|
export function getCompletions(
|
||||||
|
input: string,
|
||||||
|
currentPath: string,
|
||||||
|
fileSystem: { [key: string]: FileSystemNode }
|
||||||
|
): { completion: string | null, replaceFrom: number } {
|
||||||
|
const parts = input.trim().split(' ');
|
||||||
|
const command = parts[0];
|
||||||
|
const partialPath = parts[parts.length - 1] || '';
|
||||||
|
|
||||||
|
// Only complete paths for these commands
|
||||||
|
if (parts.length > 1 && ['ls', 'cd', 'cat', 'open'].includes(command)) {
|
||||||
|
// Path completion
|
||||||
|
const isAbsolute = partialPath.startsWith('/');
|
||||||
|
const pathToComplete = isAbsolute ? partialPath : resolvePath(currentPath, 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 { completion: null, replaceFrom };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current?.children) {
|
||||||
|
return { completion: null, replaceFrom };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first matching item
|
||||||
|
const match = Object.keys(current.children)
|
||||||
|
.find(name => name.startsWith(searchPrefix));
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const item = current.children[match];
|
||||||
|
const completion = item.type === 'directory' ? `${match}/` : match;
|
||||||
|
return { completion, replaceFrom };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { completion: null, replaceFrom: input.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOutput(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\x1b\[34m/g, '<span class="text-primary">')
|
||||||
|
.replace(/\x1b\[0m/g, '</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCommandToHistory(command: string, persistentHistory: string[]): string[] {
|
||||||
|
if (command.trim()) {
|
||||||
|
const updatedHistory = [...persistentHistory, command].slice(-100); // Keep last 100 commands
|
||||||
|
localStorage.setItem('terminal-history', JSON.stringify(updatedHistory));
|
||||||
|
return updatedHistory;
|
||||||
|
}
|
||||||
|
return persistentHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCommandHistory(): string[] {
|
||||||
|
const savedHistory = localStorage.getItem('terminal-history');
|
||||||
|
if (savedHistory) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(savedHistory);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading command history:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
@ -1,43 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import ProjectCard from "../components/ProjectCard.astro";
|
import ProjectCard from "../components/ProjectCard.astro";
|
||||||
|
import { projects } from "../config/data";
|
||||||
const projects = [
|
|
||||||
{
|
|
||||||
id: "bluesky-pds-manager",
|
|
||||||
name: "BlueSky PDS Manager",
|
|
||||||
description:
|
|
||||||
"A web-based BlueSky PDS Manager. Manage your invite codes and users with a simple web UI.",
|
|
||||||
link: "https://pdsman.atri.dad",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pollo",
|
|
||||||
name: "Pollo",
|
|
||||||
description: "A dead-simple real-time voting tool.",
|
|
||||||
link: "https://git.atri.dad/atridad/pollo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "goth-stack",
|
|
||||||
name: "GOTH Stack",
|
|
||||||
description:
|
|
||||||
"🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
|
|
||||||
link: "https://git.atri.dad/atridad/goth.stack",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "himbot",
|
|
||||||
name: "Himbot",
|
|
||||||
description:
|
|
||||||
"A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
|
|
||||||
link: "https://git.atri.dad/atridad/himbot",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "loadr",
|
|
||||||
name: "loadr",
|
|
||||||
description:
|
|
||||||
"A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.",
|
|
||||||
link: "https://git.atri.dad/atridad/loadr",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
|
@ -1,16 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import TalkCard from "../components/TalkCard.astro";
|
import TalkCard from "../components/TalkCard.astro";
|
||||||
|
import { talks } from "../config/data";
|
||||||
const talks = [
|
|
||||||
{
|
|
||||||
id: "devedmonton-hateoas",
|
|
||||||
name: "Hypermedia as the engine of application state - An Introduction",
|
|
||||||
description:
|
|
||||||
"A basic introduction to the concepts behind HATEOAS or Hypermedia as the engine of application state.",
|
|
||||||
link: "/files/DevEdmonton_Talk_HATEOAS.pdf",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
|
Reference in New Issue
Block a user