commit c8ee8b9dc58058d6a368edbd3f415d518580b928
parent edb656807b225d9d684abfe7bbb8fb7e7f7a8902
Author: Damian Myrda <damian@prime8.dev>
Date: Thu, 22 Jan 2026 15:07:49 -0600
feat: tabbed ui, experience timeline, glitch boot and mobile layout
Diffstat:
96 files changed, 3987 insertions(+), 4223 deletions(-)
diff --git a/.zed/tasks.json b/.zed/tasks.json
@@ -0,0 +1,8 @@
+[
+ {
+ "label": "web: dev",
+ "command": "bun",
+ "args": ["run", "dev"],
+ "cwd": "$ZED_WORKTREE_ROOT/web",
+ },
+]
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,38 +0,0 @@
-[
- {
- "id": "janitor",
- "name": "janitor",
- "subtitle": "Audio classification with AST",
- "tech": ["Rust", "CUDA"],
- "url": "https://git.prime8.dev/moncheeta/janitor",
- "dateCreated": "2025-09-15",
- "dateModified": "2026-01-10"
- },
- {
- "id": "summarizer",
- "name": "summarizer",
- "subtitle": "Audio transcription microservices",
- "tech": ["Python", "Docker", "Kafka"],
- "url": "https://git.prime8.dev/moncheeta/summarizer",
- "dateCreated": "2025-06-01",
- "dateModified": "2025-12-20"
- },
- {
- "id": "finger",
- "name": "finger",
- "subtitle": "Mobile auth app",
- "tech": ["TypeScript", "React Native", "Supabase"],
- "url": "https://git.prime8.dev/moncheeta/finger",
- "dateCreated": "2025-03-10",
- "dateModified": "2025-08-15"
- },
- {
- "id": "computer-science-club",
- "name": "computer-science-club",
- "subtitle": "CS Club website",
- "tech": ["Python"],
- "url": "https://github.com/moncheeta/computer-science-club",
- "dateCreated": "2024-09-01",
- "dateModified": "2025-05-20"
- }
-]
diff --git a/web/public/favicon.png b/web/public/favicon.png
Binary files differ.
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,418 +0,0 @@
----
-import type Blog from "../lib/types/blog";
-
-interface Props {
- blog: Blog;
- isStandalone?: boolean;
-}
-
-const { blog, isStandalone = true } = Astro.props;
----
-
-<article class="blog-article" data-id={blog.id} data-blog-id={blog.id}>
- {
- isStandalone ? (
- <a href="/#blog" class="blog-back-btn">
- ← See all posts
- </a>
- ) : (
- <button class="blog-back-btn blog-back-btn-internal">
- <span class="color-dim">← Back to posts</span>
- </button>
- )
- }
- <header class="blog-article-header">
- <div class="blog-article-header-top">
- <h1 class="blog-article-title">{blog.title}</h1>
- <button
- class="blog-share-btn"
- data-blog-id={blog.id}
- data-blog-title={blog.title}
- >
- [Share]
- </button>
- </div>
- <time class="blog-article-date" datetime={blog.date}>{blog.date}</time>
- </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-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);
- }
-
- .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-date {
- color: var(--fg-dim);
- font-size: 13px;
- }
-
- .blog-article-content {
- font-size: 14px;
- line-height: 1.7;
- color: var(--fg);
- padding-bottom: 64px;
- }
-
- .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-back-btn {
- padding: 6px 12px;
- font-size: 11px;
- margin-bottom: 16px;
- }
-
- .blog-article-header {
- margin-bottom: 16px;
- padding-bottom: 12px;
- }
-
- .blog-article-header-top {
- flex-direction: column;
- gap: 12px;
- }
-
- .blog-article-title {
- font-size: 22px;
- }
-
- .blog-share-btn {
- align-self: flex-start;
- }
-
- .blog-article-content {
- font-size: 13px;
- padding-bottom: 32px;
- }
-
- .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>
- import { closeBlogPost } from "../scripts/ui/blog";
- import { updateUrl } from "../scripts/ui/url";
-
- 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);
- });
- }
-
- document.querySelectorAll(".blog-back-btn-internal").forEach((btn) => {
- btn.addEventListener("click", () => {
- closeBlogPost();
- updateUrl();
- });
- });
- });
-</script>
diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro
@@ -1,51 +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;
- flex-shrink: 0;
- }
-
- #statusbar .time {
- color: var(--cyan);
- }
-
- #statusbar .spacer {
- flex: 1;
- }
-
- #statusbar .wakatime {
- color: var(--magenta);
- }
-
- @media (max-width: 768px) {
- #statusbar {
- font-size: 11px;
- padding: 0 10px;
- }
- }
-</style>
diff --git a/web/src/components/Header.astro b/web/src/components/Header.astro
@@ -1,140 +0,0 @@
-<header id="tabs">
- <nav class="tab-nav" aria-label="Main navigation">
- <div class="tab active" data-tab="about" aria-current="page">[1] About</div>
- <div class="tab" data-tab="experience">[2] Experience</div>
- <div class="tab" data-tab="projects">[3] Projects</div>
- <div class="tab" data-tab="blog">[4] 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;
- flex-shrink: 0;
- position: relative;
- z-index: 10;
- -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;
- }
- }
-</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/pages/About.astro b/web/src/components/pages/About.astro
@@ -1,393 +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"
- width="280"
- height="280"
- 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;
- }
-
- .box-title {
- color: var(--accent);
- font-size: 12px;
- margin-bottom: 8px;
- text-align: center;
- }
-
- .contact-item {
- display: flex;
- margin-bottom: 4px;
- font-size: 12px;
- }
-
- .contact-item .label {
- color: var(--fg-dim);
- width: 70px;
- flex-shrink: 0;
- }
-
- .contact-item a {
- color: var(--light-blue);
- text-decoration: none;
- }
-
- .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 {
- width: 100%;
- border-right: none;
- border-bottom: 1px solid var(--border);
- padding: 16px;
- flex-direction: row;
- flex-wrap: wrap;
- gap: 16px;
- }
-
- .profile-image {
- width: 120px;
- max-width: 120px;
- height: 120px;
- }
-
- .contact-box {
- flex: 1;
- min-width: 200px;
- }
-
- .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) {
- .about-left {
- flex-direction: column;
- align-items: center;
- }
-
- .profile-image {
- width: 150px;
- max-width: 150px;
- height: 150px;
- }
-
- .contact-box {
- width: 100%;
- }
-
- .contact-item {
- flex-direction: column;
- gap: 2px;
- }
-
- .contact-item .label {
- width: auto;
- }
- }
-</style>
diff --git a/web/src/components/pages/Blog.astro b/web/src/components/pages/Blog.astro
@@ -1,205 +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">
- {blogs.map((blog, i) => (
- <div
- class="blog-post hidden"
- data-index={i}
- data-id={blog.id}
- data-blog-id={blog.id}
- >
- <BlogArticle blog={blog} isStandalone={false} />
- </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;
- }
-
- @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;
- }
- }
-
- @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/components/pages/Experience.astro b/web/src/components/pages/Experience.astro
@@ -1,628 +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) =>
- a.startDate.localeCompare(b.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="timeline-container">
- <div class="timeline-years">
- {years.map((year) => <span class="year-marker">{year}</span>)}
- </div>
-
- <div class="timeline-bar">
- {
- experience.map((exp) => {
- const [startYear, startMonth] = exp.startDate
- .split("-")
- .map(Number);
- const expStart = new Date(startYear, startMonth - 1, 1);
- const expEnd = exp.endDate
- ? new Date(
- parseInt(exp.endDate.split("-")[0]),
- parseInt(exp.endDate.split("-")[1]) - 1,
- 28,
- )
- : new Date();
-
- const startOffset = Math.max(
- 0,
- (expStart.getTime() - timelineStart.getTime()) /
- (1000 * 60 * 60 * 24),
- );
- const duration =
- (expEnd.getTime() - expStart.getTime()) / (1000 * 60 * 60 * 24);
-
- const leftPercent = (startOffset / totalDays) * 100;
- const widthPercent = (duration / totalDays) * 100;
- const months = calculateMonths(exp.startDate, exp.endDate);
-
- const originalIndex = experience.findIndex((e) => e.id === exp.id);
-
- return (
- <div
- class={`timeline-segment ${originalIndex === currentRoleIndex ? "selected" : ""}`}
- style={`left: ${leftPercent}%; width: ${widthPercent}%;`}
- data-index={originalIndex}
- data-id={exp.id}
- >
- <div class="segment-content">
- <span class="segment-company">{exp.company}</span>
- <span class="segment-duration">{months} mos</span>
- </div>
- </div>
- );
- })
- }
- </div>
-
- <div class="timeline-ticks">
- {monthTicks.map((m) => <span class="tick-mark">{m}</span>)}
- </div>
- </div>
-
- <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";
-
- return (
- <div
- class={`exp-card ${index !== currentRoleIndex ? "hidden" : ""}`}
- data-for={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>
- </article>
-</section>
-
-<script>
- document.addEventListener("DOMContentLoaded", () => {
- 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: column;
- height: 100%;
- width: 100%;
- padding: 32px 48px;
- gap: 24px;
- }
-
- .timeline-container {
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
-
- .timeline-years {
- display: flex;
- justify-content: space-between;
- padding: 0 4px;
- font-size: 12px;
- color: var(--fg-dim);
- }
-
- .year-marker {
- font-weight: 500;
- }
-
- .timeline-bar {
- position: relative;
- height: 48px;
- background: var(--bg-alt);
- border: 1px solid var(--border);
- border-radius: 4px;
- }
-
- .timeline-segment {
- position: absolute;
- top: 4px;
- bottom: 4px;
- background: var(--border);
- border-radius: 3px;
- cursor: pointer;
- transition: all 0.2s ease;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- }
-
- .timeline-segment:hover {
- background: var(--fg-dim);
- }
-
- .timeline-segment.selected {
- background: var(--accent);
- box-shadow:
- 0 0 0 2px var(--accent),
- 0 0 12px rgba(215, 175, 95, 0.3);
- }
-
- .segment-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 2px;
- padding: 0 8px;
- min-width: 0;
- }
-
- .segment-company {
- font-size: 11px;
- font-weight: 600;
- color: var(--bg);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 100%;
- }
-
- .segment-duration {
- font-size: 9px;
- color: var(--bg);
- opacity: 0.8;
- }
-
- .timeline-segment.selected .segment-company,
- .timeline-segment.selected .segment-duration {
- color: var(--bg);
- }
-
- .timeline-ticks {
- display: flex;
- justify-content: space-between;
- padding: 0 2px;
- font-size: 9px;
- color: var(--fg-dim);
- opacity: 0.6;
- }
-
- .tick-mark {
- width: 5%;
- text-align: center;
- }
-
- .experience-detail {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- }
-
- .exp-card {
- background: var(--bg-alt);
- border: 1px solid var(--border);
- border-radius: 4px;
- padding: 24px;
- width: 100%;
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 0;
- animation: slideUp 0.2s ease;
- }
-
- @keyframes slideUp {
- from {
- opacity: 0;
- transform: translateY(8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
-
- .exp-card.hidden {
- display: none;
- }
-
- .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: 16px;
- margin-top: 12px;
- padding-top: 12px;
- border-top: 1px solid var(--border);
- flex: 1;
- min-height: 0;
- overflow: hidden;
- }
-
- .exp-image-hero {
- flex: 1;
- min-width: 0;
- 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 {
- width: 180px;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
-
- .exp-image-thumb {
- width: 100%;
- flex: 1;
- min-height: 50px;
- 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: 16px;
- }
-
- .timeline-container {
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- }
-
- .timeline-bar {
- min-width: 500px;
- height: 40px;
- }
-
- .timeline-years {
- min-width: 500px;
- font-size: 11px;
- }
-
- .timeline-ticks {
- min-width: 500px;
- font-size: 8px;
- }
-
- .segment-content {
- padding: 0 4px;
- }
-
- .segment-company {
- font-size: 9px;
- }
-
- .segment-duration {
- font-size: 8px;
- }
-
- .exp-card {
- padding: 16px;
- }
-
- .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;
- }
-
- .exp-image-hero {
- height: auto;
- max-height: 200px;
- }
-
- .exp-image-grid {
- width: 100%;
- flex-direction: row;
- flex-wrap: wrap;
- gap: 8px;
- }
-
- .exp-image-thumb {
- width: calc(33.33% - 6px);
- min-height: 60px;
- max-height: 80px;
- }
- }
-
- @media (max-width: 480px) {
- .timeline-bar {
- height: 36px;
- }
-
- .exp-image-thumb {
- width: calc(50% - 4px);
- }
- }
-</style>
diff --git a/web/src/components/pages/Projects.astro b/web/src/components/pages/Projects.astro
@@ -1,404 +0,0 @@
----
-import rawProjects from "../../../config/projects.json";
-import type Project from "../../lib/types/project";
-import { getTagClass, getTechMeta } from "../../lib/utils/tags";
-
-const projects = rawProjects as Project[];
-
-interface Props {
- readmes?: Record<string, string>;
-}
-
-const { readmes } = Astro.props;
----
-
-<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}>
- {readmes?.[prg.id] ? (
- <Fragment set:html={readmes[prg.id]} />
- ) : (
- <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;
- 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/components/styles.css b/web/src/components/styles.css
@@ -1,111 +0,0 @@
-/* Tag colors */
-.tag {
- border: none;
- padding: 2px 8px;
- font-size: 12px;
- color: var(--bg);
- background: var(--fg-dim);
-}
-
-/* Language tags */
-.tag.lang-zig {
- background: #f7a41d;
-}
-.tag.lang-rust {
- background: #dea584;
-}
-.tag.lang-c {
- background: #a8b1c2;
-}
-.tag.lang-go {
- background: #00add8;
-}
-.tag.lang-ts {
- background: #3178c6;
- color: #fff;
-}
-.tag.lang-python {
- background: #3572a5;
- color: #fff;
-}
-
-/* Tool tags */
-.tag.tool-zed {
- background: #084ccf;
- color: #fff;
-}
-.tag.tool-neovim {
- background: #57a143;
-}
-.tag.tool-opencode {
- background: #af5faf;
- color: #fff;
-}
-.tag.tool-linux {
- background: #f7c94c;
-}
-.tag.tool-fish {
- background: #d78700;
-}
-.tag.tool-ghostty {
- background: #8b5cf6;
- color: #fff;
-}
-
-/* Interest tags */
-.tag.interest-lowlevel {
- background: #d75f5f;
- color: #fff;
-}
-.tag.interest-tui {
- background: #5faf5f;
- color: #0d0d0d;
-}
-.tag.interest-embedded {
- background: #5fafaf;
- color: #0d0d0d;
-}
-.tag.interest-unix {
- background: #d7af5f;
- color: #0d0d0d;
-}
-
-/* Tech tags */
-.tag.tech-temporal {
- background: #4c35c9;
- color: #fff;
-}
-.tag.tech-postgres {
- background: #336791;
- color: #fff;
-}
-.tag.tech-pytorch {
- background: #ee4c2c;
- color: #fff;
-}
-.tag.tech-cuda {
- background: #76b900;
-}
-.tag.tech-docker {
- background: #2496ed;
- color: #fff;
-}
-.tag.tech-kafka {
- background: #4c35c9;
- color: #fff;
-}
-.tag.tech-rn {
- background: #61dafb;
-}
-.tag.tech-supabase {
- background: #3ecf8e;
-}
-
-/* Utility classes */
-.color-dim {
- color: var(--fg-dim);
-}
-
-.hidden {
- display: none;
-}
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/env.ts b/web/src/constants/env.ts
@@ -0,0 +1 @@
+export default import.meta.env.NODE_ENV as "dev" | "prod";
diff --git a/web/src/constants/external/wakatime.ts b/web/src/constants/external/wakatime.ts
@@ -0,0 +1,2 @@
+export const WAKATIME_API_BASE = "https://wakatime.com/api/v1";
+export const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
diff --git a/web/src/constants/tags.ts b/web/src/constants/tags.ts
@@ -1,33 +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",
-};
-
-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;
@@ -335,6 +336,9 @@ const ogImage = image
.tag.tech-supabase {
background: #3ecf8e;
}
+ .tag.tech-rest {
+ background: #6b8e23;
+ }
@media (max-width: 768px) {
body {
@@ -356,7 +360,7 @@ const ogImage = image
.page {
height: auto;
- min-height: calc(100vh - 60px - 24px);
+ min-height: 100vh;
}
.page.active {
@@ -374,161 +378,38 @@ const ogImage = image
background: var(--bg);
z-index: 9999;
display: flex;
- align-items: flex-start;
- justify-content: flex-start;
- padding: 20px;
+ align-items: center;
+ justify-content: center;
overflow: hidden;
}
#boot-overlay.fade-out {
- animation: bootFadeOut 0.6s ease-out forwards;
- }
-
- .boot-scanline {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: linear-gradient(
- to bottom,
- transparent 50%,
- rgba(0, 0, 0, 0.1) 50%
- );
- background-size: 100% 4px;
- pointer-events: none;
- opacity: 0.3;
- z-index: 1;
+ animation: bootFadeOut 0.3s ease-out forwards;
}
.boot-terminal {
- font-family: "JetBrains Mono", monospace;
- font-size: 14px;
- color: var(--fg);
- line-height: 1.6;
- position: relative;
- z-index: 2;
- }
-
- .boot-content {
- white-space: pre-wrap;
- }
-
- .boot-line {
- transition:
- opacity 0.15s ease-out,
- transform 0.15s ease-out;
- }
-
- .boot-line-hidden {
- opacity: 0;
- transform: translateX(-10px);
- }
-
- .boot-line-visible {
- opacity: 1;
- transform: translateX(0);
- }
-
- .boot-cursor {
- display: inline;
- animation: blink 0.7s step-end infinite;
- color: #ffffff;
- }
-
- .boot-progress {
- margin-top: 16px;
- width: 200px;
- height: 4px;
- background: var(--border);
- border-radius: 2px;
- overflow: hidden;
- }
-
- .boot-progress-bar {
- height: 100%;
- width: 0;
- background: linear-gradient(90deg, var(--cyan), var(--accent));
- animation: progressFill 1s ease-out forwards;
- }
-
- @keyframes progressFill {
- 0% {
- width: 0;
- }
- 100% {
- width: 100%;
- }
+ text-align: center;
}
- /* ANSI colors */
- .ansi-bold-yellow {
- color: #d7af5f;
- font-weight: bold;
- }
-
- .ansi-bold-cyan {
- color: #5fafaf;
- font-weight: bold;
- }
-
- .ansi-accent {
- color: var(--accent);
- font-weight: bold;
+ .boot-text {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
}
- .ansi-bold-accent {
+ .boot-title {
+ font-family: "JetBrains Mono", "SF Mono", monospace;
+ font-size: 32px;
+ font-weight: 600;
color: var(--accent);
- font-weight: bold;
- }
-
- .ansi-bold-green {
- color: #5faf5f;
- font-weight: bold;
- }
-
- .ansi-white {
- color: #e0e0e0;
- }
-
- .ansi-dim {
- color: #606060;
- }
-
- /* Color blocks for neofetch */
- .ansi-block-black {
- background: #0d0d0d;
- }
- .ansi-block-red {
- background: #d75f5f;
- }
- .ansi-block-green {
- background: #5faf5f;
- }
- .ansi-block-yellow {
- background: #d7af5f;
- }
- .ansi-block-blue {
- background: #5f87d7;
- }
- .ansi-block-magenta {
- background: #af5faf;
- }
- .ansi-block-cyan {
- background: #5fafaf;
- }
- .ansi-block-white {
- background: #c0c0c0;
+ letter-spacing: -0.5px;
}
- @keyframes blink {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0;
- }
+ .boot-subtitle {
+ font-family: "JetBrains Mono", "SF Mono", monospace;
+ font-size: 14px;
+ color: var(--fg-dim);
+ letter-spacing: 0.5px;
}
@keyframes bootFadeOut {
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/render";
-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/readme/fetch.ts b/web/src/lib/readme/fetch.ts
@@ -1,132 +0,0 @@
-import { parseMarkdown, removeFirstH1 } from "./render";
-
-export async function fetchAllReadmes(): Promise<Record<string, string>> {
- const readmes: Record<string, string> = {};
-
- const results = await Promise.allSettled(
- README_CONFIGS.map(async (config) => {
- const result = await fetchWithFallback(config.sources);
- if (result) {
- const fixed = fixRelativePaths(result.content, result.baseUrl);
- const html = parseMarkdown(removeFirstH1(fixed));
- return { name: config.name, html };
- }
- return { name: config.name, html: "" };
- }),
- );
-
- for (const result of results) {
- if (result.status === "fulfilled" && result.value.html) {
- readmes[result.value.name] = result.value.html;
- }
- }
-
- return readmes;
-}
-
-export async function fetchReadmeForProject(
- projectId: string,
-): Promise<string | null> {
- const config = README_CONFIGS.find((c) => c.name === projectId);
- if (!config) return null;
-
- const result = await fetchWithFallback(config.sources);
- if (result) {
- const fixed = fixRelativePaths(result.content, result.baseUrl);
- return parseMarkdown(removeFirstH1(fixed));
- }
- return null;
-}
-
-async function fetchWithFallback(sources: ReadmeSource[]): Promise<{
- content: string;
- baseUrl: string;
-} | null> {
- for (const source of sources) {
- try {
- const response = await fetch(source.url, {
- headers: { "User-Agent": "prime8dev-portfolio" },
- });
- if (response.ok) {
- return {
- content: await response.text(),
- baseUrl: source.baseUrl,
- };
- }
- } catch {
- // Try next source
- }
- }
- return null;
-}
-
-function fixRelativePaths(markdown: string, baseUrl: 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;
-}
-
-interface ReadmeConfig {
- name: string;
- sources: ReadmeSource[];
-}
-
-interface ReadmeSource {
- url: string;
- baseUrl: string;
-}
-
-const README_CONFIGS: ReadmeConfig[] = [
- {
- name: "janitor",
- sources: [
- {
- url: "https://git.prime8.dev/moncheeta/janitor/-/raw/master/README.md",
- baseUrl: "https://git.prime8.dev/moncheeta/janitor/-/raw/master",
- },
- ],
- },
- {
- name: "summarizer",
- sources: [
- {
- url: "https://git.prime8.dev/moncheeta/summarizer/-/raw/master/README.md",
- baseUrl: "https://git.prime8.dev/moncheeta/summarizer/-/raw/master",
- },
- ],
- },
- {
- name: "finger",
- sources: [
- {
- url: "https://git.prime8.dev/moncheeta/finger/-/raw/master/README.md",
- baseUrl: "https://git.prime8.dev/moncheeta/finger/-/raw/master",
- },
- ],
- },
- {
- name: "computer-science-club",
- sources: [
- {
- url: "https://raw.githubusercontent.com/moncheeta/computer-science-club/main/README.md",
- baseUrl:
- "https://cdn.jsdelivr.net/gh/moncheeta/computer-science-club@main",
- },
- {
- url: "https://raw.githubusercontent.com/moncheeta/computer-science-club/master/README.md",
- baseUrl:
- "https://cdn.jsdelivr.net/gh/moncheeta/computer-science-club@master",
- },
- ],
- },
-];
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,45 +0,0 @@
-import type Project from "../types/project";
-import personSchema from "./person";
-import { URL } from "../../constants/website";
-
-export function getSchemaForProject(project: Project) {
- 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,
- ...(project.dateCreated && { dateCreated: project.dateCreated }),
- ...(project.dateModified && { dateModified: project.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/project.ts b/web/src/lib/types/project.ts
@@ -1,9 +0,0 @@
-export default interface Project {
- id: string;
- name: string;
- subtitle: string;
- tech: string[];
- url: string;
- dateCreated?: string;
- dateModified?: string;
-}
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,159 +0,0 @@
-import { DAY_NAMES, MONTH_NAMES } from "../constants/calendar";
-import { formatDuration } from "./utils/duration";
-
-const WAKATIME_API_BASE = "https://wakatime.com/api/v1";
-
-const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
-
-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/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
@@ -27,7 +30,8 @@ const keywords = blog.tags?.join(", ");
>
<main id="content">
<div class="blog-page">
- <BlogArticle blog={blog} />
+ <a href="/#blog" class="blog-back-btn">← See more posts</a>
+ <Article blog={blog} />
</div>
<div id="blog-data" data-blog-id={blog.id}></div>
<script
@@ -62,16 +66,52 @@ const keywords = blog.tags?.join(", ");
.blog-page {
max-width: 720px;
margin: 0 auto;
- padding: 40px 24px;
+ padding: 40px 24px 0;
min-height: 100vh;
}
+
+ .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-back-btn {
+ padding: 6px 12px;
+ font-size: 11px;
+ margin-bottom: 16px;
+ }
+ }
</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
@@ -45,7 +42,7 @@ const description = `${experience.role} at ${experience.company}. ${experience.d
>
<main id="content">
<div class="experience-page">
- <a href="/#experience" class="back-btn">← Back to experience</a>
+ <a href="/#experience" class="back-btn">← See more experiences</a>
<header class="experience-header">
<div class="header-top">
@@ -265,13 +262,14 @@ const description = `${experience.role} at ${experience.company}. ${experience.d
.image-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
- gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 12px;
}
.experience-image {
width: 100%;
- height: auto;
+ max-height: 160px;
+ object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border);
cursor: pointer;
@@ -303,7 +301,28 @@ const description = `${experience.role} at ${experience.company}. ${experience.d
}
.image-grid {
- grid-template-columns: 1fr;
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .experience-image {
+ max-height: 120px;
}
}
</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,25 +1,20 @@
---
import Layout from "../layouts/Layout.astro";
-import { fetchWakaTimeData } from "../lib/wakatime";
-import { fetchAllReadmes } from "../lib/readme/fetch";
-
-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 schemas from "../schemas/index";
+
+const wakaTimeData = await fetchWakaTimeData();
-import Header from "../components/Header.astro";
-import AboutPage from "../components/pages/About.astro";
-import ExperiencePage from "../components/pages/Experience.astro";
-import ProjectsPage from "../components/pages/Projects.astro";
-import BlogPage from "../components/pages/Blog.astro";
-import Footer from "../components/Footer.astro";
-
-const [wakaTimeData, readmes] = await Promise.all([
- fetchWakaTimeData(),
- fetchAllReadmes(),
-]);
+const { personSchema, websiteSchema } = schemas;
---
<Layout
@@ -42,10 +37,10 @@ const [wakaTimeData, readmes] = await Promise.all([
<Header />
<main id="content">
- <AboutPage wakaTimeData={wakaTimeData} />
- <ExperiencePage />
- <ProjectsPage readmes={readmes} />
- <BlogPage isStandalone={false} />
+ <AboutTab wakaTimeData={wakaTimeData} />
+ <ExperienceTab />
+ <ProjectsTab />
+ <BlogTab />
</main>
<Footer wakaTimeData={wakaTimeData} />
@@ -55,7 +50,7 @@ const [wakaTimeData, readmes] = await Promise.all([
</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,14 @@
---
import Layout from "../../layouts/Layout.astro";
-import rawProjects from "../../../config/projects.json";
-import type Project from "../../lib/types/project";
+
+import { projects } from "../../config";
+import { fetchGitMetadataForProject } from "../../external/git";
import { URL } from "../../constants/website";
-import { getTagClass } from "../../lib/utils/tags";
-import { fetchReadmeForProject } from "../../lib/readme/fetch";
-import {
- getSchemaForProject,
- getBreadcrumbSchema,
-} from "../../lib/schemas/project";
-
-const projects = rawProjects as Project[];
-const project = projects.find((p) => p.id === Astro.params.id);
+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, {
status: 404,
@@ -20,8 +16,11 @@ if (!project) {
});
}
-const readme = await fetchReadmeForProject(project.id);
const keywords = project.tech.join(", ");
+
+const metadata = await fetchGitMetadataForProject(project);
+
+const { getSchemaForProject, getBreadcrumbSchema } = schemas.project;
---
<Layout
@@ -33,7 +32,7 @@ const keywords = project.tech.join(", ");
>
<main id="content">
<div class="project-page">
- <a href="/#projects" class="back-btn">← Back to projects</a>
+ <a href="/#projects" class="back-btn">← See more projects</a>
<header class="project-header">
<div class="header-top">
@@ -59,8 +58,8 @@ const keywords = project.tech.join(", ");
<div class="readme-content">
{
- readme ? (
- <Fragment set:html={readme} />
+ metadata?.readme ? (
+ <Fragment set:html={parseMarkdown(metadata.readme)} />
) : (
<p class="color-dim">README not available</p>
)
@@ -69,7 +68,9 @@ const keywords = project.tech.join(", ");
</div>
<script
type="application/ld+json"
- set:html={JSON.stringify(getSchemaForProject(project))}
+ set:html={JSON.stringify(
+ getSchemaForProject(project, metadata ?? undefined),
+ )}
/>
<script
type="application/ld+json"
@@ -169,6 +170,7 @@ const keywords = project.tech.join(", ");
font-size: 14px;
line-height: 1.7;
color: var(--fg);
+ padding-right: 12px;
}
.readme-content :global(p) {
@@ -273,3 +275,20 @@ const keywords = project.tech.join(", ");
}
}
</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,36 +1,41 @@
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 "../external/git";
+import { experience, projects } from "../config";
+import { blogs } from "../blogs";
-const projects = rawProjects as Project[];
-const experiences = rawExperience as Experience[];
-const siteUrl = "https://prime8.dev";
-
-export const GET: APIRoute = () => {
+export const GET: APIRoute = async () => {
const now = new Date().toISOString().split("T")[0];
+ const metadata = Object.fromEntries(
+ (
+ await Promise.all(
+ projects.map(async (project) => [
+ project.id,
+ await fetchGitMetadataForProject(project),
+ ]),
+ )
+ ).filter(([, meta]) => meta !== null),
+ );
+
// Static pages
const staticPages = [
{ url: "/", changefreq: "weekly", priority: "1.0", lastmod: now },
];
// 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: project.dateModified || project.dateCreated || 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
@@ -53,7 +58,7 @@ export const GET: APIRoute = () => {
${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,177 +0,0 @@
-interface SystemInfo {
- platform: string;
- browser: string;
- screen: string;
- locale: string;
- uptime: string;
- timezone: string;
-}
-
-function getSystemInfo(): SystemInfo {
- const ua = navigator.userAgent;
-
- let platform = "Unknown";
- if (ua.includes("Mac")) platform = "Darwin";
- else if (ua.includes("Win")) platform = "Windows";
- else if (ua.includes("Linux")) platform = "Linux";
- else if (ua.includes("Android")) platform = "Android";
- else if (ua.includes("iPhone") || ua.includes("iPad")) platform = "iOS";
-
- let browser = "Unknown";
- if (ua.includes("Firefox")) browser = "Firefox";
- else if (ua.includes("Edg")) browser = "Edge";
- else if (ua.includes("Chrome")) browser = "Chrome";
- else if (ua.includes("Safari")) browser = "Safari";
-
- const screen = `${window.screen.width}x${window.screen.height}`;
- const locale = navigator.language || "en-US";
-
- const now = new Date();
- const hours = now.getHours().toString().padStart(2, "0");
- const minutes = now.getMinutes().toString().padStart(2, "0");
- const seconds = now.getSeconds().toString().padStart(2, "0");
- const uptime = `${hours}:${minutes}:${seconds}`;
-
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
-
- return { platform, browser, screen, locale, uptime, timezone };
-}
-
-function generateNeofetch(sys: SystemInfo): string[] {
- const logo = [
- " .--. ",
- " |o_o | ",
- " |:_/ | ",
- " // \\ \\ ",
- " (| | ) ",
- " /'\\_ _/`\\ ",
- " \\___)=(___/ ",
- ];
-
- const info = [
- `<span class="ansi-bold-accent">prime8</span>@<span class="ansi-bold-accent">dev</span>`,
- '<span class="ansi-dim">──────────────</span>',
- `<span class="ansi-bold-accent">OS</span>: <span class="ansi-white">${sys.platform}</span>`,
- `<span class="ansi-bold-accent">Browser</span>: <span class="ansi-white">${sys.browser}</span>`,
- `<span class="ansi-bold-accent">Resolution</span>: <span class="ansi-white">${sys.screen}</span>`,
- `<span class="ansi-bold-accent">Time</span>: <span class="ansi-white">${sys.uptime}</span>`,
- `<span class="ansi-bold-accent">Timezone</span>: <span class="ansi-white">${sys.timezone}</span>`,
- `<span class="ansi-bold-accent">Locale</span>: <span class="ansi-white">${sys.locale}</span>`,
- ];
-
- const lines: string[] = [];
- const maxLen = Math.max(logo.length, info.length);
-
- for (let i = 0; i < maxLen; i++) {
- const left = logo[i] || " ";
- const right = info[i] || "";
- lines.push(
- `<span class="ansi-bold-white">${left.replace(/ /g, " ")}</span> ${right}`,
- );
- }
-
- return lines;
-}
-
-function generateBootMessages(): string[] {
- const sys = getSystemInfo();
- const neofetchLines = generateNeofetch(sys);
-
- return [...neofetchLines];
-}
-
-const CURSOR_CHAR = "█";
-
-function createBootOverlay(): HTMLElement {
- const overlay = document.createElement("div");
- overlay.id = "boot-overlay";
- overlay.innerHTML = `
- <div class="boot-terminal">
- <div class="boot-content"></div>
- <div class="boot-cursor">${CURSOR_CHAR}</div>
- </div>
- `;
- document.body.appendChild(overlay);
- return overlay;
-}
-
-function sleep(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-async function runBootSequence(overlay: HTMLElement): Promise<void> {
- const content = overlay.querySelector(".boot-content") as HTMLElement;
- const cursor = overlay.querySelector(".boot-cursor") as HTMLElement;
- const messages = generateBootMessages();
-
- await sleep(200);
-
- for (let i = 0; i < messages.length; i++) {
- const line = messages[i];
- const lineEl = document.createElement("div");
- lineEl.className = "boot-line boot-line-hidden";
- lineEl.innerHTML = line || " ";
- content.appendChild(lineEl);
-
- await sleep(50);
- lineEl.classList.remove("boot-line-hidden");
- lineEl.classList.add("boot-line-visible");
- }
-
- await sleep(400);
-
- const startMsg = document.createElement("div");
- startMsg.className = "boot-line boot-line-hidden";
- startMsg.innerHTML =
- '<br><span class="ansi-bold-green">Starting prime8dev...</span>';
- content.appendChild(startMsg);
- await sleep(60);
- startMsg.classList.remove("boot-line-hidden");
- startMsg.classList.add("boot-line-visible");
-
- await sleep(500);
-
- cursor.style.display = "none";
-
- const progressBar = document.createElement("div");
- progressBar.className = "boot-line boot-line-visible";
- content.appendChild(progressBar);
-
- const progressWidth = 30;
- for (let i = 0; i <= progressWidth; i++) {
- const filled = "=".repeat(i);
- const arrow = i < progressWidth ? ">" : "=";
- const empty = " ".repeat(progressWidth - i);
- progressBar.innerHTML = `<span class="ansi-bold-green">[${filled}${arrow}${empty}]</span> ${Math.round((i / progressWidth) * 100)}%`;
- await sleep(25);
- }
-
- await sleep(250);
-
- overlay.classList.add("fade-out");
- await sleep(400);
-
- overlay.remove();
-}
-
-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);
-
- 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,39 +0,0 @@
-import { selectedIndex } from "./state";
-import { updateUrl } from "./url";
-import { updateExperienceDetail, updateDetailView } from "./detail";
-
-export function setupTimelineClicks(): void {
- document.querySelectorAll(".timeline-segment").forEach((segment) => {
- segment.addEventListener("click", () => {
- const index = parseInt(segment.getAttribute("data-index") || "0");
- selectedIndex.experience = index;
-
- document.querySelectorAll(".timeline-segment").forEach((s) => {
- s.classList.toggle(
- "selected",
- parseInt(s.getAttribute("data-index") || "0") === index,
- );
- });
-
- updateExperienceDetail();
- updateUrl();
- });
- });
-}
-
-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,28 +0,0 @@
-import { applyInitialState } from "./state";
-import { loadFromUrl } from "./url";
-import { setupTabs } from "./tabs";
-import { setupKeyboard } from "./keyboard";
-import { setupTimelineClicks, 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();
- setupTimelineClicks();
- 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,82 +0,0 @@
-import { currentTab, currentBlogId, selectedIndex } from "./state";
-import { getItemId, updateUrl } from "./url";
-import { switchTab, cycleTab } from "./tabs";
-import { navigateList, navigateTimeline } 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();
- if (currentTab === "experience") {
- navigateTimeline(-1);
- } else {
- cycleTab(-1);
- }
- break;
- case "ArrowRight":
- if (
- (
- window as Window & { isLightboxOpen?: () => boolean }
- ).isLightboxOpen?.()
- )
- return;
- e.preventDefault();
- if (currentTab === "experience") {
- navigateTimeline(1);
- } else {
- 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,86 +0,0 @@
-import { currentTab, selectedIndex, currentBlogId } from "./state";
-import { updateUrl } from "./url";
-import { updateExperienceDetail, updateDetailView } from "./detail";
-import { cycleTab } from "./tabs";
-
-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 segments = document.querySelectorAll("#experience .timeline-segment");
-
- const sortedSegments = Array.from(segments).sort((a, b) => {
- const aLeft = parseFloat((a as HTMLElement).style.left) || 0;
- const bLeft = parseFloat((b as HTMLElement).style.left) || 0;
- return aLeft - bLeft;
- });
-
- const sortedIndices = sortedSegments.map((s) =>
- parseInt(s.getAttribute("data-index") || "0"),
- );
- const currentPos = sortedIndices.indexOf(selectedIndex.experience);
- const newPos = currentPos + direction;
-
- if (newPos < 0) {
- cycleTab(-1);
- return;
- }
- if (newPos >= sortedIndices.length) {
- cycleTab(1);
- return;
- }
-
- selectedIndex.experience = sortedIndices[newPos];
-
- segments.forEach((segment) => {
- const idx = parseInt(segment.getAttribute("data-index") || "0");
- segment.classList.toggle("selected", idx === selectedIndex.experience);
- });
-
- updateExperienceDetail();
- 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,78 +0,0 @@
-import { openBlogPost } from "./blog";
-import { updateDetailView, updateExperienceDetail } from "./detail";
-
-export let currentTab = "about";
-
-export const selectedIndex: Record<string, number> = {
- experience: getInitialExperienceIndex(),
- 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 timelineSegments = document.querySelectorAll(
- "#experience .timeline-segment",
- );
- timelineSegments.forEach((segment) => {
- const idx = parseInt(segment.getAttribute("data-index") || "0");
- segment.classList.toggle("selected", idx === selectedIndex.experience);
- });
- updateExperienceDetail();
-
- 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 getInitialExperienceIndex(): number {
- const selected = document.querySelector(
- "#experience .timeline-segment.selected",
- );
- return selected ? parseInt(selected.getAttribute("data-index") || "0") : 0;
-}
-
-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/lib/readme/render.ts b/web/src/utils/markdown.ts
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(", ");
+}