commit 62a7de8491e0e1ce32f71fe7754887e31ff36999 parent f119e5b937366a9e69f96b6aa9933a3b6eb0d765 Author: Damian Myrda <damian@prime8.dev> Date: Sat, 31 Jan 2026 18:44:46 -0600 refactor: move the website to the repo root, add keyboard shortcut overlay Diffstat:
90 files changed, 3098 insertions(+), 2939 deletions(-)
diff --git a/web/.gitignore b/.gitignore diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml @@ -3,6 +3,6 @@ repos: hooks: - id: lint-staged name: lint-staged - entry: sh -c 'cd web && bunx lint-staged' + entry: sh -c 'cd website && bunx lint-staged' language: system pass_filenames: false diff --git a/web/.prettierignore b/.prettierignore diff --git a/web/.prettierrc b/.prettierrc diff --git a/web/Dockerfile b/Dockerfile diff --git a/README.md b/README.md @@ -1,20 +1,36 @@ -# prime8dev +# web -Personal website at [prime8.dev](https://prime8.dev). +Astro web application for [prime8.dev](https://prime8.dev). -## Structure +## Stack -- [web](web/) - Astro web application +- **Astro** - Static site generator with SSR +- **TypeScript** - Type-safe code +- **Bun** - Package manager and runtime +- **Docker** - Containerization -## Deployment +## Development -The `cloud_config.yml` provides cloud-init configuration for provisioning a new server with: +Install dependencies: -- SSH hardening -- Fail2ban -- UFW firewall -- Dokploy installation +```sh +bun install +``` -## License +Run development server: -[AGPL-3.0](LICENSE) +```sh +bun run dev +``` + +Build for production: + +```sh +bun run build +``` + +Build the Docker image: + +```sh +docker build -t prime8dev . +``` diff --git a/web/astro.config.mjs b/astro.config.mjs diff --git a/web/bun.lock b/bun.lock diff --git a/web/env.example b/env.example diff --git a/web/eslint.config.js b/eslint.config.js diff --git a/web/package.json b/package.json diff --git a/web/public/amplifier/logo.svg b/public/amplifier/logo.svg diff --git a/web/public/favicon.png b/public/favicon.png Binary files differ. diff --git a/web/public/mandolin/company.jpeg b/public/mandolin/company.jpeg Binary files differ. diff --git a/web/public/mandolin/company_silly.jpeg b/public/mandolin/company_silly.jpeg Binary files differ. diff --git a/web/public/mandolin/employee_polaroids.jpeg b/public/mandolin/employee_polaroids.jpeg Binary files differ. diff --git a/web/public/mandolin/logo.svg b/public/mandolin/logo.svg diff --git a/web/public/mandolin/moment_polaroids.jpeg b/public/mandolin/moment_polaroids.jpeg Binary files differ. diff --git a/web/public/me.jpg b/public/me.jpg Binary files differ. diff --git a/web/public/robots.txt b/public/robots.txt diff --git a/web/public/site.webmanifest b/public/site.webmanifest diff --git a/src/blogs/building-my-portfolio.md b/src/blogs/building-my-portfolio.md @@ -0,0 +1,42 @@ +--- +id: building-my-portfolio +title: Building a TUI-Inspired Portfolio +description: How I built this terminal-style portfolio with Astro +date: 2026-01-16 +tags: + - Astro + - TypeScript + - TUI + - Web Development + - Portfolio +--- + +Once I got used to the terminal, **I couldn't live without it**. I love the **simplicity** of the interface and the fact that it's **keyboard native** (no need to reach for the mouse). Anything can be reached within a few key presses, which is one of the characteristics that I love. + +## The Stack + +I built this website using **Astro** using server-side rendering. I tried to keep it as simple as possible, no over-engineering. Don't need WASM, a full-fledged database, or a complex UI framework. Details about me are stored in json files for ease of access (no need to scour the entire project to make a change), and my blogs are stored in a directory of markdown files. + +## Design Decisions + +Some characteristics that I've observed in terminal user interfaces I've transferred over to my website: + +- **Keyboard-first navigation**: `Arrow keys` allows navigation throughout the UI. `Tab` and the number keys (`1-4`) can be used to switch between tabs instantly. `Enter` intuitively opens blog posts and `Escape` closes them. +- **No stale content**: One thing I don't want to worry about it updating my website every time something changes. This is a design pattern that is followed in the entire project. + - Project details are fetched from my [self-hosted GitLab instance](https://git.prime8.dev). + - Blog metadata is taken from the file stats, not a hard coded value. + - Coding stats are pulled from [my Wakatime account](https://wakatime.com/@moncheeta). +- **Personality**: One thing I want to make sure of is that this website is unique, but also derives some of its elements from existing terminal applications. + - Boot animation adds a feeling of life to the website, even though it's entirely static. + - Top tab bar is an idea taken from the countless GUI applications that have it. + - Bottom status bar is inspired by many terminal applications that use a similar setup. + +These characteristics make this website feel like the terminal applications that I'm in love with, while differentiating it to make it mine. + +## Conclusion + +Building this portfolio was a chance for me to combine two things **I love**: terminal user interfaces and modern web development. The result is something that feels **personal**, a site that actually reflects how I actually work. + +If you're thinking about building your own portfolio, my advice is simple: **make it yours**. Pick a style that exemplifies you and add the features you'd actually want to use. + +The source code is available at [git.prime8.dev](https://git.prime8.dev/moncheeta/prime8dev) diff --git a/src/blogs/index.ts b/src/blogs/index.ts @@ -0,0 +1,56 @@ +import matter from "gray-matter"; +import { parseMarkdown, removeFirstH1 } from "../utils/markdown"; + +export interface Blog { + id: string; + title: string; + description: string; + date: string; + content?: string; + url?: string; + image?: string; + tags?: string[]; +} + +const blogModules = import.meta.glob("./*.md", { + eager: true, + query: "?raw", + import: "default", +}); + +export const blogs = Object.values(blogModules) + .map((content) => parseBlog(content as string)) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + +function parseBlog(markdown: string): Blog { + const { data, content } = matter(markdown); + + const blog: Partial<Blog> = { + id: data.id as string | undefined, + title: data.title as string | undefined, + description: data.description as string | undefined, + date: + data.date instanceof Date + ? data.date.toISOString().split("T")[0] + : (data.date as string | undefined), + url: data.url as string | undefined, + content: parseMarkdown(removeFirstH1(content)), + image: data.image as string | undefined, + tags: data.tags as string[] | undefined, + }; + + const missingKeys = ["id", "title", "description", "date"].filter( + (key) => !blog[key as keyof typeof blog], + ); + if (missingKeys.length > 0) { + throw new Error(`Missing keys: ${missingKeys.join(", ")}`); + } + + if (blog.url && blog.content) { + throw new Error("Blogs cannot have both a URL and content"); + } else if (!blog.url && !blog.content) { + throw new Error("Blogs must have either a URL or content"); + } + + return blog as Blog; +} diff --git a/web/src/config/about.ts b/src/config/about.ts diff --git a/web/src/config/contact.ts b/src/config/contact.ts diff --git a/web/src/config/experience.ts b/src/config/experience.ts diff --git a/web/src/config/index.ts b/src/config/index.ts diff --git a/web/src/config/projects.ts b/src/config/projects.ts diff --git a/web/src/config/tabs.ts b/src/config/tabs.ts diff --git a/web/src/constants/calendar.ts b/src/constants/calendar.ts diff --git a/web/src/constants/env.ts b/src/constants/env.ts diff --git a/web/src/constants/external/wakatime.ts b/src/constants/external/wakatime.ts diff --git a/web/src/constants/ui/boot.ts b/src/constants/ui/boot.ts diff --git a/web/src/constants/ui/scroll.ts b/src/constants/ui/scroll.ts diff --git a/web/src/constants/ui/tags.ts b/src/constants/ui/tags.ts diff --git a/web/src/constants/website.ts b/src/constants/website.ts diff --git a/web/src/external/git.ts b/src/external/git.ts diff --git a/web/src/external/wakatime.ts b/src/external/wakatime.ts diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro @@ -0,0 +1,547 @@ +--- +import Lightbox from "./components/Lightbox.astro"; + +import { SITE_URL } from "../constants/website"; +import { about } from "../config"; + +interface Props { + title: string; + description: string; + type: "website" | "article"; + url: string; + image?: string; + keywords?: string; + publishedTime?: string; + modifiedTime?: string; +} + +const { + title, + description, + type, + url, + image, + keywords, + publishedTime, + modifiedTime, +} = Astro.props; + +const ogImage = image + ? image.startsWith("http") + ? image + : `${SITE_URL}${image}` + : `${SITE_URL}/me.jpg`; +--- + +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>{title}</title> + <meta name="description" content={description} /> + {keywords && <meta name="keywords" content={keywords} />} + + <link rel="canonical" href={url} /> + <link rel="alternate" hreflang="en" href={url} /> + <link rel="alternate" hreflang="x-default" href={url} /> + + <!-- Preconnect for external resources --> + <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin /> + <link rel="dns-prefetch" href="https://cdn.jsdelivr.net" /> + + <!-- Preload font for performance --> + <link + rel="preload" + href="https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-400-normal.woff2" + as="font" + type="font/woff2" + crossorigin + /> + + <!-- Open Graph --> + <meta property="og:type" content={type} /> + <meta property="og:url" content={url} /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={description} /> + <meta property="og:image" content={ogImage} /> + <meta property="og:image:width" content="1200" /> + <meta property="og:image:height" content="630" /> + <meta property="og:site_name" content={about.name} /> + <meta property="og:locale" content="en_US" /> + { + type === "article" && publishedTime && ( + <meta property="article:published_time" content={publishedTime} /> + ) + } + { + type === "article" && modifiedTime && ( + <meta property="article:modified_time" content={modifiedTime} /> + ) + } + { + type === "article" && ( + <meta property="article:author" content={about.name} /> + ) + } + + <!-- Twitter Card --> + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:url" content={url} /> + <meta name="twitter:title" content={title} /> + <meta name="twitter:description" content={description} /> + <meta name="twitter:image" content={ogImage} /> + + <!-- Additional Meta --> + <meta name="author" content={about.name} /> + <meta name="robots" content="index, follow" /> + <meta name="theme-color" content="#0d0d0d" /> + + <!-- Favicons --> + <link rel="icon" type="image/png" href="/favicon.png" /> + <link rel="apple-touch-icon" href="/favicon.png" /> + <link rel="manifest" href="/site.webmanifest" /> + </head> + <body> + <div id="app"> + <slot /> + </div> + + <Lightbox /> + </body> +</html> + +<style is:global> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + :root { + --bg: #0d0d0d; + --bg-alt: #161616; + --fg: #c0c0c0; + --fg-dim: #606060; + --accent: #d7af5f; + --accent-dim: #a68a3d; + --cyan: #5fafaf; + --blue: #5f87d7; + --light-blue: #5fafff; + --yellow: #d7af5f; + --magenta: #af5faf; + --red: #d75f5f; + --border: #333; + --selection: #3d3520; + } + + @font-face { + font-family: "JetBrains Mono"; + src: url("https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-400-normal.woff2") + format("woff2"); + font-weight: 400; + } + + body { + background: var(--bg); + color: var(--fg); + font-family: "JetBrains Mono", "SF Mono", "Fira Code", monospace; + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + overflow: hidden; + } + + ::-webkit-scrollbar { + width: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--bg); + } + + ::-webkit-scrollbar-thumb { + background: var(--fg-dim); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--fg); + } + + .link { + color: var(--light-blue); + text-decoration: underline; + cursor: pointer; + } + + .link:hover { + color: var(--light-blue); + opacity: 0.8; + } + + a { + color: var(--light-blue); + text-decoration: none; + } + + a:hover { + opacity: 0.8; + } + + .color-green { + color: var(--accent); + } + .color-blue { + color: var(--blue); + } + .color-cyan { + color: var(--cyan); + } + .color-yellow { + color: var(--yellow); + } + .color-red { + color: var(--red); + } + .color-magenta { + color: var(--magenta); + } + .color-dim { + color: var(--fg-dim); + } + .bold { + font-weight: 700; + } + + #app { + display: flex; + flex-direction: column; + height: 100vh; + opacity: 0; + } + + #app.ready { + opacity: 1; + } + + #content { + flex: 1; + overflow: hidden; + } + + .page { + display: none; + height: 100%; + } + + .page.active { + display: flex; + } + + .tag { + border: none; + padding: 2px 8px; + font-size: 12px; + color: var(--bg); + background: var(--fg-dim); + } + + .tag.lang-zig { + background: #f7a41d; + } + .tag.lang-rust { + background: #dea584; + } + .tag.lang-c { + background: #a8b1c2; + } + .tag.lang-go { + background: #00add8; + } + .tag.lang-ts { + background: #3178c6; + color: #fff; + } + .tag.lang-python { + background: #3572a5; + color: #fff; + } + + .tag.tool-zed { + background: #084ccf; + color: #fff; + } + .tag.tool-neovim { + background: #57a143; + } + .tag.tool-opencode { + background: #af5faf; + color: #fff; + } + .tag.tool-linux { + background: #f7c94c; + } + .tag.tool-fish { + background: #d78700; + } + .tag.tool-ghostty { + background: #8b5cf6; + color: #fff; + } + + .tag.interest-lowlevel { + background: #d75f5f; + color: #fff; + } + .tag.interest-tui { + background: #5faf5f; + color: #0d0d0d; + } + .tag.interest-embedded { + background: #5fafaf; + color: #0d0d0d; + } + .tag.interest-unix { + background: #d7af5f; + color: #0d0d0d; + } + + .tag.tech-temporal { + background: #4c35c9; + color: #fff; + } + .tag.tech-postgres { + background: #336791; + color: #fff; + } + .tag.tech-pytorch { + background: #ee4c2c; + color: #fff; + } + .tag.tech-cuda { + background: #76b900; + } + .tag.tech-docker { + background: #2496ed; + color: #fff; + } + .tag.tech-kafka { + background: #4c35c9; + color: #fff; + } + .tag.tech-rn { + background: #61dafb; + } + .tag.tech-supabase { + background: #3ecf8e; + } + .tag.tech-rest { + background: #6b8e23; + } + + @media (max-width: 768px) { + body { + font-size: 13px; + overflow-y: auto; + } + + #app { + display: flex; + flex-direction: column; + min-height: 100vh; + height: auto; + } + + #content { + flex: 1; + overflow: visible; + } + + .page { + height: auto; + min-height: 100vh; + } + + .page.active { + display: block; + } + } + + /* Boot Animation */ + #boot-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--bg); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + + #boot-overlay.fade-out { + animation: bootFadeOut 0.3s ease-out forwards; + } + + .boot-terminal { + text-align: center; + } + + .boot-text { + display: flex; + flex-direction: column; + gap: 8px; + } + + .boot-title { + font-family: "JetBrains Mono", "SF Mono", monospace; + font-size: 32px; + font-weight: 600; + color: var(--accent); + letter-spacing: -0.5px; + } + + .boot-subtitle { + font-family: "JetBrains Mono", "SF Mono", monospace; + font-size: 14px; + color: var(--fg-dim); + letter-spacing: 0.5px; + } + + @keyframes bootFadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } + } + + #app.boot-reveal { + animation: revealContent 0.5s ease-out; + } + + @keyframes revealContent { + 0% { + opacity: 0; + transform: translateY(15px); + } + 100% { + opacity: 1; + transform: translateY(0); + } + } + + /* Help Overlay */ + #help-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + z-index: 9998; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: + opacity 0.2s, + visibility 0.2s; + } + + #help-overlay.visible { + opacity: 1; + visibility: visible; + } + + .help-modal { + background: var(--bg); + border: 1px solid var(--border); + padding: 20px 24px; + min-width: 280px; + max-width: 90vw; + } + + .help-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + } + + .help-title { + color: var(--accent); + font-weight: 600; + } + + .help-close { + color: var(--fg-dim); + font-size: 11px; + } + + .help-content { + display: flex; + flex-direction: column; + gap: 8px; + } + + .help-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + } + + .help-key { + color: var(--cyan); + font-family: "JetBrains Mono", monospace; + font-size: 12px; + min-width: 60px; + } + + .help-desc { + color: var(--fg); + font-size: 13px; + } + + @media (max-width: 768px) { + .help-modal { + padding: 16px 20px; + min-width: 260px; + } + + .help-header { + margin-bottom: 12px; + padding-bottom: 10px; + } + + .help-title { + font-size: 14px; + } + + .help-close { + font-size: 10px; + } + + .help-row { + gap: 16px; + } + + .help-key { + font-size: 11px; + min-width: 50px; + } + + .help-desc { + font-size: 12px; + } + } +</style> diff --git a/web/src/layouts/components/Article.astro b/src/layouts/components/Article.astro diff --git a/web/src/layouts/components/Footer.astro b/src/layouts/components/Footer.astro diff --git a/src/layouts/components/Header.astro b/src/layouts/components/Header.astro @@ -0,0 +1,156 @@ +--- +import { TABS } from "../../config/tabs"; +--- + +<header id="tabs"> + <nav class="tab-nav" aria-label="Main navigation"> + { + TABS.map((tab, i) => ( + <div + class:list={["tab", { active: i === 0 }]} + data-tab={tab.id} + aria-current={i === 0 ? "page" : undefined} + > + <span class="tab-num">[{i + 1}] </span> + {tab.label} + </div> + )) + } + </nav> + <div class="tab-spacer"></div> + <div class="status" id="header-status"> + <span class="dpad"> + <span class="dpad-row"><span class="dpad-key">↑</span></span> + <span class="dpad-row" + ><span class="dpad-key">←</span><span class="dpad-key">↓</span><span + class="dpad-key">→</span + ></span + > + </span> + <span class="navigate"> navigate · Tab cycle · ? help</span> + </div> +</header> + +<style> + #tabs { + display: flex; + align-items: center; + gap: 4px; + background: var(--bg-alt); + border-bottom: 1px solid var(--border); + padding: 0 12px; + height: 36px; + position: sticky; + top: 0; + z-index: 100; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + #tabs * { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + .tab-nav { + display: flex; + align-items: center; + gap: 4px; + } + + .tab { + padding: 6px 12px; + color: var(--fg-dim); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: + color 0.15s, + border-color 0.15s; + } + + .tab:hover { + color: var(--fg); + } + + .tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + + .tab-spacer { + flex: 1; + } + + .status { + display: flex; + align-items: center; + gap: 8px; + color: var(--fg-dim); + font-size: 11px; + margin-right: 8px; + } + + .dpad { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 1px; + font-size: 8px; + line-height: 1; + } + + .dpad-row { + display: flex; + gap: 1px; + } + + .dpad-key { + width: 12px; + height: 10px; + display: flex; + align-items: center; + justify-content: center; + background: var(--border); + border-radius: 2px; + color: var(--fg); + } + + @media (max-width: 768px) { + #tabs { + flex-wrap: wrap; + height: auto; + min-height: 36px; + padding: 8px 12px; + gap: 8px; + } + + .tab-nav { + width: 100%; + justify-content: space-between; + gap: 0; + } + + .tab { + padding: 6px 8px; + font-size: 12px; + flex: 1; + text-align: center; + } + + .tab-spacer { + display: none; + } + + .status { + display: none; + } + + .tab-num { + display: none; + } + } +</style> diff --git a/web/src/layouts/components/Lightbox.astro b/src/layouts/components/Lightbox.astro diff --git a/src/layouts/tabs/About.astro b/src/layouts/tabs/About.astro @@ -0,0 +1,430 @@ +--- +import type { WakaTimeData } from "../../external/wakatime"; +import { about, contact } from "../../config"; +import { currentJob } from "../../config/experience"; +import { getTagClass } from "../../utils/tags"; + +interface Props { + wakaTimeData: WakaTimeData | null; +} + +const { wakaTimeData } = Astro.props; +--- + +<section id="about" class="page active"> + <div class="about-layout"> + <div class="about-left"> + <img + class="profile-image" + src="/me.jpg" + alt="Damian Myrda - Software Engineer and high school senior from Chicago" + loading="eager" + /> + <div class="contact-box"> + <div class="box-title">─ Contact ─</div> + <div class="contact-item"> + <span class="label">Email</span> + <a href={`mailto:${contact.email}`}>{contact.email}</a> + </div> + <div class="contact-item"> + <span class="label">GitLab</span> + <a href={contact.gitlab} target="_blank" rel="noopener noreferrer me" + >git.prime8.dev/moncheeta</a + > + </div> + <div class="contact-item"> + <span class="label">LinkedIn</span> + <a + href={contact.linkedin} + target="_blank" + rel="noopener noreferrer me">linkedin.com/in/damian-myrda</a + > + </div> + </div> + </div> + <div class="about-right"> + <div class="section"> + <h1> + {about.name} + <span class="dim">@{about.handle}</span> + </h1> + <p class="bio">{about.bio}</p> + </div> + <div class="section"> + <div class="box-title">─ Info ─</div> + <div class="info-columns"> + <div class="info-column"> + <div class="info-row"> + <span class="label">Location</span> + {about.location} + </div> + <div class="info-row"> + <span class="label">School</span> + {about.school} + </div> + <div class="info-row"> + <span class="label">Graduating</span> + {about.graduating} + </div> + </div> + { + currentJob && ( + <div class="info-column"> + <div class="info-row"> + <span class="label">Company</span> + <a href={`#experience/${currentJob.id}`}> + {currentJob.company} + </a> + </div> + <div class="info-row"> + <span class="label">Position</span> + {currentJob.role} + </div> + </div> + ) + } + </div> + </div> + <div class="section"> + <div class="box-title">─ Interests ─</div> + <div class="tags"> + { + about.interests.map((interest) => ( + <span class={`tag ${getTagClass(interest)}`}>{interest}</span> + )) + } + </div> + </div> + <div class="section"> + <div class="box-title">─ Tools ─</div> + <div class="tags"> + { + about.tools.map((tool) => ( + <span class={`tag ${getTagClass(tool)}`}>{tool}</span> + )) + } + </div> + </div> + <div class="section"> + <div class="box-title">─ Languages ─</div> + <div class="tags"> + { + about.languages.map((lang) => ( + <span class={`tag ${getTagClass(lang)}`}>{lang}</span> + )) + } + </div> + </div> + { + wakaTimeData?.weeklyStats && ( + <div class="section"> + <div class="box-title">─ Activity ─</div> + <div class="weekly-stats"> + <div class="weekly-total"> + {wakaTimeData.weeklyStats.totalText} + </div> + <div class="weekly-days"> + {wakaTimeData.weeklyStats.days.map((day) => ( + <div class="weekly-day-row"> + <span class="weekly-day-name">{day.dayText}</span> + <div class="weekly-day-bar-container"> + <div + class="weekly-day-bar" + style={`width: ${day.barWidthPercent}`} + /> + </div> + <span class="weekly-day-time">{day.timeText}</span> + </div> + ))} + </div> + </div> + </div> + ) + } + </div> + </div> +</section> + +<style> + .about-layout { + display: flex; + width: 100%; + height: 100%; + } + + .about-left { + width: 320px; + flex-shrink: 0; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 16px; + gap: 16px; + overflow-y: auto; + } + + .about-right { + flex: 1; + padding: 24px 32px; + overflow-y: auto; + display: flex; + flex-direction: column; + justify-content: space-evenly; + } + + .profile-image { + width: 100%; + max-width: 280px; + height: auto; + border-radius: 4px; + border: 1px solid var(--border); + } + + .contact-box { + border: 1px solid var(--border); + padding: 12px; + max-width: 280px; + } + + .box-title { + color: var(--accent); + font-size: 12px; + margin-bottom: 8px; + text-align: center; + } + + .contact-item { + display: flex; + margin-bottom: 4px; + font-size: 12px; + min-width: 0; + } + + .contact-item .label { + color: var(--fg-dim); + width: 70px; + flex-shrink: 0; + } + + .contact-item a { + color: var(--light-blue); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + } + + .contact-item a:hover { + opacity: 0.8; + } + + .section { + flex-shrink: 0; + } + + .section h1 { + font-size: 18px; + font-weight: 400; + margin-bottom: 4px; + } + + .section h1 .dim { + color: var(--fg-dim); + font-size: 14px; + } + + .section .bio { + color: var(--fg-dim); + font-style: italic; + } + + .info-columns { + display: flex; + gap: 48px; + } + + .info-column { + flex: 1; + } + + .info-row { + display: flex; + font-size: 13px; + margin-bottom: 4px; + } + + .info-row .label { + color: var(--fg-dim); + width: 100px; + flex-shrink: 0; + } + + .info-row a { + color: var(--light-blue); + text-decoration: none; + } + + .info-row a:hover { + opacity: 0.8; + } + + .tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .weekly-stats { + background: var(--bg-alt); + border: 1px solid var(--border); + padding: 12px; + } + + .weekly-total { + font-size: 16px; + font-weight: 500; + color: var(--accent); + margin-bottom: 12px; + } + + .weekly-days { + display: flex; + flex-direction: column; + gap: 6px; + } + + .weekly-day-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + } + + .weekly-day-name { + width: 32px; + flex-shrink: 0; + color: var(--fg-dim); + } + + .weekly-day-bar-container { + flex: 1; + height: 8px; + background: var(--bg); + border-radius: 2px; + overflow: hidden; + } + + .weekly-day-bar { + height: 100%; + background: var(--accent); + border-radius: 2px; + } + + .weekly-day-time { + min-width: 50px; + flex-shrink: 0; + text-align: right; + color: var(--fg-dim); + white-space: nowrap; + } + + @media (max-width: 768px) { + .about-layout { + flex-direction: column; + } + + .about-left { + display: flex; + flex-direction: row; + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border); + padding: 16px; + gap: 16px; + } + + .profile-image { + width: 120px; + min-width: 60px; + flex-shrink: 1; + height: 120px; + align-self: flex-start; + object-fit: cover; + object-position: center; + } + + .contact-box { + flex: 1; + max-width: none; + min-width: 0; + } + + .contact-item .label { + width: 70px; + } + + .contact-item a { + overflow: hidden; + text-overflow: ellipsis; + } + + .about-right { + padding: 16px; + display: block; + } + + .section { + margin-bottom: 16px; + } + + .section h1 { + font-size: 16px; + } + + .section h1 .dim { + font-size: 12px; + } + + .info-columns { + flex-direction: column; + gap: 12px; + } + + .tags { + gap: 4px; + } + + .tag { + font-size: 11px; + padding: 2px 6px; + } + + .weekly-stats { + padding: 10px; + } + + .weekly-total { + font-size: 14px; + } + + .weekly-day-row { + font-size: 11px; + } + + .weekly-day-name { + width: 28px; + } + + .weekly-day-time { + min-width: 45px; + } + } + + @media (max-width: 480px) { + .contact-item .label { + display: none; + } + } +</style> diff --git a/src/layouts/tabs/Blog.astro b/src/layouts/tabs/Blog.astro @@ -0,0 +1,230 @@ +--- +import BlogArticle from "../components/Article.astro"; + +import { blogs } from "../../blogs"; +--- + +<section id="blog" class="page"> + { + blogs.length === 0 ? ( + <div class="blog-empty"> + <p class="color-dim">No blog posts yet.</p> + <p class="color-dim">Check back soon!</p> + </div> + ) : ( + <> + <div id="blog-list-view" class="blog-list-view"> + <div class="blog-list-container"> + {blogs.map((blog, i) => ( + <div class="blog-list-item" data-index={i} data-id={blog.id}> + <span class="blog-list-date">{blog.date}</span> + <span class="blog-list-title">{blog.title}</span> + <span class="blog-list-arrow">→</span> + </div> + ))} + </div> + </div> + + <div id="blog-post-view" class="blog-post-view hidden"> + <div class="blog-post-container"> + <button class="blog-back-btn">← Back to posts</button> + {blogs.map((blog, i) => ( + <div + class="blog-post hidden" + data-index={i} + data-id={blog.id} + data-blog-id={blog.id} + > + <BlogArticle blog={blog} /> + </div> + ))} + </div> + </div> + </> + ) + } +</section> + +<style> + .blog-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + gap: 8px; + } + + .blog-list-view { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + } + + .blog-list-view.hidden { + display: none; + } + + .blog-list-container { + max-width: 600px; + width: 100%; + padding: 32px; + } + + .blog-list-item { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + border: 1px solid var(--border); + margin-bottom: 12px; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; + text-decoration: none; + color: inherit; + } + + .blog-list-item:hover { + background: var(--selection); + border-color: var(--accent); + } + + .blog-list-item.selected { + background: var(--bg-alt); + border-color: var(--accent); + } + + .blog-list-item.selected:hover { + background: var(--selection); + } + + .blog-list-item:last-child { + margin-bottom: 0; + } + + .blog-list-date { + color: var(--fg-dim); + font-size: 12px; + flex-shrink: 0; + width: 90px; + } + + .blog-list-title { + flex: 1; + color: var(--fg); + font-weight: 500; + } + + .blog-list-arrow { + color: var(--fg-dim); + font-size: 14px; + transition: + transform 0.15s, + color 0.15s; + } + + .blog-list-item:hover .blog-list-arrow { + color: var(--accent); + transform: translateX(4px); + } + + .blog-post-view { + display: flex; + justify-content: center; + height: 100%; + width: 100%; + overflow-y: auto; + padding: 32px; + } + + .blog-post-view.hidden { + display: none; + } + + .blog-post-container { + max-width: 700px; + width: 100%; + } + + .blog-post.hidden { + display: none; + } + + .blog-back-btn { + display: inline-block; + background: transparent; + border: 1px solid var(--border); + color: var(--fg-dim); + padding: 8px 16px; + cursor: pointer; + font-family: inherit; + font-size: 12px; + margin-bottom: 24px; + transition: all 0.15s; + text-decoration: none; + } + + .blog-back-btn:hover { + background: var(--bg-alt); + color: var(--accent); + border-color: var(--accent); + } + + @media (max-width: 768px) { + .blog-list-container { + padding: 16px; + } + + .blog-list-item { + gap: 12px; + padding: 12px 16px; + margin-bottom: 10px; + } + + .blog-list-date { + font-size: 11px; + width: 80px; + } + + .blog-list-title { + font-size: 14px; + } + + .blog-post-view { + padding: 16px; + } + + .blog-back-btn { + padding: 6px 12px; + font-size: 11px; + margin-bottom: 16px; + } + } + + @media (max-width: 480px) { + .blog-list-item { + flex-wrap: wrap; + gap: 4px; + } + + .blog-list-date { + width: auto; + order: 2; + } + + .blog-list-title { + width: 100%; + order: 1; + } + + .blog-list-arrow { + order: 3; + margin-left: auto; + } + } +</style> diff --git a/src/layouts/tabs/Experience.astro b/src/layouts/tabs/Experience.astro @@ -0,0 +1,486 @@ +--- +import { experience } from "../../config"; +import { calculateMonths } from "../../utils/period"; +import { MONTH_NAMES } from "../../constants/calendar"; +import { getTagClass } from "../../utils/tags"; + +const dates = experience.map((exp) => { + const [year, month] = exp.startDate.split("-").map(Number); + return new Date(year, month - 1, 1); +}); +const endDates = experience.map((exp) => + exp.endDate + ? new Date( + parseInt(exp.endDate.split("-")[0]), + parseInt(exp.endDate.split("-")[1]) - 1, + 28, + ) + : new Date(), +); +const earliestDate = new Date(Math.min(...dates.map((d) => d.getTime()))); +const latestDate = new Date(Math.max(...endDates.map((d) => d.getTime()))); + +const timelineStart = new Date( + earliestDate.getFullYear(), + earliestDate.getMonth(), + 1, +); +const timelineEnd = latestDate; + +const monthTicks: string[] = []; +let current = new Date(timelineStart); +while (current <= timelineEnd) { + monthTicks.push(MONTH_NAMES[current.getMonth()][0]); + current.setMonth(current.getMonth() + 1); +} +--- + +<section id="experience" class="page"> + <article class="experience-layout"> + <div class="experience-detail"> + { + experience.map((exp, index) => { + const months = calculateMonths(exp.startDate, exp.endDate); + const [startYear, startMonth] = exp.startDate.split("-").map(Number); + const startStr = `${MONTH_NAMES[startMonth - 1]} ${startYear}`; + const endStr = exp.endDate + ? `${MONTH_NAMES[parseInt(exp.endDate.split("-")[1]) - 1]} ${exp.endDate.split("-")[0]}` + : "Present"; + + const isLast = index === experience.length - 1; + + return ( + <div class="exp-row" data-index={index} data-id={exp.id}> + <div class="exp-card" data-index={index} data-id={exp.id}> + <div class="exp-card-header"> + <div class="exp-card-title"> + {exp.logo ? ( + <div class="exp-title-with-logo"> + <img + src={exp.logo} + alt={`${exp.company} logo`} + class={`exp-logo ${exp.logoFilter || ""}`} + /> + <span class="exp-role">{exp.role}</span> + </div> + ) : ( + <div class="exp-title-text"> + <h2>{exp.company}</h2> + <span class="exp-role">{exp.role}</span> + </div> + )} + </div> + <div class="exp-card-meta"> + <span class="exp-dates"> + {startStr} → {endStr} + </span> + <span class="exp-duration">{months} months</span> + {exp.url && ( + <a + class="external-link" + href={exp.url} + target="_blank" + rel="noopener noreferrer" + > + {new URL(exp.url).hostname} → + </a> + )} + </div> + </div> + <div class="exp-card-tech"> + {exp.tech.map((tech) => ( + <span class={`tag ${getTagClass(tech)}`}>{tech}</span> + ))} + </div> + <div class="exp-card-body"> + {exp.description.map((description) => ( + <p>{description}</p> + ))} + </div> + {exp.images && exp.images.length > 0 && ( + <div class="exp-card-images"> + <img + src={exp.images[0]} + alt={`${exp.company} workplace, ${exp.role} experience at ${exp.company}`} + class="exp-image-hero" + loading="lazy" + data-idx="0" + /> + {exp.images.length > 1 && ( + <div class="exp-image-grid"> + {exp.images.slice(1).map((image, index) => ( + <img + src={image} + alt={`${exp.company} team and office environment, image ${index + 2} of ${exp.images!.length}`} + class="exp-image-thumb" + loading="lazy" + data-idx={index + 1} + /> + ))} + </div> + )} + </div> + )} + </div> + <div + class={`exp-timeline ${index === 0 ? "active" : ""}`} + data-index={index} + > + <span class="timeline-dot" /> + <span class="timeline-year"> + [{exp.endDate ? exp.endDate.split("-")[0] : "Now"}] + </span> + {!isLast && <div class="timeline-line" />} + </div> + </div> + ); + }) + } + </div> + </article> +</section> + +<style> + .experience-layout { + display: flex; + flex-direction: row; + height: 100%; + width: 100%; + padding: 24px 32px; + gap: 24px; + } + + .experience-detail { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + padding: 4px; + } + + .exp-card { + background: var(--bg-alt); + border: 2px solid var(--border); + border-radius: 4px; + padding: 24px; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0; + scroll-snap-align: start; + transition: border-color 0.2s ease; + } + + .exp-card.selected { + border-color: var(--accent); + } + + .exp-row { + display: flex; + } + + .exp-timeline { + width: 64px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 24px; + position: relative; + } + + .timeline-dot { + width: 12px; + height: 12px; + border: 2px solid var(--fg-dim); + border-radius: 50%; + background: transparent; + transition: all 0.2s ease; + font-size: 0; + } + + .exp-timeline.active .timeline-dot { + border-color: var(--accent); + background: var(--accent); + transform: scale(1.2); + } + + .timeline-year { + font-size: 10px; + color: var(--fg-dim); + font-family: monospace; + margin-top: 4px; + transition: color 0.2s ease; + } + + .exp-timeline.active .timeline-year { + color: var(--accent); + } + + .timeline-line { + position: absolute; + top: 56px; + bottom: -16px; + width: 2px; + left: 50%; + transform: translateX(-50%); + background: repeating-linear-gradient( + to bottom, + var(--border) 0px, + var(--border) 4px, + transparent 4px, + transparent 8px + ); + } + + .exp-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + gap: 24px; + } + + .exp-card-title { + display: flex; + align-items: center; + gap: 16px; + } + + .exp-logo { + height: 32px; + width: auto; + flex-shrink: 0; + } + + .exp-logo.logo-invert { + filter: brightness(0) invert(1); + } + + .exp-title-with-logo { + display: flex; + flex-direction: column; + gap: 4px; + } + + .exp-title-with-logo .exp-role { + color: #999; + font-size: 14px; + } + + .exp-title-with-logo .exp-duration { + color: #aaa; + font-size: 12px; + } + + .exp-title-text h2 { + font-size: 22px; + font-weight: 500; + color: var(--accent); + margin: 0 0 4px 0; + } + + .exp-title-text .exp-role { + font-size: 14px; + color: #999; + } + + .exp-card-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + font-size: 13px; + } + + .exp-dates { + color: var(--fg); + } + + .exp-duration { + color: #aaa; + font-size: 12px; + } + + .exp-card-tech { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 20px; + } + + .exp-card-body { + color: var(--fg); + line-height: 1.7; + padding-bottom: 0; + margin-bottom: 0; + } + + .exp-card-body p { + margin-bottom: 12px; + } + + .exp-card-body p:last-child { + margin-bottom: 0; + } + + .exp-card-images { + display: flex; + gap: 12px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); + max-height: 180px; + overflow: hidden; + } + + .exp-image-hero { + max-width: 280px; + height: 100%; + border-radius: 4px; + border: 1px solid var(--border); + object-fit: cover; + cursor: pointer; + transition: + transform 0.15s, + box-shadow 0.15s; + } + + .exp-image-hero:hover { + opacity: 0.8; + border-color: var(--accent); + } + + .exp-image-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: 400px; + } + + .exp-image-thumb { + width: calc(50% - 4px); + height: 86px; + border-radius: 4px; + border: 1px solid var(--border); + object-fit: cover; + cursor: pointer; + transition: + opacity 0.15s, + border-color 0.15s; + } + + .exp-image-thumb:hover { + opacity: 0.8; + border-color: var(--accent); + } + + .external-link { + color: var(--light-blue); + text-decoration: none; + font-size: 12px; + } + + .external-link:hover { + opacity: 0.8; + } + + @media (max-width: 768px) { + .experience-layout { + padding: 16px; + gap: 12px; + } + + .exp-timeline { + display: none; + } + + .exp-card { + padding: 16px; + } + + .exp-card.selected { + border-color: var(--border); + } + + .exp-card-header { + flex-direction: column; + gap: 12px; + } + + .exp-card-meta { + align-items: flex-start; + } + + .exp-logo { + height: 28px; + } + + .exp-title-text h2 { + font-size: 18px; + } + + .exp-card-tech { + gap: 4px; + } + + .exp-card-body { + font-size: 13px; + } + + .exp-card-images { + flex-direction: column; + gap: 12px; + max-height: none; + overflow: visible; + } + + .exp-image-hero { + max-width: 100%; + height: auto; + max-height: 200px; + } + + .exp-image-grid { + width: 100%; + max-width: none; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + } + + .exp-image-thumb { + width: calc(33.33% - 6px); + height: 80px; + } + } + + @media (max-width: 480px) { + .exp-image-thumb { + width: calc(50% - 4px); + } + } +</style> + +<script> + document + .querySelectorAll(".exp-image-hero, .exp-image-thumb") + .forEach((img) => { + img.addEventListener("click", () => { + const container = (img as HTMLElement).closest(".exp-card-images"); + const allImages = container + ? Array.from(container.querySelectorAll("img")).map( + (i) => (i as HTMLImageElement).src, + ) + : []; + const startIndex = allImages.indexOf((img as HTMLImageElement).src); + ( + window as Window & { + openLightbox?: (images: string[], startIndex?: number) => void; + } + ).openLightbox?.(allImages, startIndex); + }); + }); +</script> diff --git a/src/layouts/tabs/Projects.astro b/src/layouts/tabs/Projects.astro @@ -0,0 +1,411 @@ +--- +import { projects } from "../../config/projects"; +import { fetchGitMetadataForProject } from "../../external/git"; +import { getTechMeta, getTagClass } from "../../utils/tags"; +import { parseMarkdown, removeFirstH1 } from "../../utils/markdown"; + +const metadata = Object.fromEntries( + ( + await Promise.all( + projects.map(async (project) => [ + project.id, + await fetchGitMetadataForProject(project), + ]), + ) + ).filter(([, meta]) => meta !== null), +); +--- + +<section id="projects" class="page"> + <aside class="list-view" aria-label="Project list"> + { + projects.map((prg, i) => ( + <div + class={`list-item ${i === 0 ? "selected" : ""}`} + data-index={i} + data-id={prg.id} + > + <div class="list-header"> + <span class="list-title">{prg.name}</span> + <span class="list-meta">{getTechMeta(prg.tech)}</span> + </div> + <div class="list-subtitle">{prg.subtitle}</div> + </div> + )) + } + </aside> + <article class="detail-view"> + { + projects.map((prg, index) => ( + <div + class={`detail-content ${index !== 0 ? "hidden" : ""}`} + data-for={index} + data-id={prg.id} + > + <div class="detail-header"> + <h2>{prg.name}</h2> + <a + class="external-link" + href={prg.url} + target="_blank" + rel="noopener noreferrer" + > + {prg.url.includes("github.com") ? "GitHub" : "GitLab"} → + </a> + </div> + <div class="tech-stack"> + {prg.tech.map((tech) => ( + <span class={`tag ${getTagClass(tech)}`}>{tech}</span> + ))} + </div> + <div class="readme-content" data-readme={prg.id}> + {metadata?.[prg.id]?.readme ? ( + <Fragment + set:html={parseMarkdown(removeFirstH1(metadata[prg.id].readme))} + /> + ) : ( + <p class="color-dim">README not available</p> + )} + </div> + </div> + )) + } + </article> +</section> + +<style> + aside.list-view { + width: 300px; + flex-shrink: 0; + border-right: 1px solid var(--border); + overflow-y: auto; + } + + article.detail-view { + flex: 1; + padding: 24px 32px; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .list-item { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; + } + + .list-item:hover { + background: var(--bg-alt); + } + + .list-item.selected { + background: var(--selection); + border-left: 3px solid var(--accent); + } + + .list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2px; + } + + .list-title { + font-weight: 600; + color: var(--fg); + } + + .list-meta { + font-size: 11px; + color: var(--fg-dim); + } + + .list-subtitle { + font-size: 12px; + color: var(--fg-dim); + } + + .detail-content { + display: flex; + flex-direction: column; + height: 100%; + } + + .detail-content.hidden { + display: none; + } + + .detail-content h2 { + font-size: 20px; + font-weight: 400; + color: var(--accent); + margin-bottom: 4px; + } + + .detail-content .tech-stack { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 16px; + } + + .detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .detail-header h2 { + margin-bottom: 0; + } + + .external-link { + color: var(--light-blue); + text-decoration: none; + font-size: 12px; + } + + .external-link:hover { + opacity: 0.8; + } + + .readme-content { + flex: 1; + min-height: 0; + overflow-y: auto; + font-size: 13px; + line-height: 1.6; + color: var(--fg); + margin-top: 16px; + padding-top: 16px; + padding-right: 12px; + border-top: 1px solid var(--border); + } + + .readme-content :global(p) { + margin-bottom: 12px; + } + + .readme-content :global(p:last-child) { + margin-bottom: 0; + } + + .readme-content :global(h2), + .readme-content :global(.readme-h1) { + font-size: 16px; + color: var(--accent); + margin: 20px 0 8px; + font-weight: 600; + } + + .readme-content :global(h2:first-child), + .readme-content :global(.readme-h1:first-child) { + margin-top: 0; + } + + .readme-content :global(h3), + .readme-content :global(.readme-h2) { + font-size: 14px; + color: var(--accent); + margin: 16px 0 8px; + font-weight: 500; + } + + .readme-content :global(h4), + .readme-content :global(.readme-h3) { + font-size: 13px; + color: var(--fg); + margin: 12px 0 6px; + font-weight: 500; + } + + .readme-content :global(h5), + .readme-content :global(h6) { + font-size: 12px; + color: var(--fg-dim); + margin: 10px 0 6px; + font-weight: 500; + } + + .readme-content :global(pre) { + display: block; + background: var(--bg-alt); + border: 1px solid var(--border); + padding: 12px; + margin: 12px 0; + overflow-x: auto; + font-size: 12px; + line-height: 1.4; + } + + .readme-content :global(pre code) { + background: none; + padding: 0; + font-size: inherit; + } + + .readme-content :global(code) { + background: var(--bg-alt); + padding: 2px 6px; + font-size: 12px; + border-radius: 2px; + } + + .readme-content :global(a) { + color: var(--light-blue); + text-decoration: none; + } + + .readme-content :global(a:hover) { + opacity: 0.8; + } + + .readme-content :global(ul), + .readme-content :global(ol) { + margin: 8px 0; + padding-left: 20px; + } + + .readme-content :global(li) { + margin-bottom: 4px; + } + + .readme-content :global(li > ul), + .readme-content :global(li > ol) { + margin: 4px 0; + } + + .readme-content :global(table) { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 12px; + } + + .readme-content :global(th), + .readme-content :global(td) { + border: 1px solid var(--border); + padding: 8px 12px; + text-align: left; + } + + .readme-content :global(th) { + background: var(--bg-alt); + color: var(--accent); + font-weight: 500; + } + + .readme-content :global(tr:nth-child(even)) { + background: rgba(255, 255, 255, 0.02); + } + + .readme-content :global(blockquote) { + border-left: 3px solid var(--accent); + margin: 12px 0; + padding: 8px 16px; + color: var(--fg-dim); + background: var(--bg-alt); + } + + .readme-content :global(blockquote p) { + margin-bottom: 0; + } + + .readme-content :global(hr) { + border: none; + border-top: 1px solid var(--border); + margin: 20px 0; + } + + .readme-content :global(img) { + max-width: 100%; + height: auto; + margin: 12px 0; + } + + .readme-content :global(strong) { + color: var(--fg); + font-weight: 600; + } + + .readme-content :global(em) { + font-style: italic; + color: var(--fg-dim); + } + + .readme-content :global(del) { + text-decoration: line-through; + color: var(--fg-dim); + } + + @media (max-width: 768px) { + #projects { + flex-direction: column; + } + + aside.list-view { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border); + max-height: 200px; + overflow-y: auto; + } + + .list-item { + padding: 10px 14px; + } + + .list-title { + font-size: 13px; + } + + .list-subtitle { + font-size: 11px; + } + + article.detail-view { + padding: 16px; + overflow-y: auto; + } + + .detail-content h2 { + font-size: 18px; + } + + .detail-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .readme-content { + font-size: 12px; + } + + .readme-content :global(pre) { + font-size: 11px; + padding: 10px; + } + + .readme-content :global(code) { + font-size: 11px; + } + } + + @media (max-width: 480px) { + aside.list-view { + max-height: 150px; + } + + .list-item { + padding: 8px 12px; + } + + .list-meta { + display: none; + } + } +</style> diff --git a/web/src/pages/404.astro b/src/pages/404.astro diff --git a/web/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro diff --git a/src/pages/experience/[id].astro b/src/pages/experience/[id].astro @@ -0,0 +1,376 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import Lightbox from "../../layouts/components/Lightbox.astro"; + +import { SITE_URL } from "../../constants/website"; +import { experience as experiences } from "../../config"; +import { calculateMonths } from "../../utils/period"; +import { MONTH_NAMES } from "../../constants/calendar"; +import { getTagClass } from "../../utils/tags"; +import schemas from "../../schemas/index"; + +const exp = experiences.find((e) => e.id === Astro.params.id); +if (!exp) { + return new Response(null, { + status: 404, + statusText: "Not Found", + }); +} + +const months = calculateMonths(exp.startDate, exp.endDate); +const [startYear, startMonth] = exp.startDate.split("-").map(Number); +const startStr = `${MONTH_NAMES[startMonth - 1]} ${startYear}`; +const endStr = exp.endDate + ? `${MONTH_NAMES[parseInt(exp.endDate.split("-")[1]) - 1]} ${exp.endDate.split("-")[0]}` + : "Present"; + +const keywords = [...exp.tech, exp.company, exp.role].join(", "); +const description = `${exp.role} at ${exp.company}. ${exp.description[0]}`; + +const { getSchemaForExperience, getBreadcrumbSchema } = schemas.experience; +--- + +<Layout + title={`${exp.company} - ${exp.role} | Damian Myrda`} + description={description} + type="website" + url={`${SITE_URL}/experience/${exp.id}`} + keywords={keywords} + image={exp.logo} +> + <main id="content"> + <div class="experience-page"> + <a href="/#experience" class="back-btn">← See more experience</a> + + <article class="experience-article"> + <header class="article-header"> + <div class="header-top"> + <div class="title-section"> + <div class="exp-title-with-logo"> + <img + src={exp.logo} + alt={exp.company} + class={`exp-logo ${exp.logoFilter || ""}`} + /> + <span class="exp-role">{exp.role}</span> + </div> + </div> + <div class="exp-card-meta"> + <span class="exp-dates">{startStr} → {endStr}</span> + <span class="exp-duration">{months} months</span> + { + exp.url && ( + <a + class="external-link" + href={exp.url} + target="_blank" + rel="noopener noreferrer" + > + {new URL(exp.url).hostname} → + </a> + ) + } + </div> + </div> + </header> + + <section class="tech-tags"> + { + exp.tech.map((tech) => ( + <span class={`tag ${getTagClass(tech)}`}>{tech}</span> + )) + } + </section> + + <section class="article-content"> + {exp.description.map((paragraph) => <p>{paragraph}</p>)} + </section> + + { + exp.images && exp.images.length > 0 && ( + <section class="images-section"> + <h2>Photos</h2> + <div class="image-grid"> + {exp.images.map((img, i) => ( + <img + src={img} + alt={`${exp.company} photo ${i + 1}`} + class="experience-image" + loading="lazy" + /> + ))} + </div> + </section> + ) + } + </article> + </div> + <script + type="application/ld+json" + set:html={JSON.stringify(getSchemaForExperience(exp))} + /> + <script + type="application/ld+json" + set:html={JSON.stringify(getBreadcrumbSchema(exp))} + /> + </main> + <Lightbox /> +</Layout> + +<script> + document.getElementById("app")?.classList.add("ready"); + + // Lightbox click handlers for images + document.querySelectorAll(".experience-image").forEach((img) => { + img.addEventListener("click", () => { + const container = (img as HTMLElement).closest(".image-grid"); + const allImages = container + ? Array.from(container.querySelectorAll("img")).map( + (i) => (i as HTMLImageElement).src, + ) + : []; + const startIndex = allImages.indexOf((img as HTMLImageElement).src); + ( + window as Window & { + openLightbox?: (images: string[], startIndex?: number) => void; + } + ).openLightbox?.(allImages, startIndex); + }); + }); +</script> + +<style> + :global(body) { + overflow: auto; + } + + :global(#app) { + height: auto; + min-height: 100vh; + } + + :global(#content) { + overflow: visible; + } + + .experience-page { + max-width: 720px; + margin: 0 auto; + padding: 40px 24px 40px; + min-height: 100vh; + } + + .back-btn { + display: inline-block; + background: transparent; + border: 1px solid var(--border); + color: var(--fg-dim); + padding: 8px 16px; + cursor: pointer; + font-family: inherit; + font-size: 12px; + margin-bottom: 24px; + transition: all 0.15s; + text-decoration: none; + } + + .back-btn:hover { + background: var(--bg-alt); + color: var(--accent); + border-color: var(--accent); + } + + .experience-article { + animation: fadeIn 0.2s ease; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .article-header { + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); + } + + .header-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + } + + .title-section { + display: flex; + align-items: center; + gap: 16px; + } + + .exp-logo { + height: 32px; + width: auto; + flex-shrink: 0; + } + + .exp-logo.logo-invert { + filter: brightness(0) invert(1); + } + + .exp-title-with-logo { + display: flex; + flex-direction: column; + gap: 4px; + } + + .exp-title-with-logo .exp-role { + color: #999; + font-size: 14px; + } + + .exp-card-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + font-size: 13px; + } + + .exp-dates { + color: var(--fg); + } + + .exp-duration { + color: #aaa; + font-size: 12px; + } + + .external-link { + color: var(--light-blue); + text-decoration: none; + font-size: 12px; + } + + .external-link:hover { + opacity: 0.8; + } + + .tech-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 24px; + } + + .article-content { + font-size: 14px; + line-height: 1.7; + color: var(--fg); + margin-bottom: 32px; + } + + .article-content p { + margin-bottom: 16px; + } + + .article-content p:last-child { + margin-bottom: 0; + } + + .images-section h2 { + font-size: 16px; + color: var(--accent); + margin-bottom: 16px; + font-weight: 500; + } + + .image-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .experience-image { + width: 100%; + height: 180px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--border); + cursor: pointer; + transition: + opacity 0.15s, + border-color 0.15s; + } + + .experience-image:hover { + opacity: 0.9; + border-color: var(--accent); + } + + @media (max-width: 768px) { + .experience-page { + padding: 24px 16px 0; + } + + .back-btn { + padding: 6px 12px; + font-size: 11px; + margin-bottom: 16px; + } + + .header-top { + flex-direction: column; + gap: 12px; + } + + .title-section { + gap: 12px; + } + + .company-logo { + height: 36px; + } + + h1 { + font-size: 22px; + } + + .role { + font-size: 14px; + } + + .article-content { + font-size: 13px; + } + + .image-grid { + grid-template-columns: 1fr; + } + + .experience-image { + height: 200px; + } + } +</style> + +<script> + import { initKeyboard, onKey } from "../../ui/keyboard"; + import { SCROLL_AMOUNT } from "../../constants/ui/scroll"; + + initKeyboard(); + + onKey("ArrowUp", (e) => { + e.preventDefault(); + window.scrollBy({ top: -SCROLL_AMOUNT, behavior: "smooth" }); + }); + + onKey("ArrowDown", (e) => { + e.preventDefault(); + window.scrollBy({ top: SCROLL_AMOUNT, behavior: "smooth" }); + }); +</script> diff --git a/web/src/pages/index.astro b/src/pages/index.astro diff --git a/web/src/pages/projects/[id].astro b/src/pages/projects/[id].astro diff --git a/web/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts diff --git a/src/schemas/blog.ts b/src/schemas/blog.ts @@ -0,0 +1,60 @@ +import type { Blog } from "../blogs"; +import { SITE_URL } from "../constants/website"; +import personSchema from "./person"; + +export function getSchemaForBlog(blog: Blog) { + const schema: Record<string, unknown> = { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: blog.title, + description: blog.description, + datePublished: blog.date, + url: `${SITE_URL}/blog/${blog.id}`, + author: personSchema, + publisher: personSchema, + mainEntityOfPage: { + "@type": "WebPage", + "@id": `${SITE_URL}/blog/${blog.id}`, + }, + articleSection: "Technology", + }; + + if (blog.image) { + schema.image = blog.image.startsWith("http") + ? blog.image + : `${SITE_URL}${blog.image}`; + } + + if (blog.tags && blog.tags.length > 0) { + schema.keywords = blog.tags.join(", "); + } + + return schema; +} + +export function getBreadcrumbSchema(blog: Blog) { + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: SITE_URL, + }, + { + "@type": "ListItem", + position: 2, + name: "Blog", + item: `${SITE_URL}/#blog`, + }, + { + "@type": "ListItem", + position: 3, + name: blog.title, + item: `${SITE_URL}/blog/${blog.id}`, + }, + ], + }; +} diff --git a/web/src/schemas/experience.ts b/src/schemas/experience.ts diff --git a/web/src/schemas/index.ts b/src/schemas/index.ts diff --git a/web/src/schemas/person.ts b/src/schemas/person.ts diff --git a/web/src/schemas/project.ts b/src/schemas/project.ts diff --git a/web/src/schemas/website.ts b/src/schemas/website.ts diff --git a/web/src/tabs/about.ts b/src/tabs/about.ts diff --git a/web/src/tabs/blog.ts b/src/tabs/blog.ts diff --git a/web/src/tabs/experience.ts b/src/tabs/experience.ts diff --git a/web/src/tabs/projects.ts b/src/tabs/projects.ts diff --git a/web/src/ui/boot.ts b/src/ui/boot.ts diff --git a/web/src/ui/dom.ts b/src/ui/dom.ts diff --git a/src/ui/help.ts b/src/ui/help.ts @@ -0,0 +1,71 @@ +import { onKey } from "./keyboard"; + +interface Shortcut { + key: string; + description: string; +} + +const shortcuts: Shortcut[] = [ + { key: "← →", description: "navigate tabs" }, + { key: "↑ ↓", description: "scroll content" }, + { key: "Tab", description: "cycle tabs" }, + { key: "1-4", description: "switch tabs" }, + { key: "Enter", description: "open item" }, + { key: "?", description: "toggle help" }, +]; + +export function initHelp(): void { + createHelpOverlay(); +} + +export function setupHelpKeybinds(): void { + onKey("?", () => { + toggleHelp(); + }); +} + +function createHelpOverlay(): void { + const overlay = document.createElement("div"); + overlay.id = "help-overlay"; + overlay.innerHTML = ` + <div class="help-modal"> + <div class="help-header"> + <span class="help-title">Help</span> + <span class="help-close">Press ? to close</span> + </div> + <div class="help-content"> + ${shortcuts + .map( + (s) => ` + <div class="help-row"> + <span class="help-key">${s.key}</span> + <span class="help-desc">${s.description}</span> + </div> + `, + ) + .join("")} + </div> + </div> + `; + document.body.appendChild(overlay); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) { + hideHelp(); + } + }); +} + +function toggleHelp(): void { + const overlay = document.getElementById("help-overlay"); + if (!overlay) return; + + overlay.classList.toggle("visible"); +} + +function hideHelp(): void { + const overlay = document.getElementById("help-overlay"); + if (!overlay) return; + + overlay.classList.remove("visible"); +} diff --git a/src/ui/index.ts b/src/ui/index.ts @@ -0,0 +1,27 @@ +import { playBootAnimation } from "./boot"; +import { initRouter } from "./router"; +import { routeHandler, initTabs } from "./tabs"; +import { initKeyboard } from "./keyboard"; +import { initStatus } from "./status"; +import { initHelp } from "./help"; + +// Register tabs (order matters) +import "../tabs/about"; +import "../tabs/experience"; +import "../tabs/projects"; +import "../tabs/blog"; + +export async function initUI(): Promise<void> { + const promise = playBootAnimation(); + + // Initialize core systems + initKeyboard(); + initTabs(); + initRouter(routeHandler); + + // Initialize UI modules + initStatus(); + initHelp(); + + await promise; +} diff --git a/web/src/ui/keyboard.ts b/src/ui/keyboard.ts diff --git a/web/src/ui/router.ts b/src/ui/router.ts diff --git a/web/src/ui/status.ts b/src/ui/status.ts diff --git a/src/ui/tabs.ts b/src/ui/tabs.ts @@ -0,0 +1,177 @@ +import { $$ } from "./dom"; +import { clearAllHandlers, onKey } from "./keyboard"; +import { setRoute } from "./router"; +import { setupHelpKeybinds } from "./help"; + +const order: string[] = []; +let currentIndex = 0; + +const tabs: Map<string, TabConfig> = new Map(); + +export type TabConfig = { + init?: () => void; + + activate?: () => void; + deactivate?: () => void; + + getRouteId?: () => string | undefined; + setRouteId?: (id?: string) => void; +}; + +let unsubscribe: (() => void) | undefined; + +export function initTabs(): void { + for (const config of tabs.values()) { + config.init?.(); + } + setupTabClicks(); +} + +export function registerTab(name: string, config: TabConfig): void { + if (tabs.has(name)) throw new Error(`Tab "${name}" already registered`); + + order.push(name); + tabs.set(name, config); +} + +export function routeHandler(route: { tab: string; id?: string }): void { + const config = tabs.get(route.tab); + if (!config) throw new Error(`Tab "${route.tab}" not found`); + + const index = findIndexByTabName(route.tab); + + deactivate(); + activate(index); + updateDom(); + + config.setRouteId?.(route.id); + + setRoute(route.tab, config.getRouteId?.()); +} + +export function getAllTabs(): string[] { + return order; +} + +export function getCurrentTab(): string { + const tabName = order[currentIndex]; + if (!tabName) throw new Error(`Tab index ${currentIndex} not found`); + return tabName; +} + +export function cycleTab(direction: "prev" | "next"): void { + const index = + (currentIndex + (direction === "prev" ? -1 : 1) + order.length) % + order.length; + + const tabName = order[index]; + if (!tabName) throw new Error(`Tab index ${index} not found`); + + switchTab(tabName); +} + +export function switchTab(tabName: string): void { + const index = findIndexByTabName(tabName); + + deactivate(); + activate(index); + updateDom(); + updateRoute(); +} + +function findIndexByTabName(tabName: string): number { + for (const [i, n] of order.entries()) { + if (n === tabName) return i; + } + throw new Error(`Tab "${tabName}" not found`); +} + +function activate(index: number): void { + const name = order[index]; + if (!name) throw new Error(`Tab index ${index} not found`); + + const config = tabs.get(name); + if (!config) throw new Error(`Tab "${name}" not found`); + + currentIndex = index; + + setupDefaultKeybinds(); + + config.activate?.(); + + unsubscribe = config.deactivate; +} + +function deactivate(): void { + if (unsubscribe) { + unsubscribe(); + unsubscribe = undefined; + } + clearAllHandlers(); +} + +function updateDom(): void { + const name = order[currentIndex]; + if (!name) throw new Error(`Tab index ${currentIndex} not found`); + + $$(".tab").forEach((tab) => { + const isActive = tab.getAttribute("data-tab") === name; + tab.classList.toggle("active", isActive); + if (isActive) { + tab.setAttribute("aria-current", "page"); + } else { + tab.removeAttribute("aria-current"); + } + }); + + $$(".page").forEach((page) => { + page.classList.toggle("active", page.id === name); + }); +} + +function updateRoute(): void { + const name = order[currentIndex]; + if (!name) throw new Error(`Tab index ${currentIndex} not found`); + + const config = tabs.get(name); + if (!config) throw new Error(`Tab "${name}" not found`); + + setRoute(name, config.getRouteId?.()); +} + +function setupDefaultKeybinds(): void { + setupHelpKeybinds(); + + onKey("Tab", (e) => { + e.preventDefault(); + cycleTab(e.shiftKey ? "prev" : "next"); + }); + + for (const [i, name] of order.entries()) { + onKey(String(i + 1), () => switchTab(name)); + } + + onKey("ArrowLeft", (e) => { + if (currentIndex === 0) return; + e.preventDefault(); + cycleTab("prev"); + }); + onKey("ArrowRight", (e) => { + if (currentIndex === order.length - 1) return; + e.preventDefault(); + cycleTab("next"); + }); +} + +function setupTabClicks(): void { + $$(".tab").forEach((tab) => { + tab.addEventListener("click", () => { + const name = tab.getAttribute("data-tab"); + if (!name) { + console.warn("Tab clicked without name"); + return; + } + switchTab(name); + }); + }); +} diff --git a/web/src/utils/duration.ts b/src/utils/duration.ts diff --git a/web/src/utils/markdown.ts b/src/utils/markdown.ts diff --git a/web/src/utils/period.ts b/src/utils/period.ts diff --git a/web/src/utils/sleep.ts b/src/utils/sleep.ts diff --git a/web/src/utils/tags.ts b/src/utils/tags.ts diff --git a/web/tsconfig.json b/tsconfig.json diff --git a/web/README.md b/web/README.md @@ -1,36 +0,0 @@ -# web - -Astro web application for [prime8.dev](https://prime8.dev). - -## Stack - -- **Astro** - Static site generator with SSR -- **TypeScript** - Type-safe code -- **Bun** - Package manager and runtime -- **Docker** - Containerization - -## Development - -Install dependencies: - -```sh -bun install -``` - -Run development server: - -```sh -bun run dev -``` - -Build for production: - -```sh -bun run build -``` - -Build the Docker image: - -```sh -docker build -t prime8dev . -``` diff --git a/web/src/blogs/building-my-portfolio.md b/web/src/blogs/building-my-portfolio.md @@ -1,46 +0,0 @@ ---- -id: building-my-portfolio -title: Building a TUI-Inspired Portfolio -description: How I built this terminal-style portfolio with Astro -date: 2026-01-16 -tags: - - Astro - - TypeScript - - TUI - - Web Development - - Portfolio ---- - -I've always been drawn to terminal interfaces. There's something satisfying about the simplicity and efficiency of a well-designed TUI. So when it came time to build my portfolio, I knew I wanted to capture that aesthetic. Keyboard navigation and dark color scheme that feels like home. - -## The Stack - -This site is built with **Astro** for server-side rendering. At build/request time, READMEs are fetched directly from my GitLab and GitHub repositories: no stale content. **Wakatime integration** pulls my coding stats in real-time, showing weekly activity breakdowns and total hours coded. It's a nice way to show I'm actually writing code, not just talking about it. - -## Design Decisions - -A few key choices drove the design: - -- **Keyboard-first navigation** – Number keys (`1-4`) switch tabs instantly. Arrow keys navigate lists and timelines. `Tab` cycles through sections. `Enter` opens blog posts, `Escape` closes them. It feels like using a real terminal application. -- **List-detail pattern** – Projects and experience use a sidebar list with detail views, similar to email clients or file managers. The experience page features a timeline visualization that's navigable with arrow keys. -- **Live data everywhere** – WakaTime stats update on every page load. Project READMEs are fetched fresh from git repositories. No static snapshots that go stale. - -## The Color Palette - -The palette is inspired by classic terminal emulators: - -- Background: `#0d0d0d` (near-black) -- Foreground: `#c0c0c0` (soft gray) -- Accent: `#d7af5f` (warm gold) -- Links: `#5fafff` (light blue) -- Cyan, magenta, red for semantic colors - -JetBrains Mono is the font throughout. It's readable and has great character distinction. - -## Conclusion - -Building this portfolio was a chance to combine two things I love: terminal aesthetics and modern web development. The result is something that feels personal: a site that reflects how I actually work, not just what I've worked on. - -If you're thinking about building your own portfolio, my advice is simple: make it yours. Pick a style that resonates with you, add the features you'd actually want to use, and don't be afraid to over-engineer the parts you find interesting. That's where the best learning happens. - -The source code is available at [git.prime8.dev](https://git.prime8.dev/moncheeta/prime8dev). diff --git a/web/src/blogs/index.ts b/web/src/blogs/index.ts @@ -1,62 +0,0 @@ -import matter from "gray-matter"; -import { parseMarkdown, removeFirstH1 } from "../utils/markdown"; - -export interface Blog { - id: string; - title: string; - description: string; - date: string; - modifiedDate?: string; - content?: string; - url?: string; - image?: string; - tags?: string[]; -} - -const blogModules = import.meta.glob<string>("./*.md", { - eager: true, - query: "?raw", - import: "default", -}); - -export const blogs = Object.values(blogModules) - .map(parseBlog) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - -function parseBlog(markdown: string): Blog { - const { data, content } = matter(markdown); - - const blog: Partial<Blog> = { - id: data.id as string | undefined, - title: data.title as string | undefined, - description: data.description as string | undefined, - date: - data.date instanceof Date - ? data.date.toISOString().split("T")[0] - : (data.date as string | undefined), - modifiedDate: data.modifiedDate - ? data.modifiedDate instanceof Date - ? data.modifiedDate.toISOString().split("T")[0] - : (data.modifiedDate as string) - : undefined, - url: data.url as string | undefined, - content: parseMarkdown(removeFirstH1(content)), - image: data.image as string | undefined, - tags: data.tags as string[] | undefined, - }; - - const missingKeys = ["id", "title", "description", "date"].filter( - (key) => !blog[key as keyof typeof blog], - ); - if (missingKeys.length > 0) { - throw new Error(`Missing keys: ${missingKeys.join(", ")}`); - } - - if (blog.url && blog.content) { - throw new Error("Blogs cannot have both a URL and content"); - } else if (!blog.url && !blog.content) { - throw new Error("Blogs must have either a URL or content"); - } - - return blog as Blog; -} diff --git a/web/src/layouts/Layout.astro b/web/src/layouts/Layout.astro @@ -1,438 +0,0 @@ ---- -import Lightbox from "./components/Lightbox.astro"; - -import { SITE_URL } from "../constants/website"; -import { about } from "../config"; - -interface Props { - title: string; - description: string; - type: "website" | "article"; - url: string; - image?: string; - keywords?: string; - publishedTime?: string; - modifiedTime?: string; -} - -const { - title, - description, - type, - url, - image, - keywords, - publishedTime, - modifiedTime, -} = Astro.props; - -const ogImage = image - ? image.startsWith("http") - ? image - : `${SITE_URL}${image}` - : `${SITE_URL}/me.jpg`; ---- - -<!doctype html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>{title}</title> - <meta name="description" content={description} /> - {keywords && <meta name="keywords" content={keywords} />} - - <link rel="canonical" href={url} /> - <link rel="alternate" hreflang="en" href={url} /> - <link rel="alternate" hreflang="x-default" href={url} /> - - <!-- Preconnect for external resources --> - <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin /> - <link rel="dns-prefetch" href="https://cdn.jsdelivr.net" /> - - <!-- Preload font for performance --> - <link - rel="preload" - href="https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-400-normal.woff2" - as="font" - type="font/woff2" - crossorigin - /> - - <!-- Open Graph --> - <meta property="og:type" content={type} /> - <meta property="og:url" content={url} /> - <meta property="og:title" content={title} /> - <meta property="og:description" content={description} /> - <meta property="og:image" content={ogImage} /> - <meta property="og:image:width" content="1200" /> - <meta property="og:image:height" content="630" /> - <meta property="og:site_name" content={about.name} /> - <meta property="og:locale" content="en_US" /> - { - type === "article" && publishedTime && ( - <meta property="article:published_time" content={publishedTime} /> - ) - } - { - type === "article" && modifiedTime && ( - <meta property="article:modified_time" content={modifiedTime} /> - ) - } - { - type === "article" && ( - <meta property="article:author" content={about.name} /> - ) - } - - <!-- Twitter Card --> - <meta name="twitter:card" content="summary_large_image" /> - <meta name="twitter:url" content={url} /> - <meta name="twitter:title" content={title} /> - <meta name="twitter:description" content={description} /> - <meta name="twitter:image" content={ogImage} /> - - <!-- Additional Meta --> - <meta name="author" content={about.name} /> - <meta name="robots" content="index, follow" /> - <meta name="theme-color" content="#0d0d0d" /> - - <!-- Favicons --> - <link rel="icon" type="image/png" href="/favicon.png" /> - <link rel="apple-touch-icon" href="/favicon.png" /> - <link rel="manifest" href="/site.webmanifest" /> - </head> - <body> - <div id="app"> - <slot /> - </div> - - <Lightbox /> - </body> -</html> - -<style is:global> - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - :root { - --bg: #0d0d0d; - --bg-alt: #161616; - --fg: #c0c0c0; - --fg-dim: #606060; - --accent: #d7af5f; - --accent-dim: #a68a3d; - --cyan: #5fafaf; - --blue: #5f87d7; - --light-blue: #5fafff; - --yellow: #d7af5f; - --magenta: #af5faf; - --red: #d75f5f; - --border: #333; - --selection: #3d3520; - } - - @font-face { - font-family: "JetBrains Mono"; - src: url("https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-400-normal.woff2") - format("woff2"); - font-weight: 400; - } - - body { - background: var(--bg); - color: var(--fg); - font-family: "JetBrains Mono", "SF Mono", "Fira Code", monospace; - font-size: 14px; - line-height: 1.5; - min-height: 100vh; - overflow: hidden; - } - - ::-webkit-scrollbar { - width: 8px; - } - - ::-webkit-scrollbar-track { - background: var(--bg); - } - - ::-webkit-scrollbar-thumb { - background: var(--fg-dim); - border-radius: 4px; - } - - ::-webkit-scrollbar-thumb:hover { - background: var(--fg); - } - - .link { - color: var(--light-blue); - text-decoration: underline; - cursor: pointer; - } - - .link:hover { - color: var(--light-blue); - opacity: 0.8; - } - - a { - color: var(--light-blue); - text-decoration: none; - } - - a:hover { - opacity: 0.8; - } - - .color-green { - color: var(--accent); - } - .color-blue { - color: var(--blue); - } - .color-cyan { - color: var(--cyan); - } - .color-yellow { - color: var(--yellow); - } - .color-red { - color: var(--red); - } - .color-magenta { - color: var(--magenta); - } - .color-dim { - color: var(--fg-dim); - } - .bold { - font-weight: 700; - } - - #app { - display: flex; - flex-direction: column; - height: 100vh; - opacity: 0; - } - - #app.ready { - opacity: 1; - } - - #content { - flex: 1; - overflow: hidden; - } - - .page { - display: none; - height: 100%; - } - - .page.active { - display: flex; - } - - .tag { - border: none; - padding: 2px 8px; - font-size: 12px; - color: var(--bg); - background: var(--fg-dim); - } - - .tag.lang-zig { - background: #f7a41d; - } - .tag.lang-rust { - background: #dea584; - } - .tag.lang-c { - background: #a8b1c2; - } - .tag.lang-go { - background: #00add8; - } - .tag.lang-ts { - background: #3178c6; - color: #fff; - } - .tag.lang-python { - background: #3572a5; - color: #fff; - } - - .tag.tool-zed { - background: #084ccf; - color: #fff; - } - .tag.tool-neovim { - background: #57a143; - } - .tag.tool-opencode { - background: #af5faf; - color: #fff; - } - .tag.tool-linux { - background: #f7c94c; - } - .tag.tool-fish { - background: #d78700; - } - .tag.tool-ghostty { - background: #8b5cf6; - color: #fff; - } - - .tag.interest-lowlevel { - background: #d75f5f; - color: #fff; - } - .tag.interest-tui { - background: #5faf5f; - color: #0d0d0d; - } - .tag.interest-embedded { - background: #5fafaf; - color: #0d0d0d; - } - .tag.interest-unix { - background: #d7af5f; - color: #0d0d0d; - } - - .tag.tech-temporal { - background: #4c35c9; - color: #fff; - } - .tag.tech-postgres { - background: #336791; - color: #fff; - } - .tag.tech-pytorch { - background: #ee4c2c; - color: #fff; - } - .tag.tech-cuda { - background: #76b900; - } - .tag.tech-docker { - background: #2496ed; - color: #fff; - } - .tag.tech-kafka { - background: #4c35c9; - color: #fff; - } - .tag.tech-rn { - background: #61dafb; - } - .tag.tech-supabase { - background: #3ecf8e; - } - .tag.tech-rest { - background: #6b8e23; - } - - @media (max-width: 768px) { - body { - font-size: 13px; - overflow-y: auto; - } - - #app { - display: flex; - flex-direction: column; - min-height: 100vh; - height: auto; - } - - #content { - flex: 1; - overflow: visible; - } - - .page { - height: auto; - min-height: 100vh; - } - - .page.active { - display: block; - } - } - - /* Boot Animation */ - #boot-overlay { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: var(--bg); - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - } - - #boot-overlay.fade-out { - animation: bootFadeOut 0.3s ease-out forwards; - } - - .boot-terminal { - text-align: center; - } - - .boot-text { - display: flex; - flex-direction: column; - gap: 8px; - } - - .boot-title { - font-family: "JetBrains Mono", "SF Mono", monospace; - font-size: 32px; - font-weight: 600; - color: var(--accent); - letter-spacing: -0.5px; - } - - .boot-subtitle { - font-family: "JetBrains Mono", "SF Mono", monospace; - font-size: 14px; - color: var(--fg-dim); - letter-spacing: 0.5px; - } - - @keyframes bootFadeOut { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } - } - - #app.boot-reveal { - animation: revealContent 0.5s ease-out; - } - - @keyframes revealContent { - 0% { - opacity: 0; - transform: translateY(15px); - } - 100% { - opacity: 1; - transform: translateY(0); - } - } -</style> diff --git a/web/src/layouts/components/Header.astro b/web/src/layouts/components/Header.astro @@ -1,153 +0,0 @@ ---- -import { TABS } from "../../config/tabs"; ---- - -<header id="tabs"> - <nav class="tab-nav" aria-label="Main navigation"> - {TABS.map((tab, i) => ( - <div - class:list={["tab", { active: i === 0 }]} - data-tab={tab.id} - aria-current={i === 0 ? "page" : undefined} - > - <span class="tab-num">[{i + 1}] </span>{tab.label} - </div> - ))} - </nav> - <div class="tab-spacer"></div> - <div class="status" id="header-status"> - <span class="dpad"> - <span class="dpad-row"><span class="dpad-key">↑</span></span> - <span class="dpad-row" - ><span class="dpad-key">←</span><span class="dpad-key">↓</span><span - class="dpad-key">→</span - ></span - > - </span> - <span class="navigate"> navigate · Tab cycle</span> - </div> -</header> - -<style> - #tabs { - display: flex; - align-items: center; - gap: 4px; - background: var(--bg-alt); - border-bottom: 1px solid var(--border); - padding: 0 12px; - height: 36px; - position: sticky; - top: 0; - z-index: 100; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - } - - #tabs * { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - } - - .tab-nav { - display: flex; - align-items: center; - gap: 4px; - } - - .tab { - padding: 6px 12px; - color: var(--fg-dim); - cursor: pointer; - border-bottom: 2px solid transparent; - transition: - color 0.15s, - border-color 0.15s; - } - - .tab:hover { - color: var(--fg); - } - - .tab.active { - color: var(--accent); - border-bottom-color: var(--accent); - } - - .tab-spacer { - flex: 1; - } - - .status { - display: flex; - align-items: center; - gap: 8px; - color: var(--fg-dim); - font-size: 11px; - margin-right: 8px; - } - - .dpad { - display: inline-flex; - flex-direction: column; - align-items: center; - gap: 1px; - font-size: 8px; - line-height: 1; - } - - .dpad-row { - display: flex; - gap: 1px; - } - - .dpad-key { - width: 12px; - height: 10px; - display: flex; - align-items: center; - justify-content: center; - background: var(--border); - border-radius: 2px; - color: var(--fg); - } - - @media (max-width: 768px) { - #tabs { - flex-wrap: wrap; - height: auto; - min-height: 36px; - padding: 8px 12px; - gap: 8px; - } - - .tab-nav { - width: 100%; - justify-content: space-between; - gap: 0; - } - - .tab { - padding: 6px 8px; - font-size: 12px; - flex: 1; - text-align: center; - } - - .tab-spacer { - display: none; - } - - .status { - display: none; - } - - .tab-num { - display: none; - } - } -</style> diff --git a/web/src/layouts/tabs/About.astro b/web/src/layouts/tabs/About.astro @@ -1,428 +0,0 @@ ---- -import type { WakaTimeData } from "../../external/wakatime"; -import { about, contact } from "../../config"; -import { currentJob } from "../../config/experience"; -import { getTagClass } from "../../utils/tags"; - -interface Props { - wakaTimeData: WakaTimeData | null; -} - -const { wakaTimeData } = Astro.props; ---- - -<section id="about" class="page active"> - <div class="about-layout"> - <div class="about-left"> - <img - class="profile-image" - src="/me.jpg" - alt="Damian Myrda - Software Engineer and high school senior from Chicago" - loading="eager" - /> - <div class="contact-box"> - <div class="box-title">─ Contact ─</div> - <div class="contact-item"> - <span class="label">Email</span> - <a href={`mailto:${contact.email}`}>{contact.email}</a> - </div> - <div class="contact-item"> - <span class="label">GitLab</span> - <a href={contact.gitlab} target="_blank" rel="noopener noreferrer me" - >git.prime8.dev/moncheeta</a - > - </div> - <div class="contact-item"> - <span class="label">LinkedIn</span> - <a - href={contact.linkedin} - target="_blank" - rel="noopener noreferrer me">linkedin.com/in/damian-myrda</a - > - </div> - </div> - </div> - <div class="about-right"> - <div class="section"> - <h1> - {about.name} - <span class="dim">@{about.handle}</span> - </h1> - <p class="bio">{about.bio}</p> - </div> - <div class="section"> - <div class="box-title">─ Info ─</div> - <div class="info-columns"> - <div class="info-column"> - <div class="info-row"> - <span class="label">Location</span> - {about.location} - </div> - <div class="info-row"> - <span class="label">School</span> - {about.school} - </div> - <div class="info-row"> - <span class="label">Graduating</span> - {about.graduating} - </div> - </div> - {currentJob && ( - <div class="info-column"> - <div class="info-row"> - <span class="label">Company</span> - <a href={`#experience/${currentJob.id}`}> - {currentJob.company} - </a> - </div> - <div class="info-row"> - <span class="label">Position</span> - {currentJob.role} - </div> - </div> - )} - </div> - </div> - <div class="section"> - <div class="box-title">─ Interests ─</div> - <div class="tags"> - { - about.interests.map((interest) => ( - <span class={`tag ${getTagClass(interest)}`}>{interest}</span> - )) - } - </div> - </div> - <div class="section"> - <div class="box-title">─ Tools ─</div> - <div class="tags"> - { - about.tools.map((tool) => ( - <span class={`tag ${getTagClass(tool)}`}>{tool}</span> - )) - } - </div> - </div> - <div class="section"> - <div class="box-title">─ Languages ─</div> - <div class="tags"> - { - about.languages.map((lang) => ( - <span class={`tag ${getTagClass(lang)}`}>{lang}</span> - )) - } - </div> - </div> - { - wakaTimeData?.weeklyStats && ( - <div class="section"> - <div class="box-title">─ Activity ─</div> - <div class="weekly-stats"> - <div class="weekly-total"> - {wakaTimeData.weeklyStats.totalText} - </div> - <div class="weekly-days"> - {wakaTimeData.weeklyStats.days.map((day) => ( - <div class="weekly-day-row"> - <span class="weekly-day-name">{day.dayText}</span> - <div class="weekly-day-bar-container"> - <div - class="weekly-day-bar" - style={`width: ${day.barWidthPercent}`} - /> - </div> - <span class="weekly-day-time">{day.timeText}</span> - </div> - ))} - </div> - </div> - </div> - ) - } - </div> - </div> -</section> - -<style> - .about-layout { - display: flex; - width: 100%; - height: 100%; - } - - .about-left { - width: 320px; - flex-shrink: 0; - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - padding: 16px; - gap: 16px; - overflow-y: auto; - } - - .about-right { - flex: 1; - padding: 24px 32px; - overflow-y: auto; - display: flex; - flex-direction: column; - justify-content: space-evenly; - } - - .profile-image { - width: 100%; - max-width: 280px; - height: auto; - border-radius: 4px; - border: 1px solid var(--border); - } - - .contact-box { - border: 1px solid var(--border); - padding: 12px; - max-width: 280px; - } - - .box-title { - color: var(--accent); - font-size: 12px; - margin-bottom: 8px; - text-align: center; - } - - .contact-item { - display: flex; - margin-bottom: 4px; - font-size: 12px; - min-width: 0; - } - - .contact-item .label { - color: var(--fg-dim); - width: 70px; - flex-shrink: 0; - } - - .contact-item a { - color: var(--light-blue); - text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - direction: rtl; - text-align: left; - } - - .contact-item a:hover { - opacity: 0.8; - } - - .section { - flex-shrink: 0; - } - - .section h1 { - font-size: 18px; - font-weight: 400; - margin-bottom: 4px; - } - - .section h1 .dim { - color: var(--fg-dim); - font-size: 14px; - } - - .section .bio { - color: var(--fg-dim); - font-style: italic; - } - - .info-columns { - display: flex; - gap: 48px; - } - - .info-column { - flex: 1; - } - - .info-row { - display: flex; - font-size: 13px; - margin-bottom: 4px; - } - - .info-row .label { - color: var(--fg-dim); - width: 100px; - flex-shrink: 0; - } - - .info-row a { - color: var(--light-blue); - text-decoration: none; - } - - .info-row a:hover { - opacity: 0.8; - } - - .tags { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - - .weekly-stats { - background: var(--bg-alt); - border: 1px solid var(--border); - padding: 12px; - } - - .weekly-total { - font-size: 16px; - font-weight: 500; - color: var(--accent); - margin-bottom: 12px; - } - - .weekly-days { - display: flex; - flex-direction: column; - gap: 6px; - } - - .weekly-day-row { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - } - - .weekly-day-name { - width: 32px; - flex-shrink: 0; - color: var(--fg-dim); - } - - .weekly-day-bar-container { - flex: 1; - height: 8px; - background: var(--bg); - border-radius: 2px; - overflow: hidden; - } - - .weekly-day-bar { - height: 100%; - background: var(--accent); - border-radius: 2px; - } - - .weekly-day-time { - min-width: 50px; - flex-shrink: 0; - text-align: right; - color: var(--fg-dim); - white-space: nowrap; - } - - @media (max-width: 768px) { - .about-layout { - flex-direction: column; - } - - .about-left { - display: flex; - flex-direction: row; - width: 100%; - border-right: none; - border-bottom: 1px solid var(--border); - padding: 16px; - gap: 16px; - } - - .profile-image { - width: 120px; - min-width: 60px; - flex-shrink: 1; - height: 120px; - align-self: flex-start; - object-fit: cover; - object-position: center; - } - - .contact-box { - flex: 1; - max-width: none; - min-width: 0; - } - - .contact-item .label { - width: 70px; - } - - .contact-item a { - overflow: hidden; - text-overflow: ellipsis; - } - - .about-right { - padding: 16px; - display: block; - } - - .section { - margin-bottom: 16px; - } - - .section h1 { - font-size: 16px; - } - - .section h1 .dim { - font-size: 12px; - } - - .info-columns { - flex-direction: column; - gap: 12px; - } - - .tags { - gap: 4px; - } - - .tag { - font-size: 11px; - padding: 2px 6px; - } - - .weekly-stats { - padding: 10px; - } - - .weekly-total { - font-size: 14px; - } - - .weekly-day-row { - font-size: 11px; - } - - .weekly-day-name { - width: 28px; - } - - .weekly-day-time { - min-width: 45px; - } - } - - @media (max-width: 480px) { - .contact-item .label { - display: none; - } - } -</style> diff --git a/web/src/layouts/tabs/Blog.astro b/web/src/layouts/tabs/Blog.astro @@ -1,232 +0,0 @@ ---- -import BlogArticle from "../components/Article.astro"; - -import { blogs } from "../../blogs"; ---- - -<section id="blog" class="page"> - { - blogs.length === 0 ? ( - <div class="blog-empty"> - <p class="color-dim">No blog posts yet.</p> - <p class="color-dim">Check back soon!</p> - </div> - ) : ( - <> - <div id="blog-list-view" class="blog-list-view"> - <div class="blog-list-container"> - {blogs.map((blog, i) => ( - <div class="blog-list-item" data-index={i} data-id={blog.id}> - <span class="blog-list-date">{blog.date}</span> - <span class="blog-list-title">{blog.title}</span> - <span class="blog-list-arrow">→</span> - </div> - ))} - </div> - </div> - - <div id="blog-post-view" class="blog-post-view hidden"> - <div class="blog-post-container"> - <button class="blog-back-btn">← Back to posts</button> - {blogs.map((blog, i) => ( - <div - class="blog-post hidden" - data-index={i} - data-id={blog.id} - data-blog-id={blog.id} - > - <BlogArticle blog={blog} /> - </div> - ))} - </div> - </div> - </> - ) - } -</section> - -<style> - .blog-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - width: 100%; - gap: 8px; - } - - .blog-list-view { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - width: 100%; - } - - .blog-list-view.hidden { - display: none; - } - - .blog-list-container { - max-width: 600px; - width: 100%; - padding: 32px; - } - - .blog-list-item { - display: flex; - align-items: center; - gap: 16px; - padding: 16px 20px; - border: 1px solid var(--border); - margin-bottom: 12px; - cursor: pointer; - transition: - background 0.15s ease, - border-color 0.15s ease; - text-decoration: none; - color: inherit; - } - - .blog-list-item:hover { - background: var(--selection); - border-color: var(--accent); - } - - .blog-list-item.selected { - background: var(--bg-alt); - border-color: var(--accent); - } - - .blog-list-item.selected:hover { - background: var(--selection); - } - - .blog-list-item:last-child { - margin-bottom: 0; - } - - .blog-list-date { - color: var(--fg-dim); - font-size: 12px; - flex-shrink: 0; - width: 90px; - } - - .blog-list-title { - flex: 1; - color: var(--fg); - font-weight: 500; - } - - .blog-list-arrow { - color: var(--fg-dim); - font-size: 14px; - transition: - transform 0.15s, - color 0.15s; - } - - .blog-list-item:hover .blog-list-arrow { - color: var(--accent); - transform: translateX(4px); - } - - .blog-post-view { - display: flex; - justify-content: center; - height: 100%; - width: 100%; - overflow-y: auto; - padding: 32px; - } - - .blog-post-view.hidden { - display: none; - } - - .blog-post-container { - max-width: 700px; - width: 100%; - } - - .blog-post.hidden { - display: none; - } - - .blog-back-btn { - display: inline-block; - background: transparent; - border: 1px solid var(--border); - color: var(--fg-dim); - padding: 8px 16px; - cursor: pointer; - font-family: inherit; - font-size: 12px; - margin-bottom: 24px; - transition: all 0.15s; - text-decoration: none; - } - - .blog-back-btn:hover { - background: var(--bg-alt); - color: var(--accent); - border-color: var(--accent); - } - - @media (max-width: 768px) { - .blog-list-container { - padding: 16px; - } - - .blog-list-item { - gap: 12px; - padding: 12px 16px; - margin-bottom: 10px; - } - - .blog-list-date { - font-size: 11px; - width: 80px; - } - - .blog-list-title { - font-size: 14px; - } - - .blog-post-view { - padding: 16px; - } - - .blog-back-btn { - padding: 6px 12px; - font-size: 11px; - margin-bottom: 16px; - } - } - - @media (max-width: 480px) { - .blog-list-item { - flex-wrap: wrap; - gap: 4px; - } - - .blog-list-date { - width: auto; - order: 2; - } - - .blog-list-title { - width: 100%; - order: 1; - } - - .blog-list-arrow { - order: 3; - margin-left: auto; - } - } -</style> - - diff --git a/web/src/layouts/tabs/Experience.astro b/web/src/layouts/tabs/Experience.astro @@ -1,484 +0,0 @@ ---- -import { experience } from "../../config"; -import { calculateMonths } from "../../utils/period"; -import { MONTH_NAMES } from "../../constants/calendar"; -import { getTagClass } from "../../utils/tags"; - -const dates = experience.map((exp) => { - const [year, month] = exp.startDate.split("-").map(Number); - return new Date(year, month - 1, 1); -}); -const endDates = experience.map((exp) => - exp.endDate - ? new Date( - parseInt(exp.endDate.split("-")[0]), - parseInt(exp.endDate.split("-")[1]) - 1, - 28, - ) - : new Date(), -); -const earliestDate = new Date(Math.min(...dates.map((d) => d.getTime()))); -const latestDate = new Date(Math.max(...endDates.map((d) => d.getTime()))); - -const timelineStart = new Date( - earliestDate.getFullYear(), - earliestDate.getMonth(), - 1, -); -const timelineEnd = latestDate; - -const monthTicks: string[] = []; -let current = new Date(timelineStart); -while (current <= timelineEnd) { - monthTicks.push(MONTH_NAMES[current.getMonth()][0]); - current.setMonth(current.getMonth() + 1); -} ---- - -<section id="experience" class="page"> - <article class="experience-layout"> - <div class="experience-detail"> - { - experience.map((exp, index) => { - const months = calculateMonths(exp.startDate, exp.endDate); - const [startYear, startMonth] = exp.startDate.split("-").map(Number); - const startStr = `${MONTH_NAMES[startMonth - 1]} ${startYear}`; - const endStr = exp.endDate - ? `${MONTH_NAMES[parseInt(exp.endDate.split("-")[1]) - 1]} ${exp.endDate.split("-")[0]}` - : "Present"; - - const isLast = index === experience.length - 1; - - return ( - <div class="exp-row" data-index={index} data-id={exp.id}> - <div class="exp-card" data-index={index} data-id={exp.id}> - <div class="exp-card-header"> - <div class="exp-card-title"> - {exp.logo ? ( - <div class="exp-title-with-logo"> - <img - src={exp.logo} - alt={`${exp.company} logo`} - class={`exp-logo ${exp.logoFilter || ""}`} - /> - <span class="exp-role">{exp.role}</span> - </div> - ) : ( - <div class="exp-title-text"> - <h2>{exp.company}</h2> - <span class="exp-role">{exp.role}</span> - </div> - )} - </div> - <div class="exp-card-meta"> - <span class="exp-dates"> - {startStr} → {endStr} - </span> - <span class="exp-duration">{months} months</span> - {exp.url && ( - <a - class="external-link" - href={exp.url} - target="_blank" - rel="noopener noreferrer" - > - {new URL(exp.url).hostname} → - </a> - )} - </div> - </div> - <div class="exp-card-tech"> - {exp.tech.map((tech) => ( - <span class={`tag ${getTagClass(tech)}`}>{tech}</span> - ))} - </div> - <div class="exp-card-body"> - {exp.description.map((description) => ( - <p>{description}</p> - ))} - </div> - {exp.images && exp.images.length > 0 && ( - <div class="exp-card-images"> - <img - src={exp.images[0]} - alt={`${exp.company} workplace, ${exp.role} experience at ${exp.company}`} - class="exp-image-hero" - loading="lazy" - data-idx="0" - /> - {exp.images.length > 1 && ( - <div class="exp-image-grid"> - {exp.images.slice(1).map((image, index) => ( - <img - src={image} - alt={`${exp.company} team and office environment, image ${index + 2} of ${exp.images!.length}`} - class="exp-image-thumb" - loading="lazy" - data-idx={index + 1} - /> - ))} - </div> - )} - </div> - )} - </div> - <div - class={`exp-timeline ${index === 0 ? "active" : ""}`} - data-index={index} - > - <span class="timeline-dot" /> - <span class="timeline-year"> - [{exp.endDate ? exp.endDate.split("-")[0] : "Now"}] - </span> - {!isLast && <div class="timeline-line" />} - </div> - </div> - ); - }) - } - </div> - </article> -</section> - -<style> - .experience-layout { - display: flex; - flex-direction: row; - height: 100%; - width: 100%; - padding: 24px 32px; - gap: 24px; - } - - .experience-detail { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 16px; - padding: 4px; - } - - .exp-card { - background: var(--bg-alt); - border: 2px solid var(--border); - border-radius: 4px; - padding: 24px; - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0; - scroll-snap-align: start; - transition: border-color 0.2s ease; - } - - .exp-card.selected { - border-color: var(--accent); - } - - .exp-row { - display: flex; - } - - .exp-timeline { - width: 64px; - flex-shrink: 0; - display: flex; - flex-direction: column; - align-items: center; - padding-top: 24px; - position: relative; - } - - .timeline-dot { - width: 12px; - height: 12px; - border: 2px solid var(--fg-dim); - border-radius: 50%; - background: transparent; - transition: all 0.2s ease; - font-size: 0; - } - - .exp-timeline.active .timeline-dot { - border-color: var(--accent); - background: var(--accent); - transform: scale(1.2); - } - - .timeline-year { - font-size: 10px; - color: var(--fg-dim); - font-family: monospace; - margin-top: 4px; - transition: color 0.2s ease; - } - - .exp-timeline.active .timeline-year { - color: var(--accent); - } - - .timeline-line { - position: absolute; - top: 56px; - bottom: -16px; - width: 2px; - left: 50%; - transform: translateX(-50%); - background: repeating-linear-gradient( - to bottom, - var(--border) 0px, - var(--border) 4px, - transparent 4px, - transparent 8px - ); - } - - .exp-card-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 16px; - gap: 24px; - } - - .exp-card-title { - display: flex; - align-items: center; - gap: 16px; - } - - .exp-logo { - height: 32px; - width: auto; - flex-shrink: 0; - } - - .exp-logo.logo-invert { - filter: brightness(0) invert(1); - } - - .exp-title-with-logo { - display: flex; - flex-direction: column; - gap: 4px; - } - - .exp-title-with-logo .exp-role { - color: #999; - font-size: 14px; - } - - .exp-title-with-logo .exp-duration { - color: #aaa; - font-size: 12px; - } - - .exp-title-text h2 { - font-size: 22px; - font-weight: 500; - color: var(--accent); - margin: 0 0 4px 0; - } - - .exp-title-text .exp-role { - font-size: 14px; - color: #999; - } - - .exp-card-meta { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; - font-size: 13px; - } - - .exp-dates { - color: var(--fg); - } - - .exp-duration { - color: #aaa; - font-size: 12px; - } - - .exp-card-tech { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 20px; - } - - .exp-card-body { - color: var(--fg); - line-height: 1.7; - padding-bottom: 0; - margin-bottom: 0; - } - - .exp-card-body p { - margin-bottom: 12px; - } - - .exp-card-body p:last-child { - margin-bottom: 0; - } - - .exp-card-images { - display: flex; - gap: 12px; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border); - max-height: 180px; - overflow: hidden; - } - - .exp-image-hero { - max-width: 280px; - height: 100%; - border-radius: 4px; - border: 1px solid var(--border); - object-fit: cover; - cursor: pointer; - transition: - transform 0.15s, - box-shadow 0.15s; - } - - .exp-image-hero:hover { - opacity: 0.8; - border-color: var(--accent); - } - - .exp-image-grid { - display: flex; - flex-wrap: wrap; - gap: 8px; - max-width: 400px; - } - - .exp-image-thumb { - width: calc(50% - 4px); - height: 86px; - border-radius: 4px; - border: 1px solid var(--border); - object-fit: cover; - cursor: pointer; - transition: - opacity 0.15s, - border-color 0.15s; - } - - .exp-image-thumb:hover { - opacity: 0.8; - border-color: var(--accent); - } - - .external-link { - color: var(--light-blue); - text-decoration: none; - font-size: 12px; - } - - .external-link:hover { - opacity: 0.8; - } - - @media (max-width: 768px) { - .experience-layout { - padding: 16px; - gap: 12px; - } - - .exp-timeline { - display: none; - } - - .exp-card { - padding: 16px; - } - - .exp-card.selected { - border-color: var(--border); - } - - .exp-card-header { - flex-direction: column; - gap: 12px; - } - - .exp-card-meta { - align-items: flex-start; - } - - .exp-logo { - height: 28px; - } - - .exp-title-text h2 { - font-size: 18px; - } - - .exp-card-tech { - gap: 4px; - } - - .exp-card-body { - font-size: 13px; - } - - .exp-card-images { - flex-direction: column; - gap: 12px; - max-height: none; - overflow: visible; - } - - .exp-image-hero { - max-width: 100%; - height: auto; - max-height: 200px; - } - - .exp-image-grid { - width: 100%; - max-width: none; - flex-direction: row; - flex-wrap: wrap; - gap: 8px; - } - - .exp-image-thumb { - width: calc(33.33% - 6px); - height: 80px; - } - } - - @media (max-width: 480px) { - .exp-image-thumb { - width: calc(50% - 4px); - } - } -</style> - -<script> - document.querySelectorAll(".exp-image-hero, .exp-image-thumb").forEach((img) => { - img.addEventListener("click", () => { - const container = (img as HTMLElement).closest(".exp-card-images"); - const allImages = container - ? Array.from(container.querySelectorAll("img")).map( - (i) => (i as HTMLImageElement).src, - ) - : []; - const startIndex = allImages.indexOf((img as HTMLImageElement).src); - ( - window as Window & { - openLightbox?: (images: string[], startIndex?: number) => void; - } - ).openLightbox?.(allImages, startIndex); - }); - }); -</script> diff --git a/web/src/layouts/tabs/Projects.astro b/web/src/layouts/tabs/Projects.astro @@ -1,413 +0,0 @@ ---- -import { projects } from "../../config/projects"; -import { fetchGitMetadataForProject } from "../../external/git"; -import { getTechMeta, getTagClass } from "../../utils/tags"; -import { parseMarkdown, removeFirstH1 } from "../../utils/markdown"; - -const metadata = Object.fromEntries( - ( - await Promise.all( - projects.map(async (project) => [ - project.id, - await fetchGitMetadataForProject(project), - ]), - ) - ).filter(([, meta]) => meta !== null), -); ---- - -<section id="projects" class="page"> - <aside class="list-view" aria-label="Project list"> - { - projects.map((prg, i) => ( - <div - class={`list-item ${i === 0 ? "selected" : ""}`} - data-index={i} - data-id={prg.id} - > - <div class="list-header"> - <span class="list-title">{prg.name}</span> - <span class="list-meta">{getTechMeta(prg.tech)}</span> - </div> - <div class="list-subtitle">{prg.subtitle}</div> - </div> - )) - } - </aside> - <article class="detail-view"> - { - projects.map((prg, index) => ( - <div - class={`detail-content ${index !== 0 ? "hidden" : ""}`} - data-for={index} - data-id={prg.id} - > - <div class="detail-header"> - <h2>{prg.name}</h2> - <a - class="external-link" - href={prg.url} - target="_blank" - rel="noopener noreferrer" - > - {prg.url.includes("github.com") ? "GitHub" : "GitLab"} → - </a> - </div> - <div class="tech-stack"> - {prg.tech.map((tech) => ( - <span class={`tag ${getTagClass(tech)}`}>{tech}</span> - ))} - </div> - <div class="readme-content" data-readme={prg.id}> - {metadata?.[prg.id]?.readme ? ( - <Fragment - set:html={parseMarkdown(removeFirstH1(metadata[prg.id].readme))} - /> - ) : ( - <p class="color-dim">README not available</p> - )} - </div> - </div> - )) - } - </article> -</section> - -<style> - aside.list-view { - width: 300px; - flex-shrink: 0; - border-right: 1px solid var(--border); - overflow-y: auto; - } - - article.detail-view { - flex: 1; - padding: 24px 32px; - overflow: hidden; - display: flex; - flex-direction: column; - } - - .list-item { - padding: 12px 16px; - border-bottom: 1px solid var(--border); - cursor: pointer; - transition: background 0.1s; - } - - .list-item:hover { - background: var(--bg-alt); - } - - .list-item.selected { - background: var(--selection); - border-left: 3px solid var(--accent); - } - - .list-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2px; - } - - .list-title { - font-weight: 600; - color: var(--fg); - } - - .list-meta { - font-size: 11px; - color: var(--fg-dim); - } - - .list-subtitle { - font-size: 12px; - color: var(--fg-dim); - } - - .detail-content { - display: flex; - flex-direction: column; - height: 100%; - } - - .detail-content.hidden { - display: none; - } - - .detail-content h2 { - font-size: 20px; - font-weight: 400; - color: var(--accent); - margin-bottom: 4px; - } - - .detail-content .tech-stack { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 16px; - } - - .detail-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; - } - - .detail-header h2 { - margin-bottom: 0; - } - - .external-link { - color: var(--light-blue); - text-decoration: none; - font-size: 12px; - } - - .external-link:hover { - opacity: 0.8; - } - - .readme-content { - flex: 1; - min-height: 0; - overflow-y: auto; - font-size: 13px; - line-height: 1.6; - color: var(--fg); - margin-top: 16px; - padding-top: 16px; - padding-right: 12px; - border-top: 1px solid var(--border); - } - - .readme-content :global(p) { - margin-bottom: 12px; - } - - .readme-content :global(p:last-child) { - margin-bottom: 0; - } - - .readme-content :global(h2), - .readme-content :global(.readme-h1) { - font-size: 16px; - color: var(--accent); - margin: 20px 0 8px; - font-weight: 600; - } - - .readme-content :global(h2:first-child), - .readme-content :global(.readme-h1:first-child) { - margin-top: 0; - } - - .readme-content :global(h3), - .readme-content :global(.readme-h2) { - font-size: 14px; - color: var(--accent); - margin: 16px 0 8px; - font-weight: 500; - } - - .readme-content :global(h4), - .readme-content :global(.readme-h3) { - font-size: 13px; - color: var(--fg); - margin: 12px 0 6px; - font-weight: 500; - } - - .readme-content :global(h5), - .readme-content :global(h6) { - font-size: 12px; - color: var(--fg-dim); - margin: 10px 0 6px; - font-weight: 500; - } - - .readme-content :global(pre) { - display: block; - background: var(--bg-alt); - border: 1px solid var(--border); - padding: 12px; - margin: 12px 0; - overflow-x: auto; - font-size: 12px; - line-height: 1.4; - } - - .readme-content :global(pre code) { - background: none; - padding: 0; - font-size: inherit; - } - - .readme-content :global(code) { - background: var(--bg-alt); - padding: 2px 6px; - font-size: 12px; - border-radius: 2px; - } - - .readme-content :global(a) { - color: var(--light-blue); - text-decoration: none; - } - - .readme-content :global(a:hover) { - opacity: 0.8; - } - - .readme-content :global(ul), - .readme-content :global(ol) { - margin: 8px 0; - padding-left: 20px; - } - - .readme-content :global(li) { - margin-bottom: 4px; - } - - .readme-content :global(li > ul), - .readme-content :global(li > ol) { - margin: 4px 0; - } - - .readme-content :global(table) { - width: 100%; - border-collapse: collapse; - margin: 12px 0; - font-size: 12px; - } - - .readme-content :global(th), - .readme-content :global(td) { - border: 1px solid var(--border); - padding: 8px 12px; - text-align: left; - } - - .readme-content :global(th) { - background: var(--bg-alt); - color: var(--accent); - font-weight: 500; - } - - .readme-content :global(tr:nth-child(even)) { - background: rgba(255, 255, 255, 0.02); - } - - .readme-content :global(blockquote) { - border-left: 3px solid var(--accent); - margin: 12px 0; - padding: 8px 16px; - color: var(--fg-dim); - background: var(--bg-alt); - } - - .readme-content :global(blockquote p) { - margin-bottom: 0; - } - - .readme-content :global(hr) { - border: none; - border-top: 1px solid var(--border); - margin: 20px 0; - } - - .readme-content :global(img) { - max-width: 100%; - height: auto; - margin: 12px 0; - } - - .readme-content :global(strong) { - color: var(--fg); - font-weight: 600; - } - - .readme-content :global(em) { - font-style: italic; - color: var(--fg-dim); - } - - .readme-content :global(del) { - text-decoration: line-through; - color: var(--fg-dim); - } - - @media (max-width: 768px) { - #projects { - flex-direction: column; - } - - aside.list-view { - width: 100%; - border-right: none; - border-bottom: 1px solid var(--border); - max-height: 200px; - overflow-y: auto; - } - - .list-item { - padding: 10px 14px; - } - - .list-title { - font-size: 13px; - } - - .list-subtitle { - font-size: 11px; - } - - article.detail-view { - padding: 16px; - overflow-y: auto; - } - - .detail-content h2 { - font-size: 18px; - } - - .detail-header { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .readme-content { - font-size: 12px; - } - - .readme-content :global(pre) { - font-size: 11px; - padding: 10px; - } - - .readme-content :global(code) { - font-size: 11px; - } - } - - @media (max-width: 480px) { - aside.list-view { - max-height: 150px; - } - - .list-item { - padding: 8px 12px; - } - - .list-meta { - display: none; - } - } -</style> - - diff --git a/web/src/pages/experience/[id].astro b/web/src/pages/experience/[id].astro @@ -1,374 +0,0 @@ ---- -import Layout from "../../layouts/Layout.astro"; -import Lightbox from "../../layouts/components/Lightbox.astro"; - -import { SITE_URL } from "../../constants/website"; -import { experience as experiences } from "../../config"; -import { calculateMonths } from "../../utils/period"; -import { MONTH_NAMES } from "../../constants/calendar"; -import { getTagClass } from "../../utils/tags"; -import schemas from "../../schemas/index"; - -const exp = experiences.find((e) => e.id === Astro.params.id); -if (!exp) { - return new Response(null, { - status: 404, - statusText: "Not Found", - }); -} - -const months = calculateMonths(exp.startDate, exp.endDate); -const [startYear, startMonth] = exp.startDate.split("-").map(Number); -const startStr = `${MONTH_NAMES[startMonth - 1]} ${startYear}`; -const endStr = exp.endDate - ? `${MONTH_NAMES[parseInt(exp.endDate.split("-")[1]) - 1]} ${exp.endDate.split("-")[0]}` - : "Present"; - -const keywords = [...exp.tech, exp.company, exp.role].join(", "); -const description = `${exp.role} at ${exp.company}. ${exp.description[0]}`; - -const { getSchemaForExperience, getBreadcrumbSchema } = schemas.experience; ---- - -<Layout - title={`${exp.company} - ${exp.role} | Damian Myrda`} - description={description} - type="website" - url={`${SITE_URL}/experience/${exp.id}`} - keywords={keywords} - image={exp.logo} -> - <main id="content"> - <div class="experience-page"> - <a href="/#experience" class="back-btn">← See more experience</a> - - <article class="experience-article"> - <header class="article-header"> - <div class="header-top"> - <div class="title-section"> - <div class="exp-title-with-logo"> - <img - src={exp.logo} - alt={exp.company} - class={`exp-logo ${exp.logoFilter || ""}`} - /> - <span class="exp-role">{exp.role}</span> - </div> - </div> - <div class="exp-card-meta"> - <span class="exp-dates">{startStr} → {endStr}</span> - <span class="exp-duration">{months} months</span> - {exp.url && ( - <a - class="external-link" - href={exp.url} - target="_blank" - rel="noopener noreferrer" - > - {new URL(exp.url).hostname} → - </a> - )} - </div> - </div> - </header> - - <section class="tech-tags"> - { - exp.tech.map((tech) => ( - <span class={`tag ${getTagClass(tech)}`}>{tech}</span> - )) - } - </section> - - <section class="article-content"> - {exp.description.map((paragraph) => <p>{paragraph}</p>)} - </section> - - { - exp.images && exp.images.length > 0 && ( - <section class="images-section"> - <h2>Photos</h2> - <div class="image-grid"> - {exp.images.map((img, i) => ( - <img - src={img} - alt={`${exp.company} photo ${i + 1}`} - class="experience-image" - loading="lazy" - /> - ))} - </div> - </section> - ) - } - </article> - </div> - <script - type="application/ld+json" - set:html={JSON.stringify(getSchemaForExperience(exp))} - /> - <script - type="application/ld+json" - set:html={JSON.stringify(getBreadcrumbSchema(exp))} - /> - </main> - <Lightbox /> -</Layout> - -<script> - document.getElementById("app")?.classList.add("ready"); - - // Lightbox click handlers for images - document.querySelectorAll(".experience-image").forEach((img) => { - img.addEventListener("click", () => { - const container = (img as HTMLElement).closest(".image-grid"); - const allImages = container - ? Array.from(container.querySelectorAll("img")).map( - (i) => (i as HTMLImageElement).src, - ) - : []; - const startIndex = allImages.indexOf((img as HTMLImageElement).src); - ( - window as Window & { - openLightbox?: (images: string[], startIndex?: number) => void; - } - ).openLightbox?.(allImages, startIndex); - }); - }); -</script> - -<style> - :global(body) { - overflow: auto; - } - - :global(#app) { - height: auto; - min-height: 100vh; - } - - :global(#content) { - overflow: visible; - } - - .experience-page { - max-width: 720px; - margin: 0 auto; - padding: 40px 24px 40px; - min-height: 100vh; - } - - .back-btn { - display: inline-block; - background: transparent; - border: 1px solid var(--border); - color: var(--fg-dim); - padding: 8px 16px; - cursor: pointer; - font-family: inherit; - font-size: 12px; - margin-bottom: 24px; - transition: all 0.15s; - text-decoration: none; - } - - .back-btn:hover { - background: var(--bg-alt); - color: var(--accent); - border-color: var(--accent); - } - - .experience-article { - animation: fadeIn 0.2s ease; - } - - @keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - - .article-header { - margin-bottom: 24px; - padding-bottom: 16px; - border-bottom: 1px solid var(--border); - } - - .header-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; - } - - .title-section { - display: flex; - align-items: center; - gap: 16px; - } - - .exp-logo { - height: 32px; - width: auto; - flex-shrink: 0; - } - - .exp-logo.logo-invert { - filter: brightness(0) invert(1); - } - - .exp-title-with-logo { - display: flex; - flex-direction: column; - gap: 4px; - } - - .exp-title-with-logo .exp-role { - color: #999; - font-size: 14px; - } - - .exp-card-meta { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; - font-size: 13px; - } - - .exp-dates { - color: var(--fg); - } - - .exp-duration { - color: #aaa; - font-size: 12px; - } - - .external-link { - color: var(--light-blue); - text-decoration: none; - font-size: 12px; - } - - .external-link:hover { - opacity: 0.8; - } - - .tech-tags { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 24px; - } - - .article-content { - font-size: 14px; - line-height: 1.7; - color: var(--fg); - margin-bottom: 32px; - } - - .article-content p { - margin-bottom: 16px; - } - - .article-content p:last-child { - margin-bottom: 0; - } - - .images-section h2 { - font-size: 16px; - color: var(--accent); - margin-bottom: 16px; - font-weight: 500; - } - - .image-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 12px; - } - - .experience-image { - width: 100%; - height: 180px; - object-fit: cover; - border-radius: 4px; - border: 1px solid var(--border); - cursor: pointer; - transition: - opacity 0.15s, - border-color 0.15s; - } - - .experience-image:hover { - opacity: 0.9; - border-color: var(--accent); - } - - @media (max-width: 768px) { - .experience-page { - padding: 24px 16px 0; - } - - .back-btn { - padding: 6px 12px; - font-size: 11px; - margin-bottom: 16px; - } - - .header-top { - flex-direction: column; - gap: 12px; - } - - .title-section { - gap: 12px; - } - - .company-logo { - height: 36px; - } - - h1 { - font-size: 22px; - } - - .role { - font-size: 14px; - } - - .article-content { - font-size: 13px; - } - - .image-grid { - grid-template-columns: 1fr; - } - - .experience-image { - height: 200px; - } - } -</style> - -<script> - import { initKeyboard, onKey } from "../../ui/keyboard"; - import { SCROLL_AMOUNT } from "../../constants/ui/scroll"; - - initKeyboard(); - - onKey("ArrowUp", (e) => { - e.preventDefault(); - window.scrollBy({ top: -SCROLL_AMOUNT, behavior: "smooth" }); - }); - - onKey("ArrowDown", (e) => { - e.preventDefault(); - window.scrollBy({ top: SCROLL_AMOUNT, behavior: "smooth" }); - }); -</script> diff --git a/web/src/schemas/blog.ts b/web/src/schemas/blog.ts @@ -1,61 +0,0 @@ -import type { Blog } from "../blogs"; -import { SITE_URL } from "../constants/website"; -import personSchema from "./person"; - -export function getSchemaForBlog(blog: Blog) { - const schema: Record<string, unknown> = { - "@context": "https://schema.org", - "@type": "BlogPosting", - headline: blog.title, - description: blog.description, - datePublished: blog.date, - dateModified: blog.modifiedDate || blog.date, - url: `${SITE_URL}/blog/${blog.id}`, - author: personSchema, - publisher: personSchema, - mainEntityOfPage: { - "@type": "WebPage", - "@id": `${SITE_URL}/blog/${blog.id}`, - }, - articleSection: "Technology", - }; - - if (blog.image) { - schema.image = blog.image.startsWith("http") - ? blog.image - : `${SITE_URL}${blog.image}`; - } - - if (blog.tags && blog.tags.length > 0) { - schema.keywords = blog.tags.join(", "); - } - - return schema; -} - -export function getBreadcrumbSchema(blog: Blog) { - return { - "@context": "https://schema.org", - "@type": "BreadcrumbList", - itemListElement: [ - { - "@type": "ListItem", - position: 1, - name: "Home", - item: SITE_URL, - }, - { - "@type": "ListItem", - position: 2, - name: "Blog", - item: `${SITE_URL}/#blog`, - }, - { - "@type": "ListItem", - position: 3, - name: blog.title, - item: `${SITE_URL}/blog/${blog.id}`, - }, - ], - }; -} diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts @@ -1,25 +0,0 @@ -import { playBootAnimation } from "./boot"; -import { initRouter } from "./router"; -import { routeHandler, initTabs } from "./tabs"; -import { initKeyboard } from "./keyboard"; -import { initStatus } from "./status"; - -// Register tabs (order matters) -import "../tabs/about"; -import "../tabs/experience"; -import "../tabs/projects"; -import "../tabs/blog"; - -export async function initUI(): Promise<void> { - const promise = playBootAnimation(); - - // Initialize core systems - initKeyboard(); - initTabs(); - initRouter(routeHandler); - - // Initialize UI modules - initStatus(); - - await promise; -} diff --git a/web/src/ui/tabs.ts b/web/src/ui/tabs.ts @@ -1,174 +0,0 @@ -import { $$ } from "./dom"; -import { clearAllHandlers, onKey } from "./keyboard"; -import { setRoute } from "./router"; - -const order: string[] = []; -let currentIndex = 0; - -const tabs: Map<string, TabConfig> = new Map(); - -export type TabConfig = { - init?: () => void; - - activate?: () => void; - deactivate?: () => void; - - getRouteId?: () => string | undefined; - setRouteId?: (id?: string) => void; -}; - -let unsubscribe: (() => void) | undefined; - -export function initTabs(): void { - for (const config of tabs.values()) { - config.init?.(); - } - setupTabClicks(); -} - -export function registerTab(name: string, config: TabConfig): void { - if (tabs.has(name)) throw new Error(`Tab "${name}" already registered`); - - order.push(name); - tabs.set(name, config); -} - -export function routeHandler(route: { tab: string; id?: string }): void { - const config = tabs.get(route.tab); - if (!config) throw new Error(`Tab "${route.tab}" not found`); - - const index = findIndexByTabName(route.tab); - - deactivate(); - activate(index); - updateDom(); - - config.setRouteId?.(route.id); - - setRoute(route.tab, config.getRouteId?.()); -} - -export function getAllTabs(): string[] { - return order; -} - -export function getCurrentTab(): string { - const tabName = order[currentIndex]; - if (!tabName) throw new Error(`Tab index ${currentIndex} not found`); - return tabName; -} - -export function cycleTab(direction: "prev" | "next"): void { - const index = - (currentIndex + (direction === "prev" ? -1 : 1) + order.length) % - order.length; - - const tabName = order[index]; - if (!tabName) throw new Error(`Tab index ${index} not found`); - - switchTab(tabName); -} - -export function switchTab(tabName: string): void { - const index = findIndexByTabName(tabName); - - deactivate(); - activate(index); - updateDom(); - updateRoute(); -} - -function findIndexByTabName(tabName: string): number { - for (const [i, n] of order.entries()) { - if (n === tabName) return i; - } - throw new Error(`Tab "${tabName}" not found`); -} - -function activate(index: number): void { - const name = order[index]; - if (!name) throw new Error(`Tab index ${index} not found`); - - const config = tabs.get(name); - if (!config) throw new Error(`Tab "${name}" not found`); - - currentIndex = index; - - setupDefaultKeybinds(); - - config.activate?.(); - - unsubscribe = config.deactivate; -} - -function deactivate(): void { - if (unsubscribe) { - unsubscribe(); - unsubscribe = undefined; - } - clearAllHandlers(); -} - -function updateDom(): void { - const name = order[currentIndex]; - if (!name) throw new Error(`Tab index ${currentIndex} not found`); - - $$(".tab").forEach((tab) => { - const isActive = tab.getAttribute("data-tab") === name; - tab.classList.toggle("active", isActive); - if (isActive) { - tab.setAttribute("aria-current", "page"); - } else { - tab.removeAttribute("aria-current"); - } - }); - - $$(".page").forEach((page) => { - page.classList.toggle("active", page.id === name); - }); -} - -function updateRoute(): void { - const name = order[currentIndex]; - if (!name) throw new Error(`Tab index ${currentIndex} not found`); - - const config = tabs.get(name); - if (!config) throw new Error(`Tab "${name}" not found`); - - setRoute(name, config.getRouteId?.()); -} - -function setupDefaultKeybinds(): void { - onKey("Tab", (e) => { - e.preventDefault(); - cycleTab(e.shiftKey ? "prev" : "next"); - }); - - for (const [i, name] of order.entries()) { - onKey(String(i + 1), () => switchTab(name)); - } - - onKey("ArrowLeft", (e) => { - if (currentIndex === 0) return; - e.preventDefault(); - cycleTab("prev"); - }); - onKey("ArrowRight", (e) => { - if (currentIndex === order.length - 1) return; - e.preventDefault(); - cycleTab("next"); - }); -} - -function setupTabClicks(): void { - $$(".tab").forEach((tab) => { - tab.addEventListener("click", () => { - const name = tab.getAttribute("data-tab"); - if (!name) { - console.warn("Tab clicked without name"); - return; - } - switchTab(name); - }); - }); -}