prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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:
A.zed/tasks.json | 8++++++++
Dweb/config/about.json | 18------------------
Dweb/config/contact.json | 5-----
Dweb/config/experience.json | 35-----------------------------------
Dweb/config/projects.json | 38--------------------------------------
Mweb/public/favicon.png | 0
Rweb/blogs/building-my-portfolio.md -> web/src/blogs/building-my-portfolio.md | 0
Aweb/src/blogs/index.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dweb/src/components/Article.astro | 418-------------------------------------------------------------------------------
Dweb/src/components/Footer.astro | 51---------------------------------------------------
Dweb/src/components/Header.astro | 140-------------------------------------------------------------------------------
Dweb/src/components/Lightbox.astro | 139-------------------------------------------------------------------------------
Dweb/src/components/pages/About.astro | 393-------------------------------------------------------------------------------
Dweb/src/components/pages/Blog.astro | 205-------------------------------------------------------------------------------
Dweb/src/components/pages/Experience.astro | 628-------------------------------------------------------------------------------
Dweb/src/components/pages/Projects.astro | 404-------------------------------------------------------------------------------
Dweb/src/components/styles.css | 111-------------------------------------------------------------------------------
Aweb/src/config/about.ts | 32++++++++++++++++++++++++++++++++
Aweb/src/config/contact.ts | 11+++++++++++
Aweb/src/config/experience.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/config/index.ts | 4++++
Aweb/src/config/projects.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/config/tabs.ts | 8++++++++
Aweb/src/constants/env.ts | 1+
Aweb/src/constants/external/wakatime.ts | 2++
Dweb/src/constants/tags.ts | 33---------------------------------
Aweb/src/constants/ui/boot.ts | 11+++++++++++
Aweb/src/constants/ui/scroll.ts | 1+
Aweb/src/constants/ui/tags.ts | 34++++++++++++++++++++++++++++++++++
Mweb/src/constants/website.ts | 4+++-
Aweb/src/external/git.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/external/wakatime.ts | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/src/layouts/Layout.astro | 169++++++++++++-------------------------------------------------------------------
Aweb/src/layouts/components/Article.astro | 387+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/layouts/components/Footer.astro | 46++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/layouts/components/Header.astro | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/layouts/components/Lightbox.astro | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/layouts/tabs/About.astro | 386+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/layouts/tabs/Blog.astro | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/layouts/tabs/Experience.astro | 484+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/layouts/tabs/Projects.astro | 413+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dweb/src/lib/blogs.ts | 52----------------------------------------------------
Dweb/src/lib/readme/fetch.ts | 132-------------------------------------------------------------------------------
Dweb/src/lib/schemas/blog.ts | 61-------------------------------------------------------------
Dweb/src/lib/schemas/experience.ts | 54------------------------------------------------------
Dweb/src/lib/schemas/person.ts | 24------------------------
Dweb/src/lib/schemas/project.ts | 45---------------------------------------------
Dweb/src/lib/schemas/website.ts | 13-------------
Dweb/src/lib/types/about.ts | 11-----------
Dweb/src/lib/types/blog.ts | 11-----------
Dweb/src/lib/types/contact.ts | 5-----
Dweb/src/lib/types/experience.ts | 13-------------
Dweb/src/lib/types/project.ts | 9---------
Dweb/src/lib/utils/job.ts | 8--------
Dweb/src/lib/utils/period.ts | 37-------------------------------------
Dweb/src/lib/utils/tags.ts | 12------------
Dweb/src/lib/wakatime.ts | 159-------------------------------------------------------------------------------
Mweb/src/pages/404.astro | 3++-
Mweb/src/pages/blog/[slug].astro | 58+++++++++++++++++++++++++++++++++++++++++++++++++---------
Mweb/src/pages/experience/[id].astro | 47+++++++++++++++++++++++++++++++++--------------
Mweb/src/pages/index.astro | 41++++++++++++++++++-----------------------
Mweb/src/pages/projects/[id].astro | 51+++++++++++++++++++++++++++++++++++----------------
Mweb/src/pages/sitemap.xml.ts | 39++++++++++++++++++++++-----------------
Aweb/src/schemas/blog.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/schemas/experience.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/schemas/index.ts | 13+++++++++++++
Aweb/src/schemas/person.ts | 22++++++++++++++++++++++
Aweb/src/schemas/project.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/schemas/website.ts | 12++++++++++++
Dweb/src/scripts/ui/blog.ts | 58----------------------------------------------------------
Dweb/src/scripts/ui/boot.ts | 177-------------------------------------------------------------------------------
Dweb/src/scripts/ui/clicks.ts | 39---------------------------------------
Dweb/src/scripts/ui/detail.ts | 27---------------------------
Dweb/src/scripts/ui/index.ts | 28----------------------------
Dweb/src/scripts/ui/keyboard.ts | 82-------------------------------------------------------------------------------
Dweb/src/scripts/ui/navigation.ts | 86-------------------------------------------------------------------------------
Dweb/src/scripts/ui/state.ts | 78------------------------------------------------------------------------------
Dweb/src/scripts/ui/status.ts | 17-----------------
Dweb/src/scripts/ui/tabs.ts | 48------------------------------------------------
Dweb/src/scripts/ui/url.ts | 94-------------------------------------------------------------------------------
Aweb/src/tabs/about.ts | 22++++++++++++++++++++++
Aweb/src/tabs/blog.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/tabs/experience.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/tabs/projects.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/ui/boot.ts | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/ui/dom.ts | 37+++++++++++++++++++++++++++++++++++++
Aweb/src/ui/index.ts | 25+++++++++++++++++++++++++
Aweb/src/ui/keyboard.ts | 37+++++++++++++++++++++++++++++++++++++
Aweb/src/ui/router.ts | 42++++++++++++++++++++++++++++++++++++++++++
Aweb/src/ui/status.ts | 25+++++++++++++++++++++++++
Aweb/src/ui/tabs.ts | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rweb/src/lib/utils/duration.ts -> web/src/utils/duration.ts | 0
Rweb/src/lib/readme/render.ts -> web/src/utils/markdown.ts | 0
Aweb/src/utils/period.ts | 27+++++++++++++++++++++++++++
Aweb/src/utils/sleep.ts | 3+++
Aweb/src/utils/tags.ts | 16++++++++++++++++
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: ![alt](./path) -> ![alt](baseUrl/path) + let fixed = markdown.replace( + /!\[([^\]]*)\]\(\.\/([^)]+)\)/g, + `![$1](${baseUrl}/$2)`, + ); + + // 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: ![alt](./path) -> ![alt](baseUrl/path) - let fixed = markdown.replace( - /!\[([^\]]*)\]\(\.\/([^)]+)\)/g, - `![$1](${baseUrl}/$2)`, - ); - - // 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, "&nbsp;")}</span>&nbsp;&nbsp;${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 || "&nbsp;"; - 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(", "); +}