commit f0930385d5ba6f7e7c475df5c9e7c7ceb132d1ae
parent 5cda611e21bdf7a17fa639c39c5eb2836b687e6e
Author: Damian Myrda <damian@prime8.dev>
Date: Thu, 22 Jan 2026 15:07:49 -0600
refactor: reorganize UI into tab registry pattern
- Create tab registry system (src/ui/tabs.ts) where each tab self-registers
- Move tab-specific logic to src/tabs/{about,experience,projects,blog}.ts
- Simplify keyboard system to single handler per key
- Add static tab config (src/config/tabs.ts) for SSR-compatible header
- Reorganize directory structure:
- src/ui/ for core UI modules (keyboard, router, tabs, dom, boot, status)
- src/tabs/ for tab-specific behavior
- src/layouts/tabs/ for tab Astro components
- src/layouts/components/ for shared components
- Fix blog page keyboard scrolling (scroll window instead of missing element)
- Add data-id attributes to experience cards for URL routing
Diffstat:
95 files changed, 4042 insertions(+), 3836 deletions(-)
diff --git a/web/config/about.json b/web/config/about.json
@@ -1,18 +0,0 @@
-{
- "name": "Damian Myrda",
- "handle": "moncheeta",
- "bio": "Passionate about low-level systems programming, TUI/CLI tooling, and UNIX philosophy. Always learning just like Curious George 🐒",
- "location": "Chicago, IL",
- "profession": "Software Engineer",
- "profession_short": "SWE",
- "languages": ["Zig", "Rust", "C", "Go", "TypeScript", "Python"],
- "interests": [
- "Low-level systems",
- "TUI/CLI tooling",
- "Embedded OS",
- "UNIX philosophy"
- ],
- "tools": ["Zed (Vim mode)", "OpenCode", "Linux", "Fish"],
- "school": "Prospect High School (Senior)",
- "graduating": "May 2026"
-}
diff --git a/web/config/contact.json b/web/config/contact.json
@@ -1,5 +0,0 @@
-{
- "email": "damian@prime8.dev",
- "gitlab": "https://git.prime8.dev/moncheeta",
- "linkedin": "https://linkedin.com/in/damian-myrda"
-}
diff --git a/web/config/experience.json b/web/config/experience.json
@@ -1,35 +0,0 @@
-[
- {
- "id": "mandolin",
- "company": "Mandolin",
- "role": "Senior SWE Intern",
- "startDate": "2024-10",
- "description": [
- "Building AI agents for specialty drug access. Automating prior authorizations, benefits verification, and revenue cycle management.",
- "Mandolin raised $40M to automate access to life-saving medicines, wiring AI labor into thousands of infusion centers, specialty pharmacies, and health systems."
- ],
- "tech": ["TypeScript", "Temporal"],
- "url": "https://mandolin.com",
- "logo": "/mandolin/logo.svg",
- "images": [
- "/mandolin/company.jpeg",
- "/mandolin/company_silly.jpeg",
- "/mandolin/employee_polaroids.jpeg",
- "/mandolin/moment_polaroids.jpeg"
- ]
- },
- {
- "id": "amplifier-health",
- "company": "Amplifier Health",
- "role": "SWE Intern",
- "startDate": "2024-06",
- "endDate": "2024-08",
- "description": [
- "Voice AI for early disease detection. Large Acoustic Model analyzing 1000+ voice biomarkers across 30+ health conditions."
- ],
- "tech": ["Python", "PyTorch"],
- "url": "https://amplifierhealth.com",
- "logo": "/amplifier/logo.svg",
- "logoFilter": "logo-invert"
- }
-]
diff --git a/web/config/projects.json b/web/config/projects.json
@@ -1,34 +0,0 @@
-[
- {
- "id": "janitor",
- "name": "janitor",
- "subtitle": "Audio classification with AST",
- "tech": ["Rust", "CUDA"],
- "url": "https://git.prime8.dev/moncheeta/janitor",
- "apiUrl": "https://git.prime8.dev/api/v4/projects/moncheeta%2Fjanitor"
- },
- {
- "id": "summarizer",
- "name": "summarizer",
- "subtitle": "Audio transcription microservices",
- "tech": ["Python", "Docker", "Kafka"],
- "url": "https://git.prime8.dev/moncheeta/summarizer",
- "apiUrl": "https://git.prime8.dev/api/v4/projects/moncheeta%2Fsummarizer"
- },
- {
- "id": "finger",
- "name": "finger",
- "subtitle": "Mobile auth app",
- "tech": ["TypeScript", "React Native", "Supabase"],
- "url": "https://git.prime8.dev/moncheeta/finger",
- "apiUrl": "https://git.prime8.dev/api/v4/projects/moncheeta%2Ffinger"
- },
- {
- "id": "computer-science-club",
- "name": "computer-science-club",
- "subtitle": "CS Club website",
- "tech": ["Python", "REST"],
- "url": "https://git.prime8.dev/moncheeta/computer-science-club",
- "apiUrl": "https://git.prime8.dev/api/v4/projects/moncheeta%2Fcomputer-science-club"
- }
-]
diff --git a/web/blogs/building-my-portfolio.md b/web/src/blogs/building-my-portfolio.md
diff --git a/web/src/blogs/index.ts b/web/src/blogs/index.ts
@@ -0,0 +1,62 @@
+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/components/Article.astro b/web/src/components/Article.astro
@@ -1,387 +0,0 @@
----
-import type Blog from "../lib/types/blog";
-
-interface Props {
- blog: Blog;
-}
-
-const { blog } = Astro.props;
----
-
-<article class="blog-article" data-id={blog.id} data-blog-id={blog.id}>
- <header class="blog-article-header">
- <div class="blog-article-header-top">
- <h1 class="blog-article-title">{blog.title}</h1>
- <button
- class="blog-share-btn blog-share-btn-desktop"
- data-blog-id={blog.id}
- data-blog-title={blog.title}
- >
- [Share]
- </button>
- </div>
- <div class="blog-article-meta">
- <time class="blog-article-date" datetime={blog.date}>{blog.date}</time>
- <button
- class="blog-share-btn blog-share-btn-mobile"
- data-blog-id={blog.id}
- data-blog-title={blog.title}
- >
- [Share]
- </button>
- </div>
- </header>
- <div class="blog-article-content" data-blog-id={blog.id}>
- {
- blog.url ? (
- <a
- class="external-link"
- href={blog.url}
- target="_blank"
- rel="noopener noreferrer"
- >
- Read on external site →
- </a>
- ) : blog.content ? (
- <Fragment set:html={blog.content} />
- ) : (
- <p class="color-dim">Content not available</p>
- )
- }
- </div>
-</article>
-
-<style>
- .blog-article {
- animation: fadeIn 0.2s ease;
- }
-
- @keyframes fadeIn {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
- }
-
- .blog-article-header {
- margin-bottom: 24px;
- padding-bottom: 16px;
- border-bottom: 1px solid var(--border);
- }
-
- .blog-article-header-top {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- }
-
- .blog-article-title {
- font-size: 28px;
- font-weight: 500;
- color: var(--accent);
- margin: 0 0 8px 0;
- line-height: 1.3;
- }
-
- .blog-share-btn {
- background: transparent;
- border: 1px solid var(--border);
- color: var(--fg-dim);
- padding: 6px 12px;
- cursor: pointer;
- font-family: inherit;
- font-size: 12px;
- transition: all 0.15s;
- flex-shrink: 0;
- }
-
- .blog-share-btn:hover {
- background: var(--bg-alt);
- color: var(--accent);
- border-color: var(--accent);
- }
-
- .blog-share-btn.copied {
- color: var(--green);
- border-color: var(--green);
- }
-
- .blog-article-meta {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
-
- .blog-article-date {
- color: var(--fg-dim);
- font-size: 13px;
- }
-
- .blog-share-btn-mobile {
- display: none;
- }
-
- .blog-article-content {
- font-size: 14px;
- line-height: 1.7;
- color: var(--fg);
- padding-bottom: 32px;
- }
-
- .blog-article-content :global(p) {
- margin-bottom: 16px;
- }
-
- .blog-article-content :global(p:last-child) {
- margin-bottom: 0;
- }
-
- .blog-article-content :global(h2) {
- font-size: 20px;
- color: var(--accent);
- margin: 32px 0 12px;
- font-weight: 600;
- }
-
- .blog-article-content :global(h2:first-child) {
- margin-top: 0;
- }
-
- .blog-article-content :global(h3) {
- font-size: 17px;
- color: var(--accent);
- margin: 24px 0 10px;
- font-weight: 500;
- }
-
- .blog-article-content :global(h4) {
- font-size: 15px;
- color: var(--fg);
- margin: 20px 0 8px;
- font-weight: 500;
- }
-
- .blog-article-content :global(h5),
- .blog-article-content :global(h6) {
- font-size: 14px;
- color: var(--fg-dim);
- margin: 16px 0 8px;
- font-weight: 500;
- }
-
- .blog-article-content :global(ul),
- .blog-article-content :global(ol) {
- margin: 12px 0;
- padding-left: 24px;
- }
-
- .blog-article-content :global(li) {
- margin-bottom: 8px;
- }
-
- .blog-article-content :global(li > ul),
- .blog-article-content :global(li > ol) {
- margin: 6px 0;
- }
-
- .blog-article-content :global(code) {
- background: var(--bg-alt);
- padding: 2px 6px;
- font-size: 13px;
- border-radius: 2px;
- }
-
- .blog-article-content :global(pre) {
- display: block;
- background: var(--bg-alt);
- border: 1px solid var(--border);
- padding: 16px;
- margin: 16px 0;
- overflow-x: auto;
- font-size: 13px;
- line-height: 1.5;
- }
-
- .blog-article-content :global(pre code) {
- background: none;
- padding: 0;
- font-size: inherit;
- }
-
- .blog-article-content :global(a) {
- color: var(--light-blue);
- text-decoration: none;
- }
-
- .blog-article-content :global(a:hover) {
- opacity: 0.8;
- }
-
- .blog-article-content :global(strong) {
- font-weight: 600;
- color: var(--fg);
- }
-
- .blog-article-content :global(em) {
- font-style: italic;
- color: var(--fg-dim);
- }
-
- .blog-article-content :global(del) {
- text-decoration: line-through;
- color: var(--fg-dim);
- }
-
- .blog-article-content :global(blockquote) {
- border-left: 3px solid var(--accent);
- margin: 16px 0;
- padding: 12px 20px;
- color: var(--fg-dim);
- background: var(--bg-alt);
- }
-
- .blog-article-content :global(blockquote p) {
- margin-bottom: 0;
- }
-
- .blog-article-content :global(hr) {
- border: none;
- border-top: 1px solid var(--border);
- margin: 24px 0;
- }
-
- .blog-article-content :global(table) {
- width: 100%;
- border-collapse: collapse;
- margin: 16px 0;
- font-size: 13px;
- }
-
- .blog-article-content :global(th),
- .blog-article-content :global(td) {
- border: 1px solid var(--border);
- padding: 10px 14px;
- text-align: left;
- }
-
- .blog-article-content :global(th) {
- background: var(--bg-alt);
- color: var(--accent);
- font-weight: 500;
- }
-
- .blog-article-content :global(tr:nth-child(even)) {
- background: rgba(255, 255, 255, 0.02);
- }
-
- .blog-article-content :global(img) {
- max-width: 100%;
- height: auto;
- margin: 16px 0;
- }
-
- .external-link {
- color: var(--light-blue);
- text-decoration: none;
- font-size: 14px;
- }
-
- .external-link:hover {
- opacity: 0.8;
- }
-
- @media (max-width: 768px) {
- .blog-article-header {
- margin-bottom: 16px;
- padding-bottom: 12px;
- }
-
- .blog-article-title {
- font-size: 22px;
- }
-
- .blog-share-btn-desktop {
- display: none;
- }
-
- .blog-share-btn-mobile {
- display: block;
- }
-
- .blog-article-content {
- font-size: 13px;
- }
-
- .blog-article-content :global(h2) {
- font-size: 18px;
- margin: 24px 0 10px;
- }
-
- .blog-article-content :global(h3) {
- font-size: 15px;
- margin: 20px 0 8px;
- }
-
- .blog-article-content :global(pre) {
- padding: 12px;
- font-size: 12px;
- }
-
- .blog-article-content :global(code) {
- font-size: 12px;
- }
-
- .blog-article-content :global(table) {
- font-size: 12px;
- }
-
- .blog-article-content :global(th),
- .blog-article-content :global(td) {
- padding: 8px 10px;
- }
- }
-</style>
-
-<script>
- document.addEventListener("DOMContentLoaded", () => {
- const shareButtons = document.querySelectorAll(".blog-share-btn");
-
- shareButtons.forEach((btn) => {
- btn.addEventListener("click", async (e) => {
- e.stopPropagation();
- const button = e.currentTarget as HTMLButtonElement;
- const blogId = button.dataset.blogId;
- const title = button.dataset.blogTitle;
- const url = `${window.location.origin}/blog/${blogId}`;
-
- if (navigator.share) {
- try {
- await navigator.share({
- title,
- url,
- });
- } catch {
- // User cancelled share dialog
- }
- } else {
- copyToClipboard(url, button);
- }
- });
- });
-
- function copyToClipboard(text: string, btn: HTMLButtonElement) {
- navigator.clipboard.writeText(text).then(() => {
- const originalText = btn.textContent;
- btn.textContent = "[Copied!]";
- btn.classList.add("copied");
- setTimeout(() => {
- btn.textContent = originalText;
- btn.classList.remove("copied");
- }, 2000);
- });
- }
- });
-</script>
diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro
@@ -1,46 +0,0 @@
----
-import type { WakaTimeData } from "../lib/wakatime";
-
-interface Props {
- wakaTimeData: WakaTimeData | null;
-}
-
-const { wakaTimeData } = Astro.props;
----
-
-<footer id="statusbar">
- <span class="time" id="status-time"></span>
- <span class="spacer"></span>
- {
- wakaTimeData?.weeklyStats && (
- <span class="wakatime">{wakaTimeData.allTimeStats?.totalText}</span>
- )
- }
-</footer>
-
-<style>
- #statusbar {
- display: flex;
- align-items: center;
- background: var(--bg-alt);
- border-top: 1px solid var(--border);
- padding: 0 12px;
- height: 24px;
- font-size: 12px;
- position: sticky;
- bottom: 0;
- z-index: 100;
- }
-
- #statusbar .time {
- color: var(--cyan);
- }
-
- #statusbar .spacer {
- flex: 1;
- }
-
- #statusbar .wakatime {
- color: var(--magenta);
- }
-</style>
diff --git a/web/src/components/Header.astro b/web/src/components/Header.astro
@@ -1,144 +0,0 @@
-<header id="tabs">
- <nav class="tab-nav" aria-label="Main navigation">
- <div class="tab active" data-tab="about" aria-current="page"><span class="tab-num">[1] </span>About</div>
- <div class="tab" data-tab="experience"><span class="tab-num">[2] </span>Experience</div>
- <div class="tab" data-tab="projects"><span class="tab-num">[3] </span>Projects</div>
- <div class="tab" data-tab="blog"><span class="tab-num">[4] </span>Blog</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/components/Lightbox.astro b/web/src/components/Lightbox.astro
@@ -1,139 +0,0 @@
----
-
----
-
-<div id="image-lightbox" class="lightbox hidden">
- <div class="lightbox-overlay"></div>
- <div class="lightbox-content">
- <img src="" alt="" class="lightbox-image" />
- <button class="lightbox-close">[X]</button>
- </div>
-</div>
-
-<style>
- .lightbox {
- position: fixed;
- inset: 0;
- z-index: 1000;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .lightbox.hidden {
- display: none;
- }
-
- .lightbox-overlay {
- position: absolute;
- inset: 0;
- background: rgba(0, 0, 0, 0.9);
- cursor: pointer;
- }
-
- .lightbox-content {
- position: relative;
- max-width: 90vw;
- max-height: 90vh;
- z-index: 1;
- }
-
- .lightbox-image {
- max-width: 100%;
- max-height: 90vh;
- border-radius: 4px;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
- }
-
- .lightbox-close {
- position: absolute;
- top: 8px;
- right: 8px;
- background: rgba(0, 0, 0, 0.7);
- border: 1px solid var(--border);
- color: var(--fg);
- font-family: "JetBrains Mono", monospace;
- font-size: 12px;
- cursor: pointer;
- padding: 4px 8px;
- line-height: 1;
- transition:
- color 0.15s,
- background 0.15s;
- z-index: 10;
- }
-
- .lightbox-close:hover {
- color: var(--accent);
- background: rgba(0, 0, 0, 0.9);
- }
-</style>
-
-<script>
- document.addEventListener("DOMContentLoaded", () => {
- const lightbox = document.getElementById("image-lightbox");
- const lightboxImage = lightbox?.querySelector(
- ".lightbox-image",
- ) as HTMLImageElement;
- const lightboxOverlay = lightbox?.querySelector(".lightbox-overlay");
- const lightboxClose = lightbox?.querySelector(".lightbox-close");
-
- let lightboxImages: string[] = [];
- let currentLightboxIndex = 0;
-
- (window as Window & { isLightboxOpen?: () => boolean }).isLightboxOpen =
- () => !lightbox?.classList.contains("hidden");
-
- (
- window as Window & {
- openLightbox?: (images: string[], startIndex?: number) => void;
- }
- ).openLightbox = (images: string[], startIndex: number = 0) => {
- if (lightbox && lightboxImage && images.length > 0) {
- lightboxImages = images;
- currentLightboxIndex = startIndex;
- lightboxImage.src = images[startIndex];
- lightbox.classList.remove("hidden");
- }
- };
-
- function closeLightbox() {
- lightbox?.classList.add("hidden");
- lightboxImages = [];
- currentLightboxIndex = 0;
- }
-
- function navigateLightboxImage(direction: number) {
- if (lightboxImages.length <= 1) return;
- currentLightboxIndex =
- (currentLightboxIndex + direction + lightboxImages.length) %
- lightboxImages.length;
- if (lightboxImage) {
- lightboxImage.src = lightboxImages[currentLightboxIndex];
- }
- }
-
- (
- window as Window & { navigateLightboxImage?: (direction: number) => void }
- ).navigateLightboxImage = navigateLightboxImage;
-
- lightboxOverlay?.addEventListener("click", closeLightbox);
- lightboxClose?.addEventListener("click", closeLightbox);
-
- document.addEventListener("keydown", (e) => {
- if (!lightbox?.classList.contains("hidden")) {
- if (e.key === "Escape") {
- closeLightbox();
- } else if (e.key === "ArrowLeft") {
- e.preventDefault();
- e.stopPropagation();
- navigateLightboxImage(-1);
- } else if (e.key === "ArrowRight") {
- e.preventDefault();
- e.stopPropagation();
- navigateLightboxImage(1);
- }
- }
- });
- });
-</script>
diff --git a/web/src/components/tabs/About.astro b/web/src/components/tabs/About.astro
@@ -1,392 +0,0 @@
----
-import rawAbout from "../../../config/about.json";
-import type About from "../../lib/types/about";
-import rawContact from "../../../config/contact.json";
-import type Contact from "../../lib/types/contact";
-import type { WakaTimeData } from "../../lib/wakatime";
-import { getTagClass } from "../../lib/utils/tags";
-
-const about = rawAbout as About;
-const contact = rawContact as Contact;
-
-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-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>
- <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-row {
- display: flex;
- font-size: 13px;
- margin-bottom: 4px;
- }
-
- .info-row .label {
- color: var(--fg-dim);
- width: 100px;
- flex-shrink: 0;
- }
-
- .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;
- }
-
- .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/components/tabs/Blog.astro b/web/src/components/tabs/Blog.astro
@@ -1,244 +0,0 @@
----
-import rawBlogs from "../../lib/blogs";
-import type Blog from "../../lib/types/blog";
-import BlogArticle from "../Article.astro";
-
-const blogs = rawBlogs as Blog[];
----
-
-<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>
-
-<script>
- import { closeBlogPost } from "../../scripts/ui/blog";
- import { updateUrl } from "../../scripts/ui/url";
-
- document.addEventListener("DOMContentLoaded", () => {
- document.querySelector(".blog-back-btn")?.addEventListener("click", () => {
- closeBlogPost();
- updateUrl();
- });
- });
-</script>
diff --git a/web/src/components/tabs/Experience.astro b/web/src/components/tabs/Experience.astro
@@ -1,523 +0,0 @@
----
-import rawExperience from "../../../config/experience.json";
-import type Experience from "../../lib/types/experience";
-import currentJob from "../../lib/utils/job";
-import { MONTH_NAMES } from "../../constants/calendar";
-import { calculateMonths } from "../../lib/utils/period";
-import { getTagClass } from "../../lib/utils/tags";
-
-const experience = (rawExperience as Experience[]).sort((a, b) =>
- b.startDate.localeCompare(a.startDate),
-);
-
-const currentRoleIndex = currentJob
- ? experience.findIndex((exp) => exp.id === currentJob.id)
- : 0;
-
-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 totalDays = Math.ceil(
- (timelineEnd.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24),
-);
-
-const startYear = timelineStart.getFullYear();
-const endYear = timelineEnd.getFullYear();
-const years = Array.from(
- { length: endYear - startYear + 1 },
- (_, i) => startYear + i,
-);
-
-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;
- const isCurrent = !exp.endDate;
-
- return (
- <div class="exp-row" data-index={index}>
- <div class="exp-card" data-index={index}>
- <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>
- <span class="timeline-year">[{exp.endDate ? exp.endDate.split("-")[0] : "Now"}]</span>
- {!isLast && <div class="timeline-line"></div>}
- </div>
- </div>
- );
- })
- }
- </div>
- </article>
-</section>
-
-<script>
- document.addEventListener("DOMContentLoaded", () => {
- const firstCard = document.querySelector("#experience .exp-card");
- firstCard?.classList.add("selected");
-
- const rows = document.querySelectorAll("#experience .exp-row");
-
- rows.forEach((row) => {
- row.addEventListener("mouseenter", () => {
- // Remove active from all
- rows.forEach((r) => {
- r.querySelector(".exp-card")?.classList.remove("selected");
- r.querySelector(".exp-timeline")?.classList.remove("active");
- });
-
- // Add active to hovered
- row.querySelector(".exp-card")?.classList.add("selected");
- row.querySelector(".exp-timeline")?.classList.add("active");
- });
- });
-
- 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>
-
-<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>
diff --git a/web/src/components/tabs/Projects.astro b/web/src/components/tabs/Projects.astro
@@ -1,412 +0,0 @@
----
-import rawProjects from "../../../config/projects.json";
-import type Project from "../../lib/types/project";
-import { fetchGitMetadataForProject } from "../../lib/git";
-import { parseMarkdown } from "../../lib/readme";
-import { getTagClass, getTechMeta } from "../../lib/utils/tags";
-
-const projects = rawProjects as Project[];
-
-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(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/config/about.ts b/web/src/config/about.ts
@@ -0,0 +1,32 @@
+export default interface About {
+ name: string;
+ handle: string;
+ bio: string;
+ location: string;
+ profession: string;
+ profession_short: string;
+ languages: string[];
+ interests: string[];
+ tools: string[];
+ school?: string;
+ graduating?: string;
+}
+
+export const about = {
+ name: "Damian Myrda",
+ handle: "moncheeta",
+ bio: "Passionate about low-level systems programming, TUI/CLI tooling, and UNIX philosophy. Always learning just like Curious George 🐒",
+ location: "Chicago, IL",
+ profession: "Software Engineer",
+ profession_short: "SWE",
+ languages: ["Zig", "Rust", "C", "Go", "TypeScript", "Python"],
+ interests: [
+ "Low-level systems",
+ "TUI/CLI tooling",
+ "Embedded",
+ "UNIX philosophy",
+ ],
+ tools: ["Zed (Vim mode)", "OpenCode", "Linux", "Fish"],
+ school: "Prospect High School (Senior)",
+ graduating: "May 2026",
+} as About;
diff --git a/web/src/config/contact.ts b/web/src/config/contact.ts
@@ -0,0 +1,11 @@
+export default interface Contact {
+ email: string;
+ gitlab: string;
+ linkedin: string;
+}
+
+export const contact = {
+ email: "damian@prime8.dev",
+ gitlab: "https://git.prime8.dev/moncheeta",
+ linkedin: "https://linkedin.com/in/damian-myrda",
+} as Contact;
diff --git a/web/src/config/experience.ts b/web/src/config/experience.ts
@@ -0,0 +1,51 @@
+export interface Experience {
+ id: string;
+ company: string;
+ role: string;
+ startDate: string;
+ endDate?: string;
+ description: string[];
+ tech: string[];
+ url: string;
+ logo: string;
+ logoFilter?: string;
+ images?: string[];
+}
+
+export const experience = [
+ {
+ id: "mandolin",
+ company: "Mandolin",
+ role: "Senior SWE Intern",
+ startDate: "2024-10",
+ description: [
+ "Building AI agents for specialty drug access. Automating prior authorizations, benefits verification, and revenue cycle management.",
+ "Mandolin raised $40M to automate access to life-saving medicines, wiring AI labor into thousands of infusion centers, specialty pharmacies, and health systems.",
+ ],
+ tech: ["TypeScript", "Temporal"],
+ url: "https://mandolin.com",
+ logo: "/mandolin/logo.svg",
+ images: [
+ "/mandolin/company.jpeg",
+ "/mandolin/company_silly.jpeg",
+ "/mandolin/employee_polaroids.jpeg",
+ "/mandolin/moment_polaroids.jpeg",
+ ],
+ },
+ {
+ id: "amplifier-health",
+ company: "Amplifier Health",
+ role: "SWE Intern",
+ startDate: "2024-06",
+ endDate: "2024-08",
+ description: [
+ "Voice AI for early disease detection. Large Acoustic Model analyzing 1000+ voice biomarkers across 30+ health conditions.",
+ ],
+ tech: ["Python", "PyTorch"],
+ url: "https://amplifierhealth.com",
+ logo: "/amplifier/logo.svg",
+ logoFilter: "logo-invert",
+ },
+] as Experience[];
+
+export const currentJob = experience.find((exp) => !exp.endDate);
diff --git a/web/src/config/index.ts b/web/src/config/index.ts
@@ -0,0 +1,4 @@
+export * from "./about";
+export * from "./contact";
+export * from "./experience";
+export * from "./projects";
diff --git a/web/src/config/projects.ts b/web/src/config/projects.ts
@@ -0,0 +1,44 @@
+export default interface Project {
+ id: string;
+ name: string;
+ subtitle: string;
+ tech: string[];
+ url: string;
+ apiUrl: string;
+}
+
+export const projects = [
+ {
+ id: "janitor",
+ name: "janitor",
+ subtitle: "Audio classification with AST",
+ tech: ["Rust", "CUDA"],
+ url: "https://git.prime8.dev/moncheeta/janitor",
+ apiUrl: "https://git.prime8.dev/api/v4/projects/moncheeta%2Fjanitor",
+ },
+ {
+ id: "summarizer",
+ name: "summarizer",
+ subtitle: "Audio transcription microservices",
+ tech: ["Python", "Docker", "Kafka"],
+ url: "https://git.prime8.dev/moncheeta/summarizer",
+ apiUrl: "https://git.prime8.dev/api/v4/projects/moncheeta%2Fsummarizer",
+ },
+ {
+ id: "finger",
+ name: "finger",
+ subtitle: "Mobile auth app",
+ tech: ["TypeScript", "React Native", "Supabase"],
+ url: "https://git.prime8.dev/moncheeta/finger",
+ apiUrl: "https://git.prime8.dev/api/v4/projects/moncheeta%2Ffinger",
+ },
+ {
+ id: "computer-science-club",
+ name: "computer-science-club",
+ subtitle: "CS Club website",
+ tech: ["Python", "REST"],
+ url: "https://git.prime8.dev/moncheeta/computer-science-club",
+ apiUrl:
+ "https://git.prime8.dev/api/v4/projects/moncheeta%2Fcomputer-science-club",
+ },
+] as Project[];
diff --git a/web/src/config/tabs.ts b/web/src/config/tabs.ts
@@ -0,0 +1,8 @@
+export const TABS = [
+ { id: "about", label: "About" },
+ { id: "experience", label: "Experience" },
+ { id: "projects", label: "Projects" },
+ { id: "blog", label: "Blog" },
+] as const;
+
+export type TabId = (typeof TABS)[number]["id"];
diff --git a/web/src/constants/wakatime.ts b/web/src/constants/external/wakatime.ts
diff --git a/web/src/constants/tags.ts b/web/src/constants/tags.ts
@@ -1,34 +0,0 @@
-export const TAG_CLASS_MAP: Record<string, string> = {
- // Languages
- Zig: "lang-zig",
- Rust: "lang-rust",
- C: "lang-c",
- Go: "lang-go",
- TypeScript: "lang-ts",
- Python: "lang-python",
- // Tools
- "Zed (Vim mode)": "tool-zed",
- Neovim: "tool-neovim",
- OpenCode: "tool-opencode",
- Linux: "tool-linux",
- Fish: "tool-fish",
- Ghostty: "tool-ghostty",
- // Interests
- "Low-level systems": "interest-lowlevel",
- "TUI/CLI tooling": "interest-tui",
- "Embedded OS": "interest-embedded",
- "UNIX philosophy": "interest-unix",
- // Tech
- Temporal: "tech-temporal",
- PyTorch: "tech-pytorch",
- CUDA: "tech-cuda",
- Docker: "tech-docker",
- Kafka: "tech-kafka",
- "React Native": "tech-rn",
- Supabase: "tech-supabase",
- REST: "tech-rest",
-};
-
-export const TECH_ABBREVIATIONS: Record<string, string> = {
- TypeScript: "TS",
-};
diff --git a/web/src/constants/ui/boot.ts b/web/src/constants/ui/boot.ts
@@ -0,0 +1,11 @@
+import { about } from "../../config";
+import { DOMAIN } from "../website";
+
+export const TITLE = about.name;
+export const SUBTITLE = DOMAIN;
+
+export const GLITCH_CHARS = "▓▒░█▄▀■□▪▫●○◆◇";
+export const SCRAMBLE_DURATION = 400;
+export const SCRAMBLE_INTERVAL = 30;
+export const HOLD_DURATION = 200;
+export const FADE_DURATION = 300;
diff --git a/web/src/constants/ui/scroll.ts b/web/src/constants/ui/scroll.ts
@@ -0,0 +1 @@
+export const SCROLL_AMOUNT = 200;
diff --git a/web/src/constants/ui/tags.ts b/web/src/constants/ui/tags.ts
@@ -0,0 +1,34 @@
+export const TAG_CLASS_MAP: Record<string, string> = {
+ // Languages
+ Zig: "lang-zig",
+ Rust: "lang-rust",
+ C: "lang-c",
+ Go: "lang-go",
+ TypeScript: "lang-ts",
+ Python: "lang-python",
+ // Tools
+ "Zed (Vim mode)": "tool-zed",
+ Neovim: "tool-neovim",
+ OpenCode: "tool-opencode",
+ Linux: "tool-linux",
+ Fish: "tool-fish",
+ Ghostty: "tool-ghostty",
+ // Interests
+ "Low-level systems": "interest-lowlevel",
+ "TUI/CLI tooling": "interest-tui",
+ Embedded: "interest-embedded",
+ "UNIX philosophy": "interest-unix",
+ // Tech
+ Temporal: "tech-temporal",
+ PyTorch: "tech-pytorch",
+ CUDA: "tech-cuda",
+ Docker: "tech-docker",
+ Kafka: "tech-kafka",
+ "React Native": "tech-rn",
+ Supabase: "tech-supabase",
+ REST: "tech-rest",
+};
+
+export const TECH_ABBREVIATIONS: Record<string, string> = {
+ TypeScript: "TS",
+};
diff --git a/web/src/constants/website.ts b/web/src/constants/website.ts
@@ -1 +1,3 @@
-export const URL = "https://prime8.dev";
+export const DOMAIN = "prime8.dev";
+
+export const URL = `https://${DOMAIN}`;
diff --git a/web/src/external/git.ts b/web/src/external/git.ts
@@ -0,0 +1,68 @@
+import type Project from "../config/projects";
+
+export interface GitMetadata {
+ readme: string | null;
+ dateCreated: Date;
+ dateModified: Date;
+}
+
+export async function fetchGitMetadataForProject(
+ project: Project,
+): Promise<GitMetadata | null> {
+ try {
+ const response = await fetch(project.apiUrl, {
+ headers: { "User-Agent": "prime8dev-portfolio" },
+ });
+
+ if (!response.ok) return null;
+
+ const data = await response.json();
+
+ const baseUrl = `${data.web_url.replace("http://", "https://")}/-/raw/${data.default_branch}`;
+
+ return {
+ readme: await fetchReadme(baseUrl, toRawUrl(data.readme_url)),
+ dateCreated: data.created_at,
+ dateModified: data.updated_at,
+ };
+ } catch {
+ return null;
+ }
+}
+
+function toRawUrl(blobUrl: string): string {
+ return blobUrl.replace("http://", "https://").replace("/-/blob/", "/-/raw/");
+}
+
+async function fetchReadme(
+ baseUrl: string,
+ url: string,
+): Promise<string | null> {
+ try {
+ const response = await fetch(url, {
+ headers: { "User-Agent": "prime8dev-portfolio" },
+ });
+ if (!response.ok) return null;
+
+ const content = await response.text();
+ return fixRelativePaths(baseUrl, content);
+ } catch {
+ return null;
+ }
+}
+
+function fixRelativePaths(baseUrl: string, markdown: string): string {
+ // Fix relative image paths:  -> 
+ let fixed = markdown.replace(
+ /!\[([^\]]*)\]\(\.\/([^)]+)\)/g,
+ ``,
+ );
+
+ // Fix relative link paths (not images): [text](./path) -> [text](baseUrl/path)
+ fixed = fixed.replace(
+ /(?<!!)\[([^\]]+)\]\(\.\/([^)]+)\)/g,
+ `[$1](${baseUrl}/$2)`,
+ );
+
+ return fixed;
+}
diff --git a/web/src/external/wakatime.ts b/web/src/external/wakatime.ts
@@ -0,0 +1,159 @@
+import {
+ WAKATIME_API_BASE,
+ WAKATIME_API_KEY,
+} from "../constants/external/wakatime";
+import { DAY_NAMES, MONTH_NAMES } from "../constants/calendar";
+import { formatDuration } from "../utils/duration";
+
+export interface WakaTimeData {
+ allTimeStats: AllTimeStats | null;
+ weeklyStats: WeeklyStats | null;
+}
+
+export async function fetchWakaTimeData(): Promise<WakaTimeData | null> {
+ if (!WAKATIME_API_KEY) {
+ return null;
+ }
+
+ try {
+ const [allTimeStats, weeklyStats] = await Promise.all([
+ fetchAllTimeStats(),
+ fetchWeeklyStats(),
+ ]);
+
+ return { allTimeStats, weeklyStats };
+ } catch (error) {
+ console.error("Failed to fetch WakaTime data:", error);
+ return null;
+ }
+}
+
+export interface AllTimeStats {
+ totalText: string;
+ dailyAverageText: string;
+}
+
+export async function fetchAllTimeStats(): Promise<AllTimeStats | null> {
+ try {
+ const [statsResponse, languagesResponse] = [
+ await fetch(`${WAKATIME_API_BASE}/users/current/all_time_since_today`, {
+ headers: {
+ Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
+ },
+ }),
+ await fetch(`${WAKATIME_API_BASE}/users/current/stats/last_year`, {
+ headers: {
+ Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
+ },
+ }),
+ ];
+ if ([statsResponse, languagesResponse].some((r) => !r.ok)) {
+ return null;
+ }
+
+ const [statsData, langData] = await Promise.all(
+ [statsResponse, languagesResponse].map((r) => r.json()),
+ );
+
+ let dailyAverageText = "1 hr";
+ if (langData.data?.daily_average_including_other_language) {
+ const avgSeconds = langData.data.daily_average_including_other_language;
+ dailyAverageText = formatDuration(avgSeconds);
+ }
+
+ const totalSeconds = statsData.data?.total_seconds || 0;
+ const totalHours = Math.floor(totalSeconds / 3600);
+ const totalText = `${totalHours.toLocaleString()}+ hrs coded`;
+
+ return {
+ totalText,
+ dailyAverageText,
+ };
+ } catch (error) {
+ console.error("Failed to fetch WakaTime stats:", error);
+ return null;
+ }
+}
+
+export interface WeeklyStats {
+ totalText: string;
+ days: DailyEntry[];
+}
+
+export interface DailyEntry {
+ dayText: string; // e.g., "Mon", "Tue"
+ dateText: string; // e.g., "Jan 10"
+ timeText: string; // e.g., "2 hrs 30 mins"
+ barWidthPercent: string; // e.g., "75%"
+}
+
+export async function fetchWeeklyStats(): Promise<WeeklyStats | null> {
+ try {
+ const start = new Date();
+ start.setDate(start.getDate() - 6); // 7 days including today
+ const end = new Date();
+
+ const formatDate = (d: Date) => d.toISOString().split("T")[0];
+
+ const response = await fetch(
+ `${WAKATIME_API_BASE}/users/current/summaries?start=${formatDate(start)}&end=${formatDate(end)}`,
+ {
+ headers: {
+ Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
+ },
+ },
+ );
+
+ if (!response.ok) {
+ console.error("WakaTime weekly stats error:", response.status);
+ return null;
+ }
+
+ const { data } = await response.json();
+
+ let totalSeconds = 0;
+ let maxMinutes = 0;
+
+ const minutesPerDay: number[] = [];
+ const days: DailyEntry[] = (data || []).map(
+ (
+ {
+ range,
+ grand_total,
+ }: {
+ range: { date: string };
+ grand_total: { total_seconds?: number };
+ },
+ i: number,
+ ) => {
+ const date = new Date(range.date);
+ const dayText = DAY_NAMES[date.getUTCDay()].slice(0, 3);
+ const dateText = `${MONTH_NAMES[date.getUTCMonth()].slice(0, 3)} ${date.getUTCDate()}`;
+
+ const seconds = grand_total?.total_seconds || 0;
+ totalSeconds += seconds;
+
+ const totalMinutes = Math.floor(seconds / 60);
+ minutesPerDay[i] = totalMinutes;
+ if (totalMinutes > maxMinutes) maxMinutes = totalMinutes;
+
+ const timeText = formatDuration(seconds);
+
+ return { dayText, dateText, timeText, barWidthPercent: "0%" };
+ },
+ );
+
+ const effectiveMax = maxMinutes || 1;
+ for (let i = 0; i < days.length; i++) {
+ days[i].barWidthPercent =
+ `${Math.round((minutesPerDay[i] / effectiveMax) * 100)}%`;
+ }
+
+ const totalText = formatDuration(totalSeconds);
+
+ return { totalText, days };
+ } catch (error) {
+ console.error("Failed to fetch weekly stats:", error);
+ return null;
+ }
+}
diff --git a/web/src/layouts/Layout.astro b/web/src/layouts/Layout.astro
@@ -1,7 +1,8 @@
---
-import Lightbox from "../components/Lightbox.astro";
-import about from "../../config/about.json";
+import Lightbox from "./components/Lightbox.astro";
+
import { URL } from "../constants/website";
+import { about } from "../config";
interface Props {
title: string;
@@ -396,7 +397,7 @@ const ogImage = image
gap: 8px;
}
- .boot-name {
+ .boot-title {
font-family: "JetBrains Mono", "SF Mono", monospace;
font-size: 32px;
font-weight: 600;
@@ -404,7 +405,7 @@ const ogImage = image
letter-spacing: -0.5px;
}
- .boot-title {
+ .boot-subtitle {
font-family: "JetBrains Mono", "SF Mono", monospace;
font-size: 14px;
color: var(--fg-dim);
diff --git a/web/src/layouts/components/Article.astro b/web/src/layouts/components/Article.astro
@@ -0,0 +1,387 @@
+---
+import type { Blog } from "../../blogs";
+
+interface Props {
+ blog: Blog;
+}
+
+const { blog } = Astro.props;
+---
+
+<article class="blog-article" data-id={blog.id} data-blog-id={blog.id}>
+ <header class="blog-article-header">
+ <div class="blog-article-header-top">
+ <h1 class="blog-article-title">{blog.title}</h1>
+ <button
+ class="blog-share-btn blog-share-btn-desktop"
+ data-blog-id={blog.id}
+ data-blog-title={blog.title}
+ >
+ [Share]
+ </button>
+ </div>
+ <div class="blog-article-meta">
+ <time class="blog-article-date" datetime={blog.date}>{blog.date}</time>
+ <button
+ class="blog-share-btn blog-share-btn-mobile"
+ data-blog-id={blog.id}
+ data-blog-title={blog.title}
+ >
+ [Share]
+ </button>
+ </div>
+ </header>
+ <div class="blog-article-content" data-blog-id={blog.id}>
+ {
+ blog.url ? (
+ <a
+ class="external-link"
+ href={blog.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Read on external site →
+ </a>
+ ) : blog.content ? (
+ <Fragment set:html={blog.content} />
+ ) : (
+ <p class="color-dim">Content not available</p>
+ )
+ }
+ </div>
+</article>
+
+<style>
+ .blog-article {
+ animation: fadeIn 0.2s ease;
+ }
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+
+ .blog-article-header {
+ margin-bottom: 24px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .blog-article-header-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ }
+
+ .blog-article-title {
+ font-size: 28px;
+ font-weight: 500;
+ color: var(--accent);
+ margin: 0 0 8px 0;
+ line-height: 1.3;
+ }
+
+ .blog-share-btn {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--fg-dim);
+ padding: 6px 12px;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 12px;
+ transition: all 0.15s;
+ flex-shrink: 0;
+ }
+
+ .blog-share-btn:hover {
+ background: var(--bg-alt);
+ color: var(--accent);
+ border-color: var(--accent);
+ }
+
+ .blog-share-btn.copied {
+ color: var(--green);
+ border-color: var(--green);
+ }
+
+ .blog-article-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .blog-article-date {
+ color: var(--fg-dim);
+ font-size: 13px;
+ }
+
+ .blog-share-btn-mobile {
+ display: none;
+ }
+
+ .blog-article-content {
+ font-size: 14px;
+ line-height: 1.7;
+ color: var(--fg);
+ padding-bottom: 32px;
+ }
+
+ .blog-article-content :global(p) {
+ margin-bottom: 16px;
+ }
+
+ .blog-article-content :global(p:last-child) {
+ margin-bottom: 0;
+ }
+
+ .blog-article-content :global(h2) {
+ font-size: 20px;
+ color: var(--accent);
+ margin: 32px 0 12px;
+ font-weight: 600;
+ }
+
+ .blog-article-content :global(h2:first-child) {
+ margin-top: 0;
+ }
+
+ .blog-article-content :global(h3) {
+ font-size: 17px;
+ color: var(--accent);
+ margin: 24px 0 10px;
+ font-weight: 500;
+ }
+
+ .blog-article-content :global(h4) {
+ font-size: 15px;
+ color: var(--fg);
+ margin: 20px 0 8px;
+ font-weight: 500;
+ }
+
+ .blog-article-content :global(h5),
+ .blog-article-content :global(h6) {
+ font-size: 14px;
+ color: var(--fg-dim);
+ margin: 16px 0 8px;
+ font-weight: 500;
+ }
+
+ .blog-article-content :global(ul),
+ .blog-article-content :global(ol) {
+ margin: 12px 0;
+ padding-left: 24px;
+ }
+
+ .blog-article-content :global(li) {
+ margin-bottom: 8px;
+ }
+
+ .blog-article-content :global(li > ul),
+ .blog-article-content :global(li > ol) {
+ margin: 6px 0;
+ }
+
+ .blog-article-content :global(code) {
+ background: var(--bg-alt);
+ padding: 2px 6px;
+ font-size: 13px;
+ border-radius: 2px;
+ }
+
+ .blog-article-content :global(pre) {
+ display: block;
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ padding: 16px;
+ margin: 16px 0;
+ overflow-x: auto;
+ font-size: 13px;
+ line-height: 1.5;
+ }
+
+ .blog-article-content :global(pre code) {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+ }
+
+ .blog-article-content :global(a) {
+ color: var(--light-blue);
+ text-decoration: none;
+ }
+
+ .blog-article-content :global(a:hover) {
+ opacity: 0.8;
+ }
+
+ .blog-article-content :global(strong) {
+ font-weight: 600;
+ color: var(--fg);
+ }
+
+ .blog-article-content :global(em) {
+ font-style: italic;
+ color: var(--fg-dim);
+ }
+
+ .blog-article-content :global(del) {
+ text-decoration: line-through;
+ color: var(--fg-dim);
+ }
+
+ .blog-article-content :global(blockquote) {
+ border-left: 3px solid var(--accent);
+ margin: 16px 0;
+ padding: 12px 20px;
+ color: var(--fg-dim);
+ background: var(--bg-alt);
+ }
+
+ .blog-article-content :global(blockquote p) {
+ margin-bottom: 0;
+ }
+
+ .blog-article-content :global(hr) {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 24px 0;
+ }
+
+ .blog-article-content :global(table) {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 16px 0;
+ font-size: 13px;
+ }
+
+ .blog-article-content :global(th),
+ .blog-article-content :global(td) {
+ border: 1px solid var(--border);
+ padding: 10px 14px;
+ text-align: left;
+ }
+
+ .blog-article-content :global(th) {
+ background: var(--bg-alt);
+ color: var(--accent);
+ font-weight: 500;
+ }
+
+ .blog-article-content :global(tr:nth-child(even)) {
+ background: rgba(255, 255, 255, 0.02);
+ }
+
+ .blog-article-content :global(img) {
+ max-width: 100%;
+ height: auto;
+ margin: 16px 0;
+ }
+
+ .external-link {
+ color: var(--light-blue);
+ text-decoration: none;
+ font-size: 14px;
+ }
+
+ .external-link:hover {
+ opacity: 0.8;
+ }
+
+ @media (max-width: 768px) {
+ .blog-article-header {
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ }
+
+ .blog-article-title {
+ font-size: 22px;
+ }
+
+ .blog-share-btn-desktop {
+ display: none;
+ }
+
+ .blog-share-btn-mobile {
+ display: block;
+ }
+
+ .blog-article-content {
+ font-size: 13px;
+ }
+
+ .blog-article-content :global(h2) {
+ font-size: 18px;
+ margin: 24px 0 10px;
+ }
+
+ .blog-article-content :global(h3) {
+ font-size: 15px;
+ margin: 20px 0 8px;
+ }
+
+ .blog-article-content :global(pre) {
+ padding: 12px;
+ font-size: 12px;
+ }
+
+ .blog-article-content :global(code) {
+ font-size: 12px;
+ }
+
+ .blog-article-content :global(table) {
+ font-size: 12px;
+ }
+
+ .blog-article-content :global(th),
+ .blog-article-content :global(td) {
+ padding: 8px 10px;
+ }
+ }
+</style>
+
+<script>
+ document.addEventListener("DOMContentLoaded", () => {
+ const shareButtons = document.querySelectorAll(".blog-share-btn");
+
+ shareButtons.forEach((btn) => {
+ btn.addEventListener("click", async (e) => {
+ e.stopPropagation();
+ const button = e.currentTarget as HTMLButtonElement;
+ const blogId = button.dataset.blogId;
+ const title = button.dataset.blogTitle;
+ const url = `${window.location.origin}/blog/${blogId}`;
+
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ title,
+ url,
+ });
+ } catch {
+ // User cancelled share dialog
+ }
+ } else {
+ copyToClipboard(url, button);
+ }
+ });
+ });
+
+ function copyToClipboard(text: string, btn: HTMLButtonElement) {
+ navigator.clipboard.writeText(text).then(() => {
+ const originalText = btn.textContent;
+ btn.textContent = "[Copied!]";
+ btn.classList.add("copied");
+ setTimeout(() => {
+ btn.textContent = originalText;
+ btn.classList.remove("copied");
+ }, 2000);
+ });
+ }
+ });
+</script>
diff --git a/web/src/layouts/components/Footer.astro b/web/src/layouts/components/Footer.astro
@@ -0,0 +1,46 @@
+---
+import type { WakaTimeData } from "../../external/wakatime";
+
+interface Props {
+ wakaTimeData: WakaTimeData | null;
+}
+
+const { wakaTimeData } = Astro.props;
+---
+
+<footer id="statusbar">
+ <span class="time" id="status-time"></span>
+ <span class="spacer"></span>
+ {
+ wakaTimeData?.weeklyStats && (
+ <span class="wakatime">{wakaTimeData.allTimeStats?.totalText}</span>
+ )
+ }
+</footer>
+
+<style>
+ #statusbar {
+ display: flex;
+ align-items: center;
+ background: var(--bg-alt);
+ border-top: 1px solid var(--border);
+ padding: 0 12px;
+ height: 24px;
+ font-size: 12px;
+ position: sticky;
+ bottom: 0;
+ z-index: 100;
+ }
+
+ #statusbar .time {
+ color: var(--cyan);
+ }
+
+ #statusbar .spacer {
+ flex: 1;
+ }
+
+ #statusbar .wakatime {
+ color: var(--magenta);
+ }
+</style>
diff --git a/web/src/layouts/components/Header.astro b/web/src/layouts/components/Header.astro
@@ -0,0 +1,153 @@
+---
+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/components/Lightbox.astro b/web/src/layouts/components/Lightbox.astro
@@ -0,0 +1,135 @@
+<div id="image-lightbox" class="lightbox hidden">
+ <div class="lightbox-overlay"></div>
+ <div class="lightbox-content">
+ <img src="" alt="" class="lightbox-image" />
+ <button class="lightbox-close">[X]</button>
+ </div>
+</div>
+
+<style>
+ .lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .lightbox.hidden {
+ display: none;
+ }
+
+ .lightbox-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.9);
+ cursor: pointer;
+ }
+
+ .lightbox-content {
+ position: relative;
+ max-width: 90vw;
+ max-height: 90vh;
+ z-index: 1;
+ }
+
+ .lightbox-image {
+ max-width: 100%;
+ max-height: 90vh;
+ border-radius: 4px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ }
+
+ .lightbox-close {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: rgba(0, 0, 0, 0.7);
+ border: 1px solid var(--border);
+ color: var(--fg);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+ cursor: pointer;
+ padding: 4px 8px;
+ line-height: 1;
+ transition:
+ color 0.15s,
+ background 0.15s;
+ z-index: 10;
+ }
+
+ .lightbox-close:hover {
+ color: var(--accent);
+ background: rgba(0, 0, 0, 0.9);
+ }
+</style>
+
+<script>
+ document.addEventListener("DOMContentLoaded", () => {
+ const lightbox = document.getElementById("image-lightbox");
+ const lightboxImage = lightbox?.querySelector(
+ ".lightbox-image",
+ ) as HTMLImageElement;
+ const lightboxOverlay = lightbox?.querySelector(".lightbox-overlay");
+ const lightboxClose = lightbox?.querySelector(".lightbox-close");
+
+ let lightboxImages: string[] = [];
+ let currentLightboxIndex = 0;
+
+ (window as Window & { isLightboxOpen?: () => boolean }).isLightboxOpen =
+ () => !lightbox?.classList.contains("hidden");
+
+ (
+ window as Window & {
+ openLightbox?: (images: string[], startIndex?: number) => void;
+ }
+ ).openLightbox = (images: string[], startIndex: number = 0) => {
+ if (lightbox && lightboxImage && images.length > 0) {
+ lightboxImages = images;
+ currentLightboxIndex = startIndex;
+ lightboxImage.src = images[startIndex];
+ lightbox.classList.remove("hidden");
+ }
+ };
+
+ function closeLightbox() {
+ lightbox?.classList.add("hidden");
+ lightboxImages = [];
+ currentLightboxIndex = 0;
+ }
+
+ function navigateLightboxImage(direction: number) {
+ if (lightboxImages.length <= 1) return;
+ currentLightboxIndex =
+ (currentLightboxIndex + direction + lightboxImages.length) %
+ lightboxImages.length;
+ if (lightboxImage) {
+ lightboxImage.src = lightboxImages[currentLightboxIndex];
+ }
+ }
+
+ (
+ window as Window & { navigateLightboxImage?: (direction: number) => void }
+ ).navigateLightboxImage = navigateLightboxImage;
+
+ lightboxOverlay?.addEventListener("click", closeLightbox);
+ lightboxClose?.addEventListener("click", closeLightbox);
+
+ document.addEventListener("keydown", (e) => {
+ if (!lightbox?.classList.contains("hidden")) {
+ if (e.key === "Escape") {
+ closeLightbox();
+ } else if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ e.stopPropagation();
+ navigateLightboxImage(-1);
+ } else if (e.key === "ArrowRight") {
+ e.preventDefault();
+ e.stopPropagation();
+ navigateLightboxImage(1);
+ }
+ }
+ });
+ });
+</script>
diff --git a/web/src/layouts/tabs/About.astro b/web/src/layouts/tabs/About.astro
@@ -0,0 +1,386 @@
+---
+import type { WakaTimeData } from "../../external/wakatime";
+import { about, contact } from "../../config";
+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-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>
+ <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-row {
+ display: flex;
+ font-size: 13px;
+ margin-bottom: 4px;
+ }
+
+ .info-row .label {
+ color: var(--fg-dim);
+ width: 100px;
+ flex-shrink: 0;
+ }
+
+ .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;
+ }
+
+ .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
@@ -0,0 +1,232 @@
+---
+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
@@ -0,0 +1,484 @@
+---
+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
@@ -0,0 +1,413 @@
+---
+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/lib/blogs.ts b/web/src/lib/blogs.ts
@@ -1,52 +0,0 @@
-import matter from "gray-matter";
-import { parseMarkdown } from "./readme";
-import type BlogPost from "./types/blog";
-
-const blogModules = import.meta.glob<string>("../../blogs/*.md", {
- eager: true,
- query: "?raw",
- import: "default",
-});
-
-const blogs = Object.values(blogModules)
- .map((raw) => {
- const { data, content } = matter(raw);
-
- const blog: Partial<BlogPost> = {
- 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(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 BlogPost;
- })
- .filter((blog) => blog.id && blog.title)
- .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
-
-export default blogs satisfies BlogPost[];
diff --git a/web/src/lib/git.ts b/web/src/lib/git.ts
@@ -1,63 +0,0 @@
-import type Project from "./types/project";
-import type { GitMetadata } from "./types/git";
-
-export async function fetchGitMetadataForProject(
- project: Project,
-): Promise<GitMetadata | null> {
- try {
- const response = await fetch(project.apiUrl, {
- headers: { "User-Agent": "prime8dev-portfolio" },
- });
-
- if (!response.ok) return null;
-
- const data = await response.json();
-
- const baseUrl = `${data.web_url.replace("http://", "https://")}/-/raw/${data.default_branch}`;
-
- return {
- readme: await fetchReadme(baseUrl, toRawUrl(data.readme_url)),
- dateCreated: data.created_at,
- dateModified: data.updated_at,
- };
- } catch {
- return null;
- }
-}
-
-function toRawUrl(blobUrl: string): string {
- return blobUrl.replace("http://", "https://").replace("/-/blob/", "/-/raw/");
-}
-
-async function fetchReadme(
- baseUrl: string,
- url: string,
-): Promise<string | null> {
- try {
- const response = await fetch(url, {
- headers: { "User-Agent": "prime8dev-portfolio" },
- });
- if (!response.ok) return null;
-
- const content = await response.text();
- return fixRelativePaths(baseUrl, content);
- } catch {
- return null;
- }
-}
-
-function fixRelativePaths(baseUrl: string, markdown: string): string {
- // Fix relative image paths:  -> 
- let fixed = markdown.replace(
- /!\[([^\]]*)\]\(\.\/([^)]+)\)/g,
- ``,
- );
-
- // Fix relative link paths (not images): [text](./path) -> [text](baseUrl/path)
- fixed = fixed.replace(
- /(?<!!)\[([^\]]+)\]\(\.\/([^)]+)\)/g,
- `[$1](${baseUrl}/$2)`,
- );
-
- return fixed;
-}
diff --git a/web/src/lib/readme.ts b/web/src/lib/readme.ts
@@ -1,149 +0,0 @@
-import { marked } from "marked";
-import type { Tokens, RendererThis } from "marked";
-
-export function parseMarkdown(markdown: string): string {
- return marked.parse(removeFirstH1(markdown)) as string;
-}
-
-function removeFirstH1(markdown: string): string {
- return markdown.replace(/^#\s+.+\n+/, "");
-}
-
-const renderer = {
- heading(this: RendererThis, token: Tokens.Heading): string {
- const level = token.depth;
- const tag = `h${level + 1}`;
- const text = this.parser.parseInline(token.tokens);
- return `<${tag} class="readme-h${level}">${text}</${tag}>`;
- },
-
- code(token: Tokens.Code): string {
- const escaped = token.text.replace(/</g, "<").replace(/>/g, ">");
- return `<pre class="readme-code"><code>${escaped}</code></pre>`;
- },
-
- codespan(token: Tokens.Codespan): string {
- const escaped = token.text.replace(/</g, "<").replace(/>/g, ">");
- return `<code class="readme-inline-code">${escaped}</code>`;
- },
-
- link(this: RendererThis, token: Tokens.Link): string {
- const text = this.parser.parseInline(token.tokens);
- return `<a href="${token.href}" target="_blank" rel="noopener noreferrer" class="readme-link">${text}</a>`;
- },
-
- image(token: Tokens.Image): string {
- return `<img src="${token.href}" alt="${token.text || ""}" class="readme-img" loading="lazy" />`;
- },
-
- list(this: RendererThis, token: Tokens.List): string {
- function renderList(
- parser: RendererThis["parser"],
- token: Tokens.List,
- ): string {
- const tag = token.ordered ? "ol" : "ul";
- const items = token.items
- .map((item) => {
- let content = "";
- for (const tok of item.tokens) {
- if (tok.type === "paragraph" && "tokens" in tok && tok.tokens) {
- content += parser.parseInline(tok.tokens as Tokens.Generic[]);
- } else if (tok.type === "text") {
- const textToken = tok as Tokens.Text;
- if (textToken.tokens && textToken.tokens.length > 0) {
- content += parser.parseInline(textToken.tokens);
- } else {
- const inlineTokens = marked.lexer(
- textToken.raw || textToken.text,
- { gfm: true },
- );
- if (
- inlineTokens.length > 0 &&
- inlineTokens[0].type === "paragraph" &&
- "tokens" in inlineTokens[0]
- ) {
- content += parser.parseInline(
- (inlineTokens[0] as Tokens.Paragraph).tokens,
- );
- } else {
- content += textToken.text || "";
- }
- }
- } else if (tok.type === "list") {
- content += renderList(parser, tok as Tokens.List);
- } else {
- content += parser.parse([tok as Tokens.Generic]);
- }
- }
- return `<li>${content}</li>`;
- })
- .join("");
- return `<${tag} class="readme-list">${items}</${tag}>`;
- }
-
- return renderList(this.parser, token);
- },
-
- paragraph(this: RendererThis, token: Tokens.Paragraph): string {
- const text = this.parser.parseInline(token.tokens);
- return `<p class="readme-p">${text}</p>`;
- },
-
- table(this: RendererThis, token: Tokens.Table): string {
- const headerCells = token.header
- .map((h) => {
- const text = this.parser.parseInline(h.tokens as Tokens.Generic[]);
- return `<th>${text}</th>`;
- })
- .join("");
- const bodyRows = token.rows
- .map((row) => {
- const cells = row
- .map((cell) => {
- const text = this.parser.parseInline(
- cell.tokens as Tokens.Generic[],
- );
- return `<td>${text}</td>`;
- })
- .join("");
- return `<tr>${cells}</tr>`;
- })
- .join("");
- return `<table class="readme-table"><thead><tr>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
- },
-
- blockquote(this: RendererThis, token: Tokens.Blockquote): string {
- const text = this.parser.parse(token.tokens);
- return `<blockquote class="readme-blockquote">${text}</blockquote>`;
- },
-
- hr(): string {
- return "<hr />";
- },
-
- strong(this: RendererThis, token: Tokens.Strong): string {
- const text = this.parser.parseInline(token.tokens);
- return `<strong>${text}</strong>`;
- },
-
- em(this: RendererThis, token: Tokens.Em): string {
- const text = this.parser.parseInline(token.tokens);
- return `<em>${text}</em>`;
- },
-
- del(this: RendererThis, token: Tokens.Del): string {
- const text = this.parser.parseInline(token.tokens);
- return `<del>${text}</del>`;
- },
-
- text(token: Tokens.Text | Tokens.Escape): string {
- return token.text;
- },
-};
-
-marked.use({ renderer });
-
-marked.setOptions({
- gfm: true,
- breaks: true,
-});
diff --git a/web/src/lib/schemas/blog.ts b/web/src/lib/schemas/blog.ts
@@ -1,61 +0,0 @@
-import type Blog from "../types/blog";
-import personSchema from "./person";
-import { URL } from "../../constants/website";
-
-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: `${URL}/blog/${blog.id}`,
- author: personSchema,
- publisher: personSchema,
- mainEntityOfPage: {
- "@type": "WebPage",
- "@id": `${URL}/blog/${blog.id}`,
- },
- articleSection: "Technology",
- };
-
- if (blog.image) {
- schema.image = blog.image.startsWith("http")
- ? blog.image
- : `${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: URL,
- },
- {
- "@type": "ListItem",
- position: 2,
- name: "Blog",
- item: `${URL}/#blog`,
- },
- {
- "@type": "ListItem",
- position: 3,
- name: blog.title,
- item: `${URL}/blog/${blog.id}`,
- },
- ],
- };
-}
diff --git a/web/src/lib/schemas/experience.ts b/web/src/lib/schemas/experience.ts
@@ -1,54 +0,0 @@
-import type Experience from "../types/experience";
-import personSchema from "./person";
-import { URL } from "../../constants/website";
-
-export function getSchemaForExperience(experience: Experience) {
- return {
- "@context": "https://schema.org",
- "@type": "Person",
- name: personSchema.name,
- url: personSchema.url,
- worksFor: {
- "@type": "Organization",
- name: experience.company,
- url: experience.url,
- },
- hasOccupation: {
- "@type": "Occupation",
- name: experience.role,
- startDate: experience.startDate,
- ...(experience.endDate && { endDate: experience.endDate }),
- occupationLocation: {
- "@type": "City",
- name: "Remote",
- },
- },
- };
-}
-
-export function getBreadcrumbSchema(experience: Experience) {
- return {
- "@context": "https://schema.org",
- "@type": "BreadcrumbList",
- itemListElement: [
- {
- "@type": "ListItem",
- position: 1,
- name: "Home",
- item: URL,
- },
- {
- "@type": "ListItem",
- position: 2,
- name: "Experience",
- item: `${URL}/#experience`,
- },
- {
- "@type": "ListItem",
- position: 3,
- name: experience.company,
- item: `${URL}/experience/${experience.id}`,
- },
- ],
- };
-}
diff --git a/web/src/lib/schemas/person.ts b/web/src/lib/schemas/person.ts
@@ -1,24 +0,0 @@
-import about from "../../../config/about.json";
-import currentJob from "../utils/job";
-import contact from "../../../config/contact.json";
-import { URL } from "../../constants/website";
-
-export default {
- "@context": "https://schema.org",
- "@type": "Person",
- name: about.name,
- alternateName: about.handle,
- email: contact.email,
- sameAs: [contact.gitlab, contact.linkedin],
- url: URL,
- image: `${URL}/me.jpg`,
- ...(currentJob && {
- jobTitle: currentJob.role,
- worksFor: {
- "@type": "Organization",
- name: currentJob.company,
- url: currentJob.url,
- },
- }),
- knowsAbout: [...about.interests],
-};
diff --git a/web/src/lib/schemas/project.ts b/web/src/lib/schemas/project.ts
@@ -1,46 +0,0 @@
-import type Project from "../types/project";
-import type { GitMetadata } from "../types/git";
-import personSchema from "./person";
-import { URL } from "../../constants/website";
-
-export function getSchemaForProject(project: Project, metadata?: GitMetadata) {
- return {
- "@context": "https://schema.org",
- "@type": "SoftwareSourceCode",
- name: project.name,
- description: project.subtitle,
- url: `${URL}/projects/${project.id}`,
- codeRepository: project.url,
- programmingLanguage: project.tech,
- author: personSchema,
- ...(metadata?.dateCreated && { dateCreated: metadata.dateCreated }),
- ...(metadata?.dateModified && { dateModified: metadata.dateModified }),
- };
-}
-
-export function getBreadcrumbSchema(project: Project) {
- return {
- "@context": "https://schema.org",
- "@type": "BreadcrumbList",
- itemListElement: [
- {
- "@type": "ListItem",
- position: 1,
- name: "Home",
- item: URL,
- },
- {
- "@type": "ListItem",
- position: 2,
- name: "Projects",
- item: `${URL}/#projects`,
- },
- {
- "@type": "ListItem",
- position: 3,
- name: project.name,
- item: `${URL}/projects/${project.id}`,
- },
- ],
- };
-}
diff --git a/web/src/lib/schemas/website.ts b/web/src/lib/schemas/website.ts
@@ -1,13 +0,0 @@
-import about from "../../../config/about.json";
-import { URL } from "../../constants/website";
-import currentJob from "../utils/job";
-import personSchema from "./person";
-
-export default {
- "@context": "https://schema.org",
- "@type": "WebSite",
- name: about.name,
- url: URL,
- description: `Portfolio and blog of ${about.name}, a ${about.profession.toLowerCase()}${currentJob?.company ? ` at ${currentJob.company}` : ""}. ${about.bio}.`,
- author: personSchema,
-};
diff --git a/web/src/lib/types/about.ts b/web/src/lib/types/about.ts
@@ -1,11 +0,0 @@
-export default interface About {
- name: string;
- handle: string;
- bio: string;
- location: string;
- school?: string;
- graduating?: string;
- languages: string[];
- interests: string[];
- tools: string[];
-}
diff --git a/web/src/lib/types/blog.ts b/web/src/lib/types/blog.ts
@@ -1,11 +0,0 @@
-export default interface Blog {
- id: string;
- title: string;
- description: string;
- date: string;
- modifiedDate?: string;
- content?: string;
- url?: string;
- image?: string;
- tags?: string[];
-}
diff --git a/web/src/lib/types/contact.ts b/web/src/lib/types/contact.ts
@@ -1,5 +0,0 @@
-export default interface Contact {
- email: string;
- gitlab: string;
- linkedin: string;
-}
diff --git a/web/src/lib/types/experience.ts b/web/src/lib/types/experience.ts
@@ -1,13 +0,0 @@
-export default interface Experience {
- id: string;
- company: string;
- role: string;
- startDate: string;
- endDate?: string;
- description: string[];
- tech: string[];
- url: string;
- logo: string;
- logoFilter?: string;
- images?: string[];
-}
diff --git a/web/src/lib/types/git.ts b/web/src/lib/types/git.ts
@@ -1,9 +0,0 @@
-import type Project from "./project";
-
-export interface GitMetadata {
- readme: string | null;
- dateCreated: Date;
- dateModified: Date;
-}
-
-export type GitMetadataMap = Partial<Record<Project["id"], GitMetadata>>;
diff --git a/web/src/lib/types/project.ts b/web/src/lib/types/project.ts
@@ -1,8 +0,0 @@
-export default interface Project {
- id: string;
- name: string;
- subtitle: string;
- tech: string[];
- url: string;
- apiUrl: string;
-}
diff --git a/web/src/lib/types/wakatime.ts b/web/src/lib/types/wakatime.ts
@@ -1,21 +0,0 @@
-export interface WakaTimeData {
- allTimeStats: AllTimeStats | null;
- weeklyStats: WeeklyStats | null;
-}
-
-export interface AllTimeStats {
- totalText: string;
- dailyAverageText: string;
-}
-
-export interface WeeklyStats {
- totalText: string;
- days: DailyEntry[];
-}
-
-export interface DailyEntry {
- dayText: string; // e.g., "Mon", "Tue"
- dateText: string; // e.g., "Jan 10"
- timeText: string; // e.g., "2 hrs 30 mins"
- barWidthPercent: string; // e.g., "75%"
-}
diff --git a/web/src/lib/utils/job.ts b/web/src/lib/utils/job.ts
@@ -1,8 +0,0 @@
-import rawExperiences from "../../../config/experience.json";
-import type Experience from "../types/experience";
-
-const experiences = rawExperiences as Experience[];
-
-const currentJob = experiences.find((exp) => !exp.endDate) || null;
-
-export default currentJob;
diff --git a/web/src/lib/utils/period.ts b/web/src/lib/utils/period.ts
@@ -1,37 +0,0 @@
-export function formatPeriod(startDate: string, endDate?: string): string {
- const months = [
- "Jan",
- "Feb",
- "Mar",
- "Apr",
- "May",
- "Jun",
- "Jul",
- "Aug",
- "Sep",
- "Oct",
- "Nov",
- "Dec",
- ];
- const [startYear, startMonth] = startDate.split("-").map(Number);
- const startStr = `${months[startMonth - 1]} ${startYear}`;
-
- if (!endDate) {
- const totalMonths = calculateMonths(startDate);
- return `${startStr} – Present (${totalMonths} mos)`;
- }
-
- const [endYear, endMonth] = endDate.split("-").map(Number);
- const endStr = `${months[endMonth - 1]} ${endYear}`;
- const totalMonths = calculateMonths(startDate, endDate);
- return `${startStr} – ${endStr} (${totalMonths} mos)`;
-}
-
-export function calculateMonths(startDate: string, endDate?: string): number {
- const [startYear, startMonth] = startDate.split("-").map(Number);
- const end = endDate
- ? endDate.split("-").map(Number)
- : [new Date().getFullYear(), new Date().getMonth() + 1];
- const [endYear, endMonth] = end;
- return (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
-}
diff --git a/web/src/lib/utils/tags.ts b/web/src/lib/utils/tags.ts
@@ -1,12 +0,0 @@
-import { TAG_CLASS_MAP, TECH_ABBREVIATIONS } from "../../constants/tags";
-
-export function getTagClass(tag: string): string {
- return TAG_CLASS_MAP[tag] || "";
-}
-
-export function getTechMeta(tech: string[]): string {
- return tech
- .slice(0, 2)
- .map((t) => TECH_ABBREVIATIONS[t] || t)
- .join(", ");
-}
diff --git a/web/src/lib/wakatime.ts b/web/src/lib/wakatime.ts
@@ -1,140 +0,0 @@
-import type {
- AllTimeStats,
- DailyEntry,
- WakaTimeData,
- WeeklyStats,
-} from "./types/wakatime";
-import { DAY_NAMES, MONTH_NAMES } from "../constants/calendar";
-import { WAKATIME_API_BASE, WAKATIME_API_KEY } from "../constants/wakatime";
-import { formatDuration } from "./utils/duration";
-
-export async function fetchWakaTimeData(): Promise<WakaTimeData | null> {
- if (!WAKATIME_API_KEY) {
- return null;
- }
-
- try {
- const [allTimeStats, weeklyStats] = await Promise.all([
- fetchAllTimeStats(),
- fetchWeeklyStats(),
- ]);
-
- return { allTimeStats, weeklyStats };
- } catch (error) {
- console.error("Failed to fetch WakaTime data:", error);
- return null;
- }
-}
-
-export async function fetchAllTimeStats(): Promise<AllTimeStats | null> {
- try {
- const [statsResponse, languagesResponse] = [
- await fetch(`${WAKATIME_API_BASE}/users/current/all_time_since_today`, {
- headers: {
- Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
- },
- }),
- await fetch(`${WAKATIME_API_BASE}/users/current/stats/last_year`, {
- headers: {
- Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
- },
- }),
- ];
- if ([statsResponse, languagesResponse].some((r) => !r.ok)) {
- return null;
- }
-
- const [statsData, langData] = await Promise.all(
- [statsResponse, languagesResponse].map((r) => r.json()),
- );
-
- let dailyAverageText = "1 hr";
- if (langData.data?.daily_average_including_other_language) {
- const avgSeconds = langData.data.daily_average_including_other_language;
- dailyAverageText = formatDuration(avgSeconds);
- }
-
- const totalSeconds = statsData.data?.total_seconds || 0;
- const totalHours = Math.floor(totalSeconds / 3600);
- const totalText = `${totalHours.toLocaleString()}+ hrs coded`;
-
- return {
- totalText,
- dailyAverageText,
- };
- } catch (error) {
- console.error("Failed to fetch WakaTime stats:", error);
- return null;
- }
-}
-
-export async function fetchWeeklyStats(): Promise<WeeklyStats | null> {
- try {
- const start = new Date();
- start.setDate(start.getDate() - 6); // 7 days including today
- const end = new Date();
-
- const formatDate = (d: Date) => d.toISOString().split("T")[0];
-
- const response = await fetch(
- `${WAKATIME_API_BASE}/users/current/summaries?start=${formatDate(start)}&end=${formatDate(end)}`,
- {
- headers: {
- Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
- },
- },
- );
-
- if (!response.ok) {
- console.error("WakaTime weekly stats error:", response.status);
- return null;
- }
-
- const { data } = await response.json();
-
- let totalSeconds = 0;
- let maxMinutes = 0;
-
- const minutesPerDay: number[] = [];
- const days: DailyEntry[] = (data || []).map(
- (
- {
- range,
- grand_total,
- }: {
- range: { date: string };
- grand_total: { total_seconds?: number };
- },
- i: number,
- ) => {
- const date = new Date(range.date);
- const dayText = DAY_NAMES[date.getUTCDay()].slice(0, 3);
- const dateText = `${MONTH_NAMES[date.getUTCMonth()].slice(0, 3)} ${date.getUTCDate()}`;
-
- const seconds = grand_total?.total_seconds || 0;
- totalSeconds += seconds;
-
- const totalMinutes = Math.floor(seconds / 60);
- minutesPerDay[i] = totalMinutes;
- if (totalMinutes > maxMinutes) maxMinutes = totalMinutes;
-
- const timeText = formatDuration(seconds);
-
- return { dayText, dateText, timeText, barWidthPercent: "0%" };
- },
- );
-
- const effectiveMax = maxMinutes || 1;
- for (let i = 0; i < days.length; i++) {
- days[i].barWidthPercent =
- `${Math.round((minutesPerDay[i] / effectiveMax) * 100)}%`;
- }
-
- const totalText = formatDuration(totalSeconds);
-
- return { totalText, days };
- } catch (error) {
- console.error("Failed to fetch weekly stats:", error);
- return null;
- }
-}
diff --git a/web/src/pages/404.astro b/web/src/pages/404.astro
@@ -1,6 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
-import about from "../../config/about.json";
+
+import { about } from "../config";
import { URL } from "../constants/website";
---
diff --git a/web/src/pages/blog/[slug].astro b/web/src/pages/blog/[slug].astro
@@ -1,8 +1,9 @@
---
import Layout from "../../layouts/Layout.astro";
-import BlogArticle from "../../components/Article.astro";
-import blogs from "../../lib/blogs";
-import { getSchemaForBlog, getBreadcrumbSchema } from "../../lib/schemas/blog";
+import Article from "../../layouts/components/Article.astro";
+
+import { blogs } from "../../blogs";
+import schemas from "../../schemas/index";
const blog = blogs.find((blog) => blog.id === Astro.params.slug);
if (!blog) {
@@ -13,6 +14,8 @@ if (!blog) {
}
const keywords = blog.tags?.join(", ");
+
+const { getSchemaForBlog, getBreadcrumbSchema } = schemas.blog;
---
<Layout
@@ -28,7 +31,7 @@ const keywords = blog.tags?.join(", ");
<main id="content">
<div class="blog-page">
<a href="/#blog" class="blog-back-btn">← See more posts</a>
- <BlogArticle blog={blog} />
+ <Article blog={blog} />
</div>
<div id="blog-data" data-blog-id={blog.id}></div>
<script
@@ -97,10 +100,18 @@ const keywords = blog.tags?.join(", ");
</style>
<script>
- import { setCurrentBlogId } from "../../scripts/ui/state";
+ 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" });
+ });
- document.addEventListener("DOMContentLoaded", () => {
- const blogId = document.getElementById("blog-data")?.dataset.blogId;
- if (blogId) setCurrentBlogId(blogId);
+ onKey("ArrowDown", (e) => {
+ e.preventDefault();
+ window.scrollBy({ top: SCROLL_AMOUNT, behavior: "smooth" });
});
</script>
diff --git a/web/src/pages/experience/[id].astro b/web/src/pages/experience/[id].astro
@@ -1,17 +1,12 @@
---
import Layout from "../../layouts/Layout.astro";
-import rawExperience from "../../../config/experience.json";
-import type Experience from "../../lib/types/experience";
+
import { URL } from "../../constants/website";
-import { getTagClass } from "../../lib/utils/tags";
-import {
- getSchemaForExperience,
- getBreadcrumbSchema,
-} from "../../lib/schemas/experience";
+import { experience as experiences } from "../../config";
+import { getTagClass } from "../../utils/tags";
+import schemas from "../../schemas/index";
-const experiences = rawExperience as Experience[];
const experience = experiences.find((e) => e.id === Astro.params.id);
-
if (!experience) {
return new Response(null, {
status: 404,
@@ -33,6 +28,8 @@ const keywords = [...experience.tech, experience.company, experience.role].join(
", ",
);
const description = `${experience.role} at ${experience.company}. ${experience.description[0]}`;
+
+const { getSchemaForExperience, getBreadcrumbSchema } = schemas.experience;
---
<Layout
@@ -312,3 +309,20 @@ const description = `${experience.role} at ${experience.company}. ${experience.d
}
}
</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/web/src/pages/index.astro
@@ -1,21 +1,20 @@
---
import Layout from "../layouts/Layout.astro";
-import { fetchWakaTimeData } from "../lib/wakatime";
-
-import about from "../../config/about.json";
-import currentJob from "../lib/utils/job";
-import personSchema from "../lib/schemas/person";
-import websiteSchema from "../lib/schemas/website";
+import Header from "../layouts/components/Header.astro";
+import AboutTab from "../layouts/tabs/About.astro";
+import ExperienceTab from "../layouts/tabs/Experience.astro";
+import ProjectsTab from "../layouts/tabs/Projects.astro";
+import BlogTab from "../layouts/tabs/Blog.astro";
+import Footer from "../layouts/components/Footer.astro";
+
+import { about, currentJob } from "../config";
+import { fetchWakaTimeData } from "../external/wakatime";
import { URL } from "../constants/website";
-
-import Header from "../components/Header.astro";
-import AboutPage from "../components/tabs/About.astro";
-import ExperiencePage from "../components/tabs/Experience.astro";
-import ProjectsPage from "../components/tabs/Projects.astro";
-import BlogPage from "../components/tabs/Blog.astro";
-import Footer from "../components/Footer.astro";
+import schemas from "../schemas/index";
const wakaTimeData = await fetchWakaTimeData();
+
+const { personSchema, websiteSchema } = schemas;
---
<Layout
@@ -38,10 +37,10 @@ const wakaTimeData = await fetchWakaTimeData();
<Header />
<main id="content">
- <AboutPage wakaTimeData={wakaTimeData} />
- <ExperiencePage />
- <ProjectsPage />
- <BlogPage isStandalone={false} />
+ <AboutTab wakaTimeData={wakaTimeData} />
+ <ExperienceTab />
+ <ProjectsTab />
+ <BlogTab />
</main>
<Footer wakaTimeData={wakaTimeData} />
@@ -51,7 +50,7 @@ const wakaTimeData = await fetchWakaTimeData();
</Layout>
<script>
- import { initUI } from "../scripts/ui/index";
+ import { initUI } from "../ui";
document.addEventListener("DOMContentLoaded", () => {
initUI();
diff --git a/web/src/pages/projects/[id].astro b/web/src/pages/projects/[id].astro
@@ -1,18 +1,13 @@
---
import Layout from "../../layouts/Layout.astro";
-import rawProjects from "../../../config/projects.json";
-import type Project from "../../lib/types/project";
-import { URL } from "../../constants/website";
-import { getTagClass } from "../../lib/utils/tags";
-import { fetchGitMetadataForProject } from "../../lib/git";
-import { parseMarkdown } from "../../lib/readme";
-import {
- getSchemaForProject,
- getBreadcrumbSchema,
-} from "../../lib/schemas/project";
-const projects = rawProjects as Project[];
+import { projects } from "../../config";
+import { fetchGitMetadataForProject } from "../../external/git";
+import { URL } from "../../constants/website";
+import schemas from "../../schemas/index";
+import { getTagClass } from "../../utils/tags";
+import { parseMarkdown } from "../../utils/markdown";
const project = projects.find((p) => p.id === Astro.params.id);
if (!project) {
return new Response(null, {
@@ -24,6 +19,8 @@ if (!project) {
const keywords = project.tech.join(", ");
const metadata = await fetchGitMetadataForProject(project);
+
+const { getSchemaForProject, getBreadcrumbSchema } = schemas.project;
---
<Layout
@@ -278,3 +275,20 @@ const metadata = await fetchGitMetadataForProject(project);
}
}
</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/sitemap.xml.ts b/web/src/pages/sitemap.xml.ts
@@ -1,14 +1,7 @@
import type { APIRoute } from "astro";
-import blogs from "../lib/blogs";
-import rawProjects from "../../config/projects.json";
-import rawExperience from "../../config/experience.json";
-import type Project from "../lib/types/project";
-import type Experience from "../lib/types/experience";
-import { fetchGitMetadataForProject } from "../lib/git";
-
-const projects = rawProjects as Project[];
-const experiences = rawExperience as Experience[];
-const siteUrl = "https://prime8.dev";
+import { fetchGitMetadataForProject } from "../external/git";
+import { experience, projects } from "../config";
+import { blogs } from "../blogs";
export const GET: APIRoute = async () => {
const now = new Date().toISOString().split("T")[0];
@@ -30,19 +23,19 @@ export const GET: APIRoute = async () => {
];
// Project pages
- const projectPages = projects.map((project) => ({
- url: `/projects/${project.id}`,
+ const projectPages = projects.map((prg) => ({
+ url: `/projects/${prg.id}`,
changefreq: "monthly",
priority: "0.7",
- lastmod: metadata[project.id]?.dateModified ?? now,
+ lastmod: metadata[prg.id]?.dateModified ?? now,
}));
// Experience pages
- const experiencePages = experiences.map((experience) => ({
- url: `/experience/${experience.id}`,
+ const experiencePages = experience.map((exp) => ({
+ url: `/experience/${exp.id}`,
changefreq: "monthly",
priority: "0.7",
- lastmod: experience.endDate || experience.startDate || now,
+ lastmod: exp.endDate || exp.startDate || now,
}));
// Blog posts
@@ -65,7 +58,7 @@ export const GET: APIRoute = async () => {
${allPages
.map(
(page) => ` <url>
- <loc>${siteUrl}${page.url}</loc>
+ <loc>${URL}${page.url}</loc>
<lastmod>${page.lastmod || now}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
diff --git a/web/src/schemas/blog.ts b/web/src/schemas/blog.ts
@@ -0,0 +1,61 @@
+import type { Blog } from "../blogs";
+import { 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: `${URL}/blog/${blog.id}`,
+ author: personSchema,
+ publisher: personSchema,
+ mainEntityOfPage: {
+ "@type": "WebPage",
+ "@id": `${URL}/blog/${blog.id}`,
+ },
+ articleSection: "Technology",
+ };
+
+ if (blog.image) {
+ schema.image = blog.image.startsWith("http")
+ ? blog.image
+ : `${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: URL,
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: "Blog",
+ item: `${URL}/#blog`,
+ },
+ {
+ "@type": "ListItem",
+ position: 3,
+ name: blog.title,
+ item: `${URL}/blog/${blog.id}`,
+ },
+ ],
+ };
+}
diff --git a/web/src/schemas/experience.ts b/web/src/schemas/experience.ts
@@ -0,0 +1,54 @@
+import type { Experience } from "../config";
+import { URL } from "../constants/website";
+import personSchema from "./person";
+
+export function getSchemaForExperience(experience: Experience) {
+ return {
+ "@context": "https://schema.org",
+ "@type": "Person",
+ name: personSchema.name,
+ url: personSchema.url,
+ worksFor: {
+ "@type": "Organization",
+ name: experience.company,
+ url: experience.url,
+ },
+ hasOccupation: {
+ "@type": "Occupation",
+ name: experience.role,
+ startDate: experience.startDate,
+ ...(experience.endDate && { endDate: experience.endDate }),
+ occupationLocation: {
+ "@type": "City",
+ name: "Remote",
+ },
+ },
+ };
+}
+
+export function getBreadcrumbSchema(experience: Experience) {
+ return {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Home",
+ item: URL,
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: "Experience",
+ item: `${URL}/#experience`,
+ },
+ {
+ "@type": "ListItem",
+ position: 3,
+ name: experience.company,
+ item: `${URL}/experience/${experience.id}`,
+ },
+ ],
+ };
+}
diff --git a/web/src/schemas/index.ts b/web/src/schemas/index.ts
@@ -0,0 +1,13 @@
+import * as blog from "./blog";
+import * as experience from "./experience";
+import * as personSchema from "./person";
+import * as project from "./project";
+import * as websiteSchema from "./website";
+
+export default {
+ blog,
+ experience,
+ personSchema,
+ project,
+ websiteSchema,
+};
diff --git a/web/src/schemas/person.ts b/web/src/schemas/person.ts
@@ -0,0 +1,22 @@
+import { about, contact, currentJob } from "../config";
+import { URL } from "../constants/website";
+
+export default {
+ "@context": "https://schema.org",
+ "@type": "Person",
+ name: about.name,
+ alternateName: about.handle,
+ email: contact.email,
+ sameAs: [contact.gitlab, contact.linkedin],
+ url: URL,
+ image: `${URL}/me.jpg`,
+ ...(currentJob && {
+ jobTitle: currentJob.role,
+ worksFor: {
+ "@type": "Organization",
+ name: currentJob.company,
+ url: currentJob.url,
+ },
+ }),
+ knowsAbout: [...about.interests],
+};
diff --git a/web/src/schemas/project.ts b/web/src/schemas/project.ts
@@ -0,0 +1,46 @@
+import type Project from "../config/projects";
+import type { GitMetadata } from "../external/git";
+import { URL } from "../constants/website";
+import personSchema from "./person";
+
+export function getSchemaForProject(project: Project, metadata?: GitMetadata) {
+ return {
+ "@context": "https://schema.org",
+ "@type": "SoftwareSourceCode",
+ name: project.name,
+ description: project.subtitle,
+ url: `${URL}/projects/${project.id}`,
+ codeRepository: project.url,
+ programmingLanguage: project.tech,
+ author: personSchema,
+ ...(metadata?.dateCreated && { dateCreated: metadata.dateCreated }),
+ ...(metadata?.dateModified && { dateModified: metadata.dateModified }),
+ };
+}
+
+export function getBreadcrumbSchema(project: Project) {
+ return {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Home",
+ item: URL,
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: "Projects",
+ item: `${URL}/#projects`,
+ },
+ {
+ "@type": "ListItem",
+ position: 3,
+ name: project.name,
+ item: `${URL}/projects/${project.id}`,
+ },
+ ],
+ };
+}
diff --git a/web/src/schemas/website.ts b/web/src/schemas/website.ts
@@ -0,0 +1,12 @@
+import { about, currentJob } from "../config";
+import { URL } from "../constants/website";
+import personSchema from "./person";
+
+export default {
+ "@context": "https://schema.org",
+ "@type": "WebSite",
+ name: about.name,
+ url: URL,
+ description: `Portfolio and blog of ${about.name}, a ${about.profession.toLowerCase()}${currentJob?.company ? ` at ${currentJob.company}` : ""}. ${about.bio}.`,
+ author: personSchema,
+};
diff --git a/web/src/scripts/ui/blog.ts b/web/src/scripts/ui/blog.ts
@@ -1,58 +0,0 @@
-import { selectedIndex, setCurrentBlogId } from "./state";
-import { updateUrl } from "./url";
-
-export function setupBlogNavigation(): void {
- document.querySelectorAll("#blog .blog-list-item").forEach((item) => {
- item.addEventListener("click", () => {
- const blogId = item.getAttribute("data-id");
- if (blogId) {
- const index = parseInt(item.getAttribute("data-index") || "0");
- selectedIndex.blog = index;
-
- document.querySelectorAll("#blog .blog-list-item").forEach((li, i) => {
- li.classList.toggle("selected", i === index);
- });
-
- openBlogPost(blogId);
- }
- });
- });
-
- const listItems = document.querySelectorAll("#blog .blog-list-item");
- if (listItems.length > 0) {
- listItems[0].classList.add("selected");
- }
-}
-
-export function openBlogPost(blogId: string): void {
- setCurrentBlogId(blogId);
-
- const listView = document.getElementById("blog-list-view");
- const postView = document.getElementById("blog-post-view");
-
- if (listView && postView) {
- listView.classList.add("hidden");
- postView.classList.remove("hidden");
-
- document.querySelectorAll(".blog-post").forEach((post) => {
- post.classList.toggle(
- "hidden",
- post.getAttribute("data-blog-id") !== blogId,
- );
- });
- }
-
- updateUrl();
-}
-
-export function closeBlogPost(): void {
- setCurrentBlogId(null);
-
- const listView = document.getElementById("blog-list-view");
- const postView = document.getElementById("blog-post-view");
-
- if (listView && postView) {
- listView.classList.remove("hidden");
- postView.classList.add("hidden");
- }
-}
diff --git a/web/src/scripts/ui/boot.ts b/web/src/scripts/ui/boot.ts
@@ -1,103 +0,0 @@
-import env from "../../constants/env";
-
-const GLITCH_CHARS = "▓▒░█▄▀■□▪▫●○◆◇";
-const SCRAMBLE_DURATION = 400;
-const SCRAMBLE_INTERVAL = 30;
-const HOLD_DURATION = 200;
-const FADE_DURATION = 300;
-
-function getRandomChar(): string {
- return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)];
-}
-
-function scrambleText(length: number): string {
- return Array.from({ length }, () => getRandomChar()).join("");
-}
-
-function createBootOverlay(): HTMLElement {
- const overlay = document.createElement("div");
- overlay.id = "boot-overlay";
- overlay.innerHTML = `
- <div class="boot-terminal">
- <div class="boot-text">
- <span class="boot-name"></span>
- <span class="boot-title"></span>
- </div>
- </div>
- `;
- document.body.appendChild(overlay);
- return overlay;
-}
-
-function sleep(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-async function scrambleReveal(
- element: HTMLElement,
- target: string,
- duration: number,
-): Promise<void> {
- const steps = Math.floor(duration / SCRAMBLE_INTERVAL);
- const length = target.length;
-
- for (let i = 0; i <= steps; i++) {
- const progress = i / steps;
- const revealedCount = Math.floor(progress * length);
-
- const revealed = target.slice(0, revealedCount);
- const scrambled = scrambleText(length - revealedCount);
-
- element.textContent = revealed + scrambled;
- await sleep(SCRAMBLE_INTERVAL);
- }
-
- element.textContent = target;
-}
-
-async function runBootSequence(overlay: HTMLElement): Promise<void> {
- const nameEl = overlay.querySelector(".boot-name") as HTMLElement;
- const titleEl = overlay.querySelector(".boot-title") as HTMLElement;
-
- const name = "prime8.dev";
- const title = "Damian Myrda";
-
- // Initialize with scrambled text
- nameEl.textContent = scrambleText(name.length);
- titleEl.textContent = scrambleText(title.length);
-
- await sleep(100);
-
- // Reveal name first, then title
- await scrambleReveal(nameEl, name, SCRAMBLE_DURATION);
- await scrambleReveal(titleEl, title, SCRAMBLE_DURATION);
-
- await sleep(HOLD_DURATION);
-
- overlay.classList.add("fade-out");
- await sleep(FADE_DURATION);
-
- overlay.remove();
-}
-
-export async function playBootAnimation(): Promise<void> {
- const BOOT_KEY = "prime8dev-boot-shown";
- const app = document.getElementById("app");
-
- if (env === "prod" && sessionStorage.getItem(BOOT_KEY)) {
- if (app) {
- app.classList.add("ready");
- }
- return;
- }
-
- const overlay = createBootOverlay();
-
- await runBootSequence(overlay);
-
- sessionStorage.setItem(BOOT_KEY, "1");
-
- if (app) {
- app.classList.add("ready", "boot-reveal");
- }
-}
diff --git a/web/src/scripts/ui/clicks.ts b/web/src/scripts/ui/clicks.ts
@@ -1,20 +0,0 @@
-import { selectedIndex } from "./state";
-import { updateUrl } from "./url";
-import { updateDetailView } from "./detail";
-
-export function setupListClicks(): void {
- document.querySelectorAll("#projects .list-item").forEach((item) => {
- item.addEventListener("click", () => {
- const index = parseInt(item.getAttribute("data-index") || "0");
-
- selectedIndex.projects = index;
-
- document.querySelectorAll("#projects .list-item").forEach((li, i) => {
- li.classList.toggle("selected", i === index);
- });
-
- updateDetailView("projects");
- updateUrl();
- });
- });
-}
diff --git a/web/src/scripts/ui/detail.ts b/web/src/scripts/ui/detail.ts
@@ -1,27 +0,0 @@
-import { selectedIndex } from "./state";
-
-export function updateExperienceDetail(): void {
- const index = selectedIndex.experience;
- const detailContents = document.querySelectorAll("#experience .exp-card");
-
- detailContents.forEach((content) => {
- content.classList.toggle(
- "hidden",
- parseInt(content.getAttribute("data-for") || "0") !== index,
- );
- });
-}
-
-export function updateDetailView(pageId: string): void {
- const index = selectedIndex[pageId];
- const detailContents = document.querySelectorAll(
- `#${pageId} .detail-content`,
- );
-
- detailContents.forEach((content) => {
- content.classList.toggle(
- "hidden",
- parseInt(content.getAttribute("data-for") || "0") !== index,
- );
- });
-}
diff --git a/web/src/scripts/ui/index.ts b/web/src/scripts/ui/index.ts
@@ -1,27 +0,0 @@
-import { applyInitialState } from "./state";
-import { loadFromUrl } from "./url";
-import { setupTabs } from "./tabs";
-import { setupKeyboard } from "./keyboard";
-import { setupListClicks } from "./clicks";
-import { setupBlogNavigation } from "./blog";
-import { updateStatusTime } from "./status";
-import { playBootAnimation } from "./boot";
-
-export async function initUI(): Promise<void> {
- await playBootAnimation();
-
- loadFromUrl();
- setupTabs();
- setupKeyboard();
- setupListClicks();
- setupBlogNavigation();
- applyInitialState();
-
- updateStatusTime();
- setInterval(updateStatusTime, 1000);
-
- window.addEventListener("popstate", () => {
- loadFromUrl();
- applyInitialState();
- });
-}
diff --git a/web/src/scripts/ui/keyboard.ts b/web/src/scripts/ui/keyboard.ts
@@ -1,74 +0,0 @@
-import { currentTab, currentBlogId, selectedIndex } from "./state";
-import { getItemId, updateUrl } from "./url";
-import { switchTab, cycleTab } from "./tabs";
-import { navigateList } from "./navigation";
-import { openBlogPost, closeBlogPost } from "./blog";
-
-export function setupKeyboard(): void {
- document.addEventListener("keydown", (e) => {
- if (
- e.target instanceof HTMLInputElement ||
- e.target instanceof HTMLTextAreaElement
- )
- return;
-
- switch (e.key) {
- case "1":
- switchTab("about");
- break;
- case "2":
- switchTab("experience");
- break;
- case "3":
- switchTab("projects");
- break;
- case "4":
- switchTab("blog");
- break;
- case "ArrowDown":
- e.preventDefault();
- navigateList(1);
- break;
- case "ArrowUp":
- e.preventDefault();
- navigateList(-1);
- break;
- case "ArrowLeft":
- if (
- (
- window as Window & { isLightboxOpen?: () => boolean }
- ).isLightboxOpen?.()
- )
- return;
- e.preventDefault();
- cycleTab(-1);
- break;
- case "ArrowRight":
- if (
- (
- window as Window & { isLightboxOpen?: () => boolean }
- ).isLightboxOpen?.()
- )
- return;
- e.preventDefault();
- cycleTab(1);
- break;
- case "Tab":
- e.preventDefault();
- cycleTab(e.shiftKey ? -1 : 1);
- break;
- case "Enter":
- if (currentTab === "blog" && !currentBlogId) {
- const blogId = getItemId("blog", selectedIndex.blog);
- openBlogPost(blogId);
- }
- break;
- case "Escape":
- if (currentTab === "blog" && currentBlogId) {
- closeBlogPost();
- updateUrl();
- }
- break;
- }
- });
-}
diff --git a/web/src/scripts/ui/navigation.ts b/web/src/scripts/ui/navigation.ts
@@ -1,77 +0,0 @@
-import { currentTab, selectedIndex, currentBlogId } from "./state";
-import { updateUrl } from "./url";
-import { updateDetailView } from "./detail";
-
-export function navigateList(direction: number): void {
- if (currentTab === "about") return;
- if (currentTab === "experience") {
- navigateTimeline(direction);
- return;
- }
- if (currentTab === "blog") {
- if (!currentBlogId) {
- navigateBlogList(direction);
- }
- return;
- }
-
- const listItems = document.querySelectorAll(`#${currentTab} .list-item`);
- const maxIndex = listItems.length - 1;
-
- selectedIndex[currentTab] = Math.max(
- 0,
- Math.min(maxIndex, selectedIndex[currentTab] + direction),
- );
-
- listItems.forEach((item, i) => {
- item.classList.toggle("selected", i === selectedIndex[currentTab]);
- });
-
- updateDetailView(currentTab);
- updateUrl();
-
- listItems[selectedIndex[currentTab]]?.scrollIntoView({ block: "nearest" });
-}
-
-export function navigateTimeline(direction: number): void {
- const cards = document.querySelectorAll("#experience .exp-card");
- const rows = document.querySelectorAll("#experience .exp-row");
- const timelines = document.querySelectorAll("#experience .exp-timeline");
- const maxIndex = cards.length - 1;
-
- const newIndex = selectedIndex.experience + direction;
-
- if (newIndex < 0 || newIndex > maxIndex) {
- return;
- }
-
- selectedIndex.experience = newIndex;
-
- cards.forEach((card, i) => {
- card.classList.toggle("selected", i === newIndex);
- });
-
- timelines.forEach((tl, i) => {
- tl.classList.toggle("active", i === newIndex);
- });
-
- rows[newIndex]?.scrollIntoView({ behavior: "smooth", block: "start" });
-
- updateUrl();
-}
-
-export function navigateBlogList(direction: number): void {
- const listItems = document.querySelectorAll("#blog .blog-list-item");
- const maxIndex = listItems.length - 1;
-
- selectedIndex.blog = Math.max(
- 0,
- Math.min(maxIndex, selectedIndex.blog + direction),
- );
-
- listItems.forEach((item, i) => {
- item.classList.toggle("selected", i === selectedIndex.blog);
- });
-
- listItems[selectedIndex.blog]?.scrollIntoView({ block: "nearest" });
-}
diff --git a/web/src/scripts/ui/state.ts b/web/src/scripts/ui/state.ts
@@ -1,72 +0,0 @@
-import { openBlogPost } from "./blog";
-import { updateDetailView } from "./detail";
-
-export let currentTab = "about";
-
-export const selectedIndex: Record<string, number> = {
- experience: 0,
- projects: getInitialProjectsIndex(),
- blog: getInitialBlogIndex(),
-};
-export let currentBlogId: string | null = null;
-
-export function applyInitialState(): void {
- const tabs = document.querySelectorAll(".tab");
- const pages = document.querySelectorAll(".page");
-
- tabs.forEach((tab) => {
- const isActive = tab.getAttribute("data-tab") === currentTab;
- tab.classList.toggle("active", isActive);
- if (isActive) {
- tab.setAttribute("aria-current", "page");
- } else {
- tab.removeAttribute("aria-current");
- }
- });
- pages.forEach((page) => {
- page.classList.toggle("active", page.id === currentTab);
- });
-
- const expCards = document.querySelectorAll("#experience .exp-card");
- expCards.forEach((card, i) => {
- card.classList.toggle("selected", i === selectedIndex.experience);
- });
-
- const expTimelines = document.querySelectorAll("#experience .exp-timeline");
- expTimelines.forEach((tl, i) => {
- tl.classList.toggle("active", i === selectedIndex.experience);
- });
-
- const projectItems = document.querySelectorAll("#projects .list-item");
- projectItems.forEach((item, i) => {
- item.classList.toggle("selected", i === selectedIndex.projects);
- });
- updateDetailView("projects");
-
- const blogItems = document.querySelectorAll("#blog .blog-list-item");
- blogItems.forEach((item, i) => {
- item.classList.toggle("selected", i === selectedIndex.blog);
- });
-
- if (currentTab === "blog" && currentBlogId) {
- openBlogPost(currentBlogId);
- }
-}
-
-export function setCurrentTab(tab: string): void {
- currentTab = tab;
-}
-
-function getInitialProjectsIndex(): number {
- const selected = document.querySelector("#projects .list-item.selected");
- return selected ? parseInt(selected.getAttribute("data-index") || "0") : 0;
-}
-
-function getInitialBlogIndex(): number {
- const selected = document.querySelector("#blog .blog-list-item.selected");
- return selected ? parseInt(selected.getAttribute("data-index") || "0") : 0;
-}
-
-export function setCurrentBlogId(id: string | null): void {
- currentBlogId = id;
-}
diff --git a/web/src/scripts/ui/status.ts b/web/src/scripts/ui/status.ts
@@ -1,17 +0,0 @@
-export function updateStatusTime(): void {
- const el = document.getElementById("status-time");
- if (!el) return;
-
- const now = new Date();
- const time = now.toLocaleTimeString("en-US", {
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- hour12: false,
- });
- const date = now.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- });
- el.textContent = `${date} ${time}`;
-}
diff --git a/web/src/scripts/ui/tabs.ts b/web/src/scripts/ui/tabs.ts
@@ -1,48 +0,0 @@
-import { currentTab, setCurrentTab } from "./state";
-import { updateUrl } from "./url";
-import { closeBlogPost } from "./blog";
-
-export function setupTabs(): void {
- const tabs = document.querySelectorAll(".tab");
- tabs.forEach((tab) => {
- tab.addEventListener("click", () => {
- const tabName = tab.getAttribute("data-tab");
- if (tabName) switchTab(tabName);
- });
- });
-}
-
-export function switchTab(tabName: string): void {
- setCurrentTab(tabName);
-
- if (tabName === "blog") {
- closeBlogPost();
- }
-
- const tabs = document.querySelectorAll(".tab");
- const pages = document.querySelectorAll(".page");
-
- tabs.forEach((tab) => {
- const isActive = tab.getAttribute("data-tab") === tabName;
- tab.classList.toggle("active", isActive);
- if (isActive) {
- tab.setAttribute("aria-current", "page");
- } else {
- tab.removeAttribute("aria-current");
- }
- });
-
- pages.forEach((page) => {
- page.classList.toggle("active", page.id === tabName);
- });
-
- updateUrl();
-}
-
-export function cycleTab(direction: number): void {
- const tabOrder = ["about", "experience", "projects", "blog"];
- const currentIndex = tabOrder.indexOf(currentTab);
- const newIndex =
- (currentIndex + direction + tabOrder.length) % tabOrder.length;
- switchTab(tabOrder[newIndex]);
-}
diff --git a/web/src/scripts/ui/url.ts b/web/src/scripts/ui/url.ts
@@ -1,94 +0,0 @@
-import {
- currentTab,
- selectedIndex,
- currentBlogId,
- setCurrentTab,
- setCurrentBlogId,
-} from "./state";
-
-export function getItemId(tab: string, index: number): string {
- if (tab === "experience") {
- const item = document.querySelector(
- `#experience .timeline-segment[data-index="${index}"]`,
- );
- return item?.getAttribute("data-id") || String(index);
- } else if (tab === "projects") {
- const item = document.querySelector(
- `#projects .list-item[data-index="${index}"]`,
- );
- return item?.getAttribute("data-id") || String(index);
- } else if (tab === "blog") {
- const item = document.querySelector(
- `#blog .blog-list-item[data-index="${index}"]`,
- );
- return item?.getAttribute("data-id") || String(index);
- }
- return String(index);
-}
-
-export function getIndexFromId(tab: string, id: string): number {
- if (tab === "experience") {
- const items = document.querySelectorAll(`#experience .timeline-segment`);
- for (let i = 0; i < items.length; i++) {
- if (items[i].getAttribute("data-id") === id) {
- return parseInt(items[i].getAttribute("data-index") || "0");
- }
- }
- } else if (tab === "projects") {
- const items = document.querySelectorAll(`#projects .list-item`);
- for (let i = 0; i < items.length; i++) {
- if (items[i].getAttribute("data-id") === id) {
- return i;
- }
- }
- } else if (tab === "blog") {
- const items = document.querySelectorAll(`#blog .blog-list-item`);
- for (let i = 0; i < items.length; i++) {
- if (items[i].getAttribute("data-id") === id) {
- return parseInt(items[i].getAttribute("data-index") || "0");
- }
- }
- }
- return 0;
-}
-
-export function updateUrl(): void {
- let newHash: string;
-
- if (currentTab === "about") {
- newHash = "#about";
- } else if (currentTab === "blog") {
- if (currentBlogId) {
- newHash = `#blog/${currentBlogId}`;
- } else {
- newHash = "#blog";
- }
- } else {
- newHash = `#${currentTab}/${getItemId(currentTab, selectedIndex[currentTab])}`;
- }
-
- if (window.location.hash !== newHash) {
- history.pushState(null, "", newHash);
- }
-}
-
-export function loadFromUrl(): void {
- const hash = window.location.hash.slice(1);
- if (!hash) return;
-
- const parts = hash.split("/");
- const tab = parts[0];
- const id = parts[1];
-
- if (["about", "experience", "projects", "blog"].includes(tab)) {
- setCurrentTab(tab);
- if (tab === "blog" && id) {
- setCurrentBlogId(id);
- selectedIndex.blog = getIndexFromId("blog", id);
- } else if (tab === "blog") {
- setCurrentBlogId(null);
- } else if (tab !== "about" && id) {
- selectedIndex[tab] = getIndexFromId(tab, id);
- }
- }
-}
diff --git a/web/src/tabs/about.ts b/web/src/tabs/about.ts
@@ -0,0 +1,22 @@
+import { onKey, type KeybindHandle } from "../ui/keyboard";
+import { registerTab, cycleTab } from "../ui/tabs";
+
+let keybinds: KeybindHandle[] = [];
+
+registerTab("about", {
+ activate: () => {
+ keybinds = [
+ onKey("ArrowLeft", (e) => {
+ e.preventDefault();
+ cycleTab("prev");
+ }),
+ onKey("ArrowRight", (e) => {
+ e.preventDefault();
+ cycleTab("next");
+ }),
+ ];
+ },
+ deactivate: () => {
+ keybinds.forEach((handle) => handle.unsubscribe());
+ },
+});
diff --git a/web/src/tabs/blog.ts b/web/src/tabs/blog.ts
@@ -0,0 +1,191 @@
+import { SCROLL_AMOUNT } from "../constants/ui/scroll";
+import { $, $$, setActiveByIndex, scrollIntoViewSmooth } from "../ui/dom";
+import { onKey, type KeybindHandle } from "../ui/keyboard";
+import { setRoute } from "../ui/router";
+import { cycleTab, registerTab } from "../ui/tabs";
+
+let selectedIndex = 0;
+let currentPostId: string | null = null;
+
+let keybinds: KeybindHandle[] = [];
+
+registerTab("blog", {
+ init: () => {
+ selectItem(0);
+ setupClicks();
+ setupBackButton();
+ },
+
+ activate: () => {
+ if (currentPostId) {
+ usePostKeyboard();
+ } else {
+ useListKeyboard();
+ }
+ },
+ deactivate,
+
+ getRouteId: () => currentPostId || undefined,
+ setRouteId: (id) => {
+ if (id) {
+ selectedIndex = getIndexById(id);
+ selectItem(selectedIndex);
+ openPost(id);
+ } else {
+ closePost();
+ }
+ },
+});
+
+function useListKeyboard(): void {
+ keybinds = [
+ onKey("ArrowUp", (e) => {
+ e.preventDefault();
+ navigate("up");
+ }),
+ onKey("ArrowDown", (e) => {
+ e.preventDefault();
+ navigate("down");
+ }),
+ onKey("ArrowLeft", (e) => {
+ e.preventDefault();
+ cycleTab("prev");
+ }),
+ onKey("ArrowRight", (e) => {
+ e.preventDefault();
+ const id = getId(selectedIndex);
+ openPost(id);
+ setRoute("blog", id);
+ }),
+ onKey("Enter", (e) => {
+ e.preventDefault();
+ const id = getId(selectedIndex);
+ openPost(id);
+ setRoute("blog", id);
+ }),
+ ];
+}
+
+function usePostKeyboard(): void {
+ keybinds = [
+ onKey("ArrowUp", (e) => {
+ e.preventDefault();
+ scrollPost("up");
+ }),
+ onKey("ArrowDown", (e) => {
+ e.preventDefault();
+ scrollPost("down");
+ }),
+ onKey("ArrowLeft", (e) => {
+ e.preventDefault();
+ closePost();
+ setRoute("blog");
+ }),
+ onKey("Escape", (e) => {
+ e.preventDefault();
+ closePost();
+ setRoute("blog");
+ }),
+ ];
+}
+
+function openPost(id: string): void {
+ currentPostId = id;
+
+ const listView = $("#blog-list-view");
+ const postView = $("#blog-post-view");
+
+ if (listView && postView) {
+ listView.classList.add("hidden");
+ postView.classList.remove("hidden");
+
+ $$(".blog-post").forEach((post) => {
+ post.classList.toggle("hidden", post.getAttribute("data-blog-id") !== id);
+ });
+
+ postView.scrollTop = 0;
+ }
+
+ // Switch keyboard context
+ deactivate();
+ usePostKeyboard();
+}
+
+function closePost(): void {
+ currentPostId = null;
+
+ const listView = $("#blog-list-view");
+ const postView = $("#blog-post-view");
+
+ if (listView && postView) {
+ listView.classList.remove("hidden");
+ postView.classList.add("hidden");
+ }
+
+ // Switch keyboard context
+ deactivate();
+ useListKeyboard();
+}
+
+function deactivate(): void {
+ keybinds.forEach((handle) => handle.unsubscribe());
+}
+
+function setupClicks(): void {
+ $$("#blog .blog-list-item").forEach((item, i) => {
+ item.addEventListener("click", () => {
+ const id = item.getAttribute("data-id");
+ if (id) {
+ selectItem(i);
+ openPost(id);
+ setRoute("blog", id);
+ }
+ });
+ });
+}
+
+function setupBackButton(): void {
+ $(".blog-back-btn")?.addEventListener("click", () => {
+ closePost();
+ setRoute("blog");
+ });
+}
+
+function navigate(direction: "up" | "down"): void {
+ const items = $$("#blog .blog-list-item");
+ const newIndex = selectedIndex + (direction === "up" ? -1 : 1);
+ if (newIndex >= 0 && newIndex < items.length) {
+ selectItem(newIndex);
+ }
+}
+
+function selectItem(index: number): void {
+ selectedIndex = index;
+ setActiveByIndex("#blog .blog-list-item", index, "selected");
+
+ const items = $$("#blog .blog-list-item");
+ scrollIntoViewSmooth(items[index]);
+}
+
+function scrollPost(direction: string): void {
+ const postView = $("#blog-post-view");
+ if (postView) {
+ postView.scrollBy({
+ top: (direction === "up" ? -1 : 1) * SCROLL_AMOUNT,
+ behavior: "smooth",
+ });
+ }
+}
+
+function getId(index: number): string {
+ const item = $$("#blog .blog-list-item")[index];
+ return item?.getAttribute("data-id") || String(index);
+}
+
+function getIndexById(id: string): number {
+ const items = $$("#blog .blog-list-item");
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].getAttribute("data-id") === id) return i;
+ }
+ return 0;
+}
diff --git a/web/src/tabs/experience.ts b/web/src/tabs/experience.ts
@@ -0,0 +1,79 @@
+import { $$, setActiveByIndex, scrollIntoViewSmooth } from "../ui/dom";
+import { onKey, type KeybindHandle } from "../ui/keyboard";
+import { setRoute } from "../ui/router";
+import { registerTab } from "../ui/tabs";
+
+let selectedIndex = 0;
+
+let keybinds: KeybindHandle[] = [];
+
+registerTab("experience", {
+ init: () => {
+ select(0);
+ setupHover();
+ },
+
+ activate: () => {
+ keybinds = [
+ onKey("ArrowUp", (e) => {
+ e.preventDefault();
+ navigate("up");
+ }),
+ onKey("ArrowDown", (e) => {
+ e.preventDefault();
+ navigate("down");
+ }),
+ ];
+ },
+ deactivate: () => {
+ keybinds.forEach((handle) => handle.unsubscribe());
+ },
+
+ getRouteId: () => getId(selectedIndex),
+ setRouteId: (id) => {
+ if (id) {
+ selectedIndex = getIndexById(id);
+ select(selectedIndex);
+ }
+ },
+});
+
+function navigate(direction: string): void {
+ const cards = $$("#experience .exp-card");
+ const newIndex = selectedIndex + (direction === "up" ? -1 : 1);
+ if (newIndex >= 0 && newIndex < cards.length) {
+ select(newIndex);
+ setRoute("experience", getId(selectedIndex));
+ }
+}
+
+function select(index: number): void {
+ selectedIndex = index;
+ setActiveByIndex("#experience .exp-card", index, "selected");
+ setActiveByIndex("#experience .exp-timeline", index, "active");
+
+ const rows = $$("#experience .exp-row");
+ scrollIntoViewSmooth(rows[index], "start");
+}
+
+function setupHover(): void {
+ $$("#experience .exp-card").forEach((card, i) => {
+ card.addEventListener("mouseenter", () => select(i));
+ });
+ $$("#experience .exp-timeline").forEach((tl, i) => {
+ tl.addEventListener("mouseenter", () => select(i));
+ });
+}
+
+function getId(index: number): string {
+ const item = $$("#experience .exp-card")[index];
+ return item?.getAttribute("data-id") || String(index);
+}
+
+function getIndexById(id: string): number {
+ const cards = $$("#experience .exp-card");
+ for (let i = 0; i < cards.length; i++) {
+ if (cards[i].getAttribute("data-id") === id) return i;
+ }
+ return 0;
+}
diff --git a/web/src/tabs/projects.ts b/web/src/tabs/projects.ts
@@ -0,0 +1,85 @@
+import { $$, setActiveByIndex, scrollIntoViewSmooth } from "../ui/dom";
+import { onKey, type KeybindHandle } from "../ui/keyboard";
+import { setRoute } from "../ui/router";
+import { registerTab } from "../ui/tabs";
+
+let selectedIndex = 0;
+
+let keybinds: KeybindHandle[] = [];
+
+registerTab("projects", {
+ init: () => {
+ select(0);
+ setupClicks();
+ },
+
+ activate: () => {
+ keybinds = [
+ onKey("ArrowUp", (e) => {
+ e.preventDefault();
+ navigate("up");
+ }),
+ onKey("ArrowDown", (e) => {
+ e.preventDefault();
+ navigate("down");
+ }),
+ ];
+ },
+ deactivate: () => {
+ keybinds.forEach((handle) => handle.unsubscribe());
+ },
+
+ getRouteId: () => getId(selectedIndex),
+ setRouteId: (id) => {
+ if (id) {
+ selectedIndex = getIndexById(id);
+ select(selectedIndex);
+ }
+ },
+});
+
+function navigate(direction: string): void {
+ const items = $$("#projects .list-item");
+ const newIndex = selectedIndex + (direction === "up" ? -1 : 1);
+ if (newIndex >= 0 && newIndex < items.length) {
+ select(newIndex);
+ setRoute("projects", getId(selectedIndex));
+ }
+}
+
+function select(index: number): void {
+ selectedIndex = index;
+ setActiveByIndex("#projects .list-item", index, "selected");
+ updateDetail(index);
+
+ const items = $$("#projects .list-item");
+ scrollIntoViewSmooth(items[index]);
+}
+
+function setupClicks(): void {
+ $$("#projects .list-item").forEach((item, i) => {
+ item.addEventListener("click", () => select(i));
+ });
+}
+
+function updateDetail(index: number): void {
+ $$("#projects .detail-content").forEach((content) => {
+ content.classList.toggle(
+ "hidden",
+ parseInt(content.getAttribute("data-for") || "0") !== index,
+ );
+ });
+}
+
+function getId(index: number): string {
+ const item = $$("#projects .list-item")[index];
+ return item?.getAttribute("data-id") || String(index);
+}
+
+function getIndexById(id: string): number {
+ const items = $$("#projects .list-item");
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].getAttribute("data-id") === id) return i;
+ }
+ return 0;
+}
diff --git a/web/src/ui/boot.ts b/web/src/ui/boot.ts
@@ -0,0 +1,101 @@
+import env from "../constants/env";
+import {
+ FADE_DURATION,
+ GLITCH_CHARS,
+ HOLD_DURATION,
+ SCRAMBLE_DURATION,
+ SCRAMBLE_INTERVAL,
+ SUBTITLE,
+ TITLE,
+} from "../constants/ui/boot";
+import { sleep } from "../utils/sleep";
+
+export async function playBootAnimation(): Promise<void> {
+ const BOOT_KEY = "prime8dev-boot-shown";
+ const app = document.getElementById("app");
+
+ if (sessionStorage.getItem(BOOT_KEY)) {
+ if (app) {
+ app.classList.add("ready");
+ }
+ return;
+ }
+
+ const overlay = createBootOverlay();
+
+ await runBootSequence(overlay);
+
+ if (env === "prod") {
+ sessionStorage.setItem(BOOT_KEY, "1");
+ }
+
+ if (app) {
+ app.classList.add("ready", "boot-reveal");
+ }
+}
+
+function createBootOverlay(): HTMLElement {
+ const overlay = document.createElement("div");
+ overlay.id = "boot-overlay";
+ overlay.innerHTML = `
+ <div class="boot-terminal">
+ <div class="boot-text">
+ <span class="boot-title"></span>
+ <span class="boot-subtitle"></span>
+ </div>
+ </div>
+ `;
+ document.body.appendChild(overlay);
+ return overlay;
+}
+
+async function runBootSequence(overlay: HTMLElement): Promise<void> {
+ const title = overlay.querySelector(".boot-title") as HTMLElement;
+ const subtitle = overlay.querySelector(".boot-subtitle") as HTMLElement;
+
+ // Initialize with scrambled text
+ title.textContent = scrambleText(TITLE.length);
+ subtitle.textContent = scrambleText(SUBTITLE.length);
+
+ await sleep(100);
+
+ // Reveal name first, then title
+ await scrambleReveal(title, TITLE, SCRAMBLE_DURATION);
+ await scrambleReveal(subtitle, SUBTITLE, SCRAMBLE_DURATION);
+
+ await sleep(HOLD_DURATION);
+
+ overlay.classList.add("fade-out");
+ await sleep(FADE_DURATION);
+
+ overlay.remove();
+}
+
+function scrambleText(length: number): string {
+ return Array.from({ length }, () => {
+ const index = Math.floor(Math.random() * GLITCH_CHARS.length);
+ return GLITCH_CHARS[index];
+ }).join("");
+}
+
+async function scrambleReveal(
+ element: HTMLElement,
+ target: string,
+ duration: number,
+): Promise<void> {
+ const steps = Math.floor(duration / SCRAMBLE_INTERVAL);
+ const length = target.length;
+
+ for (let i = 0; i <= steps; i++) {
+ const progress = i / steps;
+ const revealedCount = Math.floor(progress * length);
+
+ const revealed = target.slice(0, revealedCount);
+ const scrambled = scrambleText(length - revealedCount);
+
+ element.textContent = revealed + scrambled;
+ await sleep(SCRAMBLE_INTERVAL);
+ }
+
+ element.textContent = target;
+}
diff --git a/web/src/ui/dom.ts b/web/src/ui/dom.ts
@@ -0,0 +1,37 @@
+export function $(
+ selector: string,
+ parent: Element | Document = document,
+): Element | null {
+ return parent.querySelector(selector);
+}
+
+export function $$(
+ selector: string,
+ parent: Element | Document = document,
+): Element[] {
+ return Array.from(parent.querySelectorAll(selector));
+}
+
+export function toggleClass(
+ elements: Element | Element[],
+ className: string,
+ predicate: (el: Element, index: number) => boolean,
+): void {
+ const els = elements instanceof Element ? [elements] : Array.from(elements);
+ els.forEach((el, i) => el.classList.toggle(className, predicate(el, i)));
+}
+
+export function setActiveByIndex(
+ selector: string,
+ index: number,
+ className = "selected",
+): void {
+ $$(selector).forEach((el, i) => el.classList.toggle(className, i === index));
+}
+
+export function scrollIntoViewSmooth(
+ element: Element | null,
+ block: ScrollLogicalPosition = "nearest",
+): void {
+ element?.scrollIntoView({ behavior: "smooth", block });
+}
diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts
@@ -0,0 +1,25 @@
+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/keyboard.ts b/web/src/ui/keyboard.ts
@@ -0,0 +1,37 @@
+const handlers: Map<string, KeyHandler> = new Map();
+
+export type KeyHandler = (e: KeyboardEvent) => void;
+
+export function initKeyboard(): void {
+ document.addEventListener("keydown", (e) => {
+ if (shouldIgnoreKeydown(e)) return;
+
+ const handler = handlers.get(e.key);
+ if (handler) handler(e);
+ });
+}
+
+export interface KeybindHandle {
+ unsubscribe: () => void;
+}
+
+export function onKey(key: string, handler: KeyHandler): KeybindHandle {
+ handlers.set(key, handler);
+
+ return {
+ unsubscribe: () => {
+ handlers.delete(key);
+ },
+ };
+}
+
+export function clearAllHandlers(): void {
+ handlers.clear();
+}
+
+function shouldIgnoreKeydown(e: KeyboardEvent): boolean {
+ return (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ );
+}
diff --git a/web/src/ui/router.ts b/web/src/ui/router.ts
@@ -0,0 +1,42 @@
+type RouteHandler = (route: { tab: string; id?: string }) => void;
+
+let currentRoute: { tab: string; id?: string } = { tab: "about" };
+
+export function initRouter(handler: RouteHandler): void {
+ // Load initial route
+ currentRoute = parseHash();
+ handler(currentRoute);
+
+ // Handle back/forward
+ window.addEventListener("popstate", () => {
+ currentRoute = parseHash();
+ handler(currentRoute);
+ });
+}
+
+export function getRoute(): { tab: string; id?: string } {
+ return { ...currentRoute };
+}
+
+export function setRoute(tab: string, id?: string): void {
+ currentRoute = { tab, id };
+
+ const hash = id ? `#${tab}/${id}` : `#${tab}`;
+ if (window.location.hash !== hash) {
+ history.pushState(null, "", hash);
+ }
+}
+
+function parseHash(): { tab: string; id?: string } {
+ const hash = window.location.hash.slice(1);
+ if (!hash) return { tab: "about" };
+
+ const [tab, id] = hash.split("/");
+ const validTabs = ["about", "experience", "projects", "blog"];
+
+ if (validTabs.includes(tab)) {
+ return { tab, id };
+ }
+
+ return { tab: "about" };
+}
diff --git a/web/src/ui/status.ts b/web/src/ui/status.ts
@@ -0,0 +1,25 @@
+export function initStatus(): void {
+ const el = document.getElementById("status-time");
+ if (!el) return;
+
+ updateStatusTime();
+ setInterval(updateStatusTime, 1000);
+}
+
+function updateStatusTime(): void {
+ const el = document.getElementById("status-time");
+ if (!el) return;
+
+ const now = new Date();
+ const time = now.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ });
+ const date = now.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ });
+ el.textContent = `${date} ${time}`;
+}
diff --git a/web/src/ui/tabs.ts b/web/src/ui/tabs.ts
@@ -0,0 +1,170 @@
+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);
+}
+
+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) => {
+ e.preventDefault();
+ cycleTab("prev");
+ });
+ onKey("ArrowRight", (e) => {
+ 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/lib/utils/duration.ts b/web/src/utils/duration.ts
diff --git a/web/src/utils/markdown.ts b/web/src/utils/markdown.ts
@@ -0,0 +1,149 @@
+import { marked } from "marked";
+import type { Tokens, RendererThis } from "marked";
+
+export function parseMarkdown(markdown: string): string {
+ return marked.parse(markdown) as string;
+}
+
+export function removeFirstH1(markdown: string): string {
+ return markdown.replace(/^#\s+.+\n+/, "");
+}
+
+const renderer = {
+ heading(this: RendererThis, token: Tokens.Heading): string {
+ const level = token.depth;
+ const tag = `h${level + 1}`;
+ const text = this.parser.parseInline(token.tokens);
+ return `<${tag} class="readme-h${level}">${text}</${tag}>`;
+ },
+
+ code(token: Tokens.Code): string {
+ const escaped = token.text.replace(/</g, "<").replace(/>/g, ">");
+ return `<pre class="readme-code"><code>${escaped}</code></pre>`;
+ },
+
+ codespan(token: Tokens.Codespan): string {
+ const escaped = token.text.replace(/</g, "<").replace(/>/g, ">");
+ return `<code class="readme-inline-code">${escaped}</code>`;
+ },
+
+ link(this: RendererThis, token: Tokens.Link): string {
+ const text = this.parser.parseInline(token.tokens);
+ return `<a href="${token.href}" target="_blank" rel="noopener noreferrer" class="readme-link">${text}</a>`;
+ },
+
+ image(token: Tokens.Image): string {
+ return `<img src="${token.href}" alt="${token.text || ""}" class="readme-img" loading="lazy" />`;
+ },
+
+ list(this: RendererThis, token: Tokens.List): string {
+ function renderList(
+ parser: RendererThis["parser"],
+ token: Tokens.List,
+ ): string {
+ const tag = token.ordered ? "ol" : "ul";
+ const items = token.items
+ .map((item) => {
+ let content = "";
+ for (const tok of item.tokens) {
+ if (tok.type === "paragraph" && "tokens" in tok && tok.tokens) {
+ content += parser.parseInline(tok.tokens as Tokens.Generic[]);
+ } else if (tok.type === "text") {
+ const textToken = tok as Tokens.Text;
+ if (textToken.tokens && textToken.tokens.length > 0) {
+ content += parser.parseInline(textToken.tokens);
+ } else {
+ const inlineTokens = marked.lexer(
+ textToken.raw || textToken.text,
+ { gfm: true },
+ );
+ if (
+ inlineTokens.length > 0 &&
+ inlineTokens[0].type === "paragraph" &&
+ "tokens" in inlineTokens[0]
+ ) {
+ content += parser.parseInline(
+ (inlineTokens[0] as Tokens.Paragraph).tokens,
+ );
+ } else {
+ content += textToken.text || "";
+ }
+ }
+ } else if (tok.type === "list") {
+ content += renderList(parser, tok as Tokens.List);
+ } else {
+ content += parser.parse([tok as Tokens.Generic]);
+ }
+ }
+ return `<li>${content}</li>`;
+ })
+ .join("");
+ return `<${tag} class="readme-list">${items}</${tag}>`;
+ }
+
+ return renderList(this.parser, token);
+ },
+
+ paragraph(this: RendererThis, token: Tokens.Paragraph): string {
+ const text = this.parser.parseInline(token.tokens);
+ return `<p class="readme-p">${text}</p>`;
+ },
+
+ table(this: RendererThis, token: Tokens.Table): string {
+ const headerCells = token.header
+ .map((h) => {
+ const text = this.parser.parseInline(h.tokens as Tokens.Generic[]);
+ return `<th>${text}</th>`;
+ })
+ .join("");
+ const bodyRows = token.rows
+ .map((row) => {
+ const cells = row
+ .map((cell) => {
+ const text = this.parser.parseInline(
+ cell.tokens as Tokens.Generic[],
+ );
+ return `<td>${text}</td>`;
+ })
+ .join("");
+ return `<tr>${cells}</tr>`;
+ })
+ .join("");
+ return `<table class="readme-table"><thead><tr>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
+ },
+
+ blockquote(this: RendererThis, token: Tokens.Blockquote): string {
+ const text = this.parser.parse(token.tokens);
+ return `<blockquote class="readme-blockquote">${text}</blockquote>`;
+ },
+
+ hr(): string {
+ return "<hr />";
+ },
+
+ strong(this: RendererThis, token: Tokens.Strong): string {
+ const text = this.parser.parseInline(token.tokens);
+ return `<strong>${text}</strong>`;
+ },
+
+ em(this: RendererThis, token: Tokens.Em): string {
+ const text = this.parser.parseInline(token.tokens);
+ return `<em>${text}</em>`;
+ },
+
+ del(this: RendererThis, token: Tokens.Del): string {
+ const text = this.parser.parseInline(token.tokens);
+ return `<del>${text}</del>`;
+ },
+
+ text(token: Tokens.Text | Tokens.Escape): string {
+ return token.text;
+ },
+};
+
+marked.use({ renderer });
+
+marked.setOptions({
+ gfm: true,
+ breaks: true,
+});
diff --git a/web/src/utils/period.ts b/web/src/utils/period.ts
@@ -0,0 +1,27 @@
+import { MONTH_NAMES } from "../constants/calendar";
+
+export function formatPeriod(startDate: string, endDate?: string): string {
+ const months = MONTH_NAMES.map((m) => m.slice(0, 3));
+
+ const [startYear, startMonth] = startDate.split("-").map(Number);
+ const startStr = `${months[startMonth - 1]} ${startYear}`;
+
+ if (!endDate) {
+ const totalMonths = calculateMonths(startDate);
+ return `${startStr} – Present (${totalMonths} mos)`;
+ }
+
+ const [endYear, endMonth] = endDate.split("-").map(Number);
+ const endStr = `${months[endMonth - 1]} ${endYear}`;
+ const totalMonths = calculateMonths(startDate, endDate);
+ return `${startStr} – ${endStr} (${totalMonths} mos)`;
+}
+
+export function calculateMonths(startDate: string, endDate?: string): number {
+ const [startYear, startMonth] = startDate.split("-").map(Number);
+ const end = endDate
+ ? endDate.split("-").map(Number)
+ : [new Date().getFullYear(), new Date().getMonth() + 1];
+ const [endYear, endMonth] = end;
+ return (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
+}
diff --git a/web/src/utils/sleep.ts b/web/src/utils/sleep.ts
@@ -0,0 +1,3 @@
+export function sleep(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/web/src/utils/tags.ts b/web/src/utils/tags.ts
@@ -0,0 +1,16 @@
+import { TAG_CLASS_MAP, TECH_ABBREVIATIONS } from "../constants/ui/tags";
+
+export function getTagClass(tag: string): string {
+ const className = TAG_CLASS_MAP[tag];
+ if (!className) {
+ throw new Error(`Tag ${tag} not found`);
+ }
+ return className;
+}
+
+export function getTechMeta(tech: string[]): string {
+ return tech
+ .slice(0, 2)
+ .map((t) => TECH_ABBREVIATIONS[t] || t)
+ .join(", ");
+}