prime8dev

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

commit f0930385d5ba6f7e7c475df5c9e7c7ceb132d1ae
parent 5cda611e21bdf7a17fa639c39c5eb2836b687e6e
Author: Damian Myrda <damian@prime8.dev>
Date:   Thu, 22 Jan 2026 15:07:49 -0600

refactor: reorganize UI into tab registry pattern

- Create tab registry system (src/ui/tabs.ts) where each tab self-registers
- Move tab-specific logic to src/tabs/{about,experience,projects,blog}.ts
- Simplify keyboard system to single handler per key
- Add static tab config (src/config/tabs.ts) for SSR-compatible header
- Reorganize directory structure:
  - src/ui/ for core UI modules (keyboard, router, tabs, dom, boot, status)
  - src/tabs/ for tab-specific behavior
  - src/layouts/tabs/ for tab Astro components
  - src/layouts/components/ for shared components
- Fix blog page keyboard scrolling (scroll window instead of missing element)
- Add data-id attributes to experience cards for URL routing

Diffstat:
Dweb/config/about.json | 18------------------
Dweb/config/contact.json | 5-----
Dweb/config/experience.json | 35-----------------------------------
Dweb/config/projects.json | 34----------------------------------
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 | 387-------------------------------------------------------------------------------
Dweb/src/components/Footer.astro | 46----------------------------------------------
Dweb/src/components/Header.astro | 144-------------------------------------------------------------------------------
Dweb/src/components/Lightbox.astro | 139-------------------------------------------------------------------------------
Dweb/src/components/tabs/About.astro | 392-------------------------------------------------------------------------------
Dweb/src/components/tabs/Blog.astro | 244-------------------------------------------------------------------------------
Dweb/src/components/tabs/Experience.astro | 523-------------------------------------------------------------------------------
Dweb/src/components/tabs/Projects.astro | 412-------------------------------------------------------------------------------
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++++++++
Rweb/src/constants/wakatime.ts -> web/src/constants/external/wakatime.ts | 0
Dweb/src/constants/tags.ts | 34----------------------------------
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 | 9+++++----
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/git.ts | 63---------------------------------------------------------------
Dweb/src/lib/readme.ts | 149-------------------------------------------------------------------------------
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 | 46----------------------------------------------
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/git.ts | 9---------
Dweb/src/lib/types/project.ts | 8--------
Dweb/src/lib/types/wakatime.ts | 21---------------------
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 | 140-------------------------------------------------------------------------------
Mweb/src/pages/404.astro | 3++-
Mweb/src/pages/blog/[slug].astro | 27+++++++++++++++++++--------
Mweb/src/pages/experience/[id].astro | 32+++++++++++++++++++++++---------
Mweb/src/pages/index.astro | 35+++++++++++++++++------------------
Mweb/src/pages/projects/[id].astro | 36+++++++++++++++++++++++++-----------
Mweb/src/pages/sitemap.xml.ts | 27++++++++++-----------------
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 | 103-------------------------------------------------------------------------------
Dweb/src/scripts/ui/clicks.ts | 20--------------------
Dweb/src/scripts/ui/detail.ts | 27---------------------------
Dweb/src/scripts/ui/index.ts | 27---------------------------
Dweb/src/scripts/ui/keyboard.ts | 74--------------------------------------------------------------------------
Dweb/src/scripts/ui/navigation.ts | 77-----------------------------------------------------------------------------
Dweb/src/scripts/ui/state.ts | 72------------------------------------------------------------------------
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
Aweb/src/utils/markdown.ts | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/utils/period.ts | 27+++++++++++++++++++++++++++
Aweb/src/utils/sleep.ts | 3+++
Aweb/src/utils/tags.ts | 16++++++++++++++++
95 files changed, 4042 insertions(+), 3836 deletions(-)

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