prime8dev

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

commit 17a913be3d7b41c9d1a968367352b04b5a08f84d
parent 337a5a0c52fca316f653031772783508d8003313
Author: Damian Myrda <damian@prime8.dev>
Date:   Thu, 22 Jan 2026 15:28:49 -0600

feat: improve experience page styling and routing

- Refactor standalone experience page to match card styling from experience tab
- Add lightbox support to experience page images
- Update route ID on hover over experience cards
- Set default route ID on initial page load
- Rename URL/DOMAIN constants to SITE_URL/SITE_DOMAIN for clarity
- Reduce bottom padding on experience page

Diffstat:
Mweb/src/constants/ui/boot.ts | 4++--
Mweb/src/constants/website.ts | 4++--
Mweb/src/layouts/Layout.astro | 6+++---
Mweb/src/pages/404.astro | 4++--
Mweb/src/pages/experience/[id].astro | 326+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mweb/src/pages/index.astro | 4++--
Mweb/src/pages/projects/[id].astro | 4++--
Mweb/src/schemas/blog.ts | 14+++++++-------
Mweb/src/schemas/experience.ts | 8++++----
Mweb/src/schemas/person.ts | 6+++---
Mweb/src/schemas/project.ts | 10+++++-----
Mweb/src/schemas/website.ts | 4++--
Mweb/src/tabs/experience.ts | 10++++++++--
Mweb/src/ui/tabs.ts | 2++
14 files changed, 230 insertions(+), 176 deletions(-)

diff --git a/web/src/constants/ui/boot.ts b/web/src/constants/ui/boot.ts @@ -1,8 +1,8 @@ import { about } from "../../config"; -import { DOMAIN } from "../website"; +import { SITE_DOMAIN } from "../website"; export const TITLE = about.name; -export const SUBTITLE = DOMAIN; +export const SUBTITLE = SITE_DOMAIN; export const GLITCH_CHARS = "▓▒░█▄▀■□▪▫●○◆◇"; export const SCRAMBLE_DURATION = 400; diff --git a/web/src/constants/website.ts b/web/src/constants/website.ts @@ -1,3 +1,3 @@ -export const DOMAIN = "prime8.dev"; +export const SITE_DOMAIN = "prime8.dev"; -export const URL = `https://${DOMAIN}`; +export const SITE_URL = `https://${SITE_DOMAIN}`; diff --git a/web/src/layouts/Layout.astro b/web/src/layouts/Layout.astro @@ -1,7 +1,7 @@ --- import Lightbox from "./components/Lightbox.astro"; -import { URL } from "../constants/website"; +import { SITE_URL } from "../constants/website"; import { about } from "../config"; interface Props { @@ -29,8 +29,8 @@ const { const ogImage = image ? image.startsWith("http") ? image - : `${URL}${image}` - : `${URL}/me.jpg`; + : `${SITE_URL}${image}` + : `${SITE_URL}/me.jpg`; --- <!doctype html> diff --git a/web/src/pages/404.astro b/web/src/pages/404.astro @@ -2,14 +2,14 @@ import Layout from "../layouts/Layout.astro"; import { about } from "../config"; -import { URL } from "../constants/website"; +import { SITE_URL } from "../constants/website"; --- <Layout title={`404 - Page Not Found | ${about.name}`} description="The page you're looking for doesn't exist." type="website" - url={`${URL}/404`} + url={`${SITE_URL}/404`} > <main class="error-page"> <div class="error-content"> diff --git a/web/src/pages/experience/[id].astro b/web/src/pages/experience/[id].astro @@ -1,128 +1,140 @@ --- import Layout from "../../layouts/Layout.astro"; +import Lightbox from "../../layouts/components/Lightbox.astro"; -import { URL } from "../../constants/website"; +import { SITE_URL } from "../../constants/website"; import { experience as experiences } from "../../config"; +import { calculateMonths } from "../../utils/period"; +import { MONTH_NAMES } from "../../constants/calendar"; import { getTagClass } from "../../utils/tags"; import schemas from "../../schemas/index"; -const experience = experiences.find((e) => e.id === Astro.params.id); -if (!experience) { +const exp = experiences.find((e) => e.id === Astro.params.id); +if (!exp) { return new Response(null, { status: 404, statusText: "Not Found", }); } -function formatDate(dateStr: string): string { - const [year, month] = dateStr.split("-"); - const date = new Date(parseInt(year), parseInt(month) - 1); - return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); -} - -const dateRange = experience.endDate - ? `${formatDate(experience.startDate)} - ${formatDate(experience.endDate)}` - : `${formatDate(experience.startDate)} - Present`; +const months = calculateMonths(exp.startDate, exp.endDate); +const [startYear, startMonth] = exp.startDate.split("-").map(Number); +const startStr = `${MONTH_NAMES[startMonth - 1]} ${startYear}`; +const endStr = exp.endDate + ? `${MONTH_NAMES[parseInt(exp.endDate.split("-")[1]) - 1]} ${exp.endDate.split("-")[0]}` + : "Present"; -const keywords = [...experience.tech, experience.company, experience.role].join( - ", ", -); -const description = `${experience.role} at ${experience.company}. ${experience.description[0]}`; +const keywords = [...exp.tech, exp.company, exp.role].join(", "); +const description = `${exp.role} at ${exp.company}. ${exp.description[0]}`; const { getSchemaForExperience, getBreadcrumbSchema } = schemas.experience; --- <Layout - title={`${experience.company} - ${experience.role} | Damian Myrda`} + title={`${exp.company} - ${exp.role} | Damian Myrda`} description={description} type="website" - url={`${URL}/experience/${experience.id}`} + url={`${SITE_URL}/experience/${exp.id}`} keywords={keywords} - image={experience.logo} + image={exp.logo} > <main id="content"> <div class="experience-page"> - <a href="/#experience" class="back-btn">← See more experiences</a> + <a href="/#experience" class="back-btn">← See more experience</a> - <header class="experience-header"> - <div class="header-top"> - <div class="company-info"> - { - experience.logo && ( + <article class="experience-article"> + <header class="article-header"> + <div class="header-top"> + <div class="title-section"> + <div class="exp-title-with-logo"> <img - src={experience.logo} - alt={`${experience.company} logo`} - class={`company-logo ${experience.logoFilter || ""}`} - width="48" - height="48" + src={exp.logo} + alt={exp.company} + class={`exp-logo ${exp.logoFilter || ""}`} /> - ) - } - <div> - <h1>{experience.company}</h1> - <p class="role">{experience.role}</p> + <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> - <a - class="external-link" - href={experience.url} - target="_blank" - rel="noopener noreferrer" - > - Visit website → - </a> - </div> - <time class="date-range" datetime={experience.startDate} - >{dateRange}</time - > - </header> - - <section class="description"> - {experience.description.map((paragraph) => <p>{paragraph}</p>)} - </section> - - <section class="tech-section"> - <h2>Technologies</h2> - <div class="tech-stack"> + </header> + + <section class="tech-tags"> { - experience.tech.map((tech) => ( + exp.tech.map((tech) => ( <span class={`tag ${getTagClass(tech)}`}>{tech}</span> )) } - </div> - </section> - - { - experience.images && experience.images.length > 0 && ( - <section class="images-section"> - <h2>Photos</h2> - <div class="image-grid"> - {experience.images.map((img, i) => ( - <img - src={img} - alt={`${experience.company} photo ${i + 1}`} - class="experience-image" - loading="lazy" - /> - ))} - </div> - </section> - ) - } + </section> + + <section class="article-content"> + {exp.description.map((paragraph) => <p>{paragraph}</p>)} + </section> + + { + exp.images && exp.images.length > 0 && ( + <section class="images-section"> + <h2>Photos</h2> + <div class="image-grid"> + {exp.images.map((img, i) => ( + <img + src={img} + alt={`${exp.company} photo ${i + 1}`} + class="experience-image" + loading="lazy" + /> + ))} + </div> + </section> + ) + } + </article> </div> <script type="application/ld+json" - set:html={JSON.stringify(getSchemaForExperience(experience))} + set:html={JSON.stringify(getSchemaForExperience(exp))} /> <script type="application/ld+json" - set:html={JSON.stringify(getBreadcrumbSchema(experience))} + set:html={JSON.stringify(getBreadcrumbSchema(exp))} /> </main> + <Lightbox /> </Layout> <script> document.getElementById("app")?.classList.add("ready"); + + // Lightbox click handlers for images + document.querySelectorAll(".experience-image").forEach((img) => { + img.addEventListener("click", () => { + const container = (img as HTMLElement).closest(".image-grid"); + const allImages = container + ? Array.from(container.querySelectorAll("img")).map( + (i) => (i as HTMLImageElement).src, + ) + : []; + const startIndex = allImages.indexOf((img as HTMLImageElement).src); + ( + window as Window & { + openLightbox?: (images: string[], startIndex?: number) => void; + } + ).openLightbox?.(allImages, startIndex); + }); + }); </script> <style> @@ -140,9 +152,9 @@ const { getSchemaForExperience, getBreadcrumbSchema } = schemas.experience; } .experience-page { - max-width: 800px; + max-width: 720px; margin: 0 auto; - padding: 40px 24px; + padding: 40px 24px 40px; min-height: 100vh; } @@ -166,146 +178,180 @@ const { getSchemaForExperience, getBreadcrumbSchema } = schemas.experience; border-color: var(--accent); } - .experience-header { - margin-bottom: 32px; - padding-bottom: 24px; + .experience-article { + animation: fadeIn 0.2s ease; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .article-header { + margin-bottom: 24px; + padding-bottom: 16px; border-bottom: 1px solid var(--border); } .header-top { display: flex; - justify-content: space-between; align-items: flex-start; - margin-bottom: 12px; + justify-content: space-between; + gap: 24px; } - .company-info { + .title-section { display: flex; align-items: center; gap: 16px; } - .company-logo { - width: 48px; - height: 48px; - object-fit: contain; + .exp-logo { + height: 32px; + width: auto; + flex-shrink: 0; } - .company-logo.logo-invert { - filter: invert(1); + .exp-logo.logo-invert { + filter: brightness(0) invert(1); } - .experience-header h1 { - font-size: 28px; - font-weight: 500; - color: var(--accent); - margin: 0; + .exp-title-with-logo { + display: flex; + flex-direction: column; + gap: 4px; + } + + .exp-title-with-logo .exp-role { + color: #999; + font-size: 14px; } - .role { + .exp-card-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + font-size: 13px; + } + + .exp-dates { color: var(--fg); - font-size: 16px; - margin: 4px 0 0 0; + } + + .exp-duration { + color: #aaa; + font-size: 12px; } .external-link { color: var(--light-blue); text-decoration: none; - font-size: 14px; + font-size: 12px; } .external-link:hover { opacity: 0.8; } - .date-range { - color: var(--fg-dim); - font-size: 14px; - } - - .description { - margin-bottom: 32px; + .tech-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 24px; } - .description p { - color: var(--fg); + .article-content { font-size: 14px; line-height: 1.7; - margin-bottom: 12px; + color: var(--fg); + margin-bottom: 32px; } - .description p:last-child { - margin-bottom: 0; + .article-content p { + margin-bottom: 16px; } - .tech-section { - margin-bottom: 32px; + .article-content p:last-child { + margin-bottom: 0; } - .tech-section h2, .images-section h2 { font-size: 16px; color: var(--accent); - margin-bottom: 12px; + margin-bottom: 16px; font-weight: 500; } - .tech-stack { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .images-section { - margin-bottom: 32px; - } - .image-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + grid-template-columns: repeat(2, 1fr); gap: 12px; } .experience-image { width: 100%; - max-height: 160px; + height: 180px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); cursor: pointer; - transition: transform 0.15s; + transition: + opacity 0.15s, + border-color 0.15s; } .experience-image:hover { - transform: scale(1.02); + opacity: 0.9; + border-color: var(--accent); } @media (max-width: 768px) { .experience-page { - padding: 24px 16px; + padding: 24px 16px 0; + } + + .back-btn { + padding: 6px 12px; + font-size: 11px; + margin-bottom: 16px; } .header-top { flex-direction: column; - gap: 16px; + gap: 12px; } - .company-info { - flex-direction: column; - align-items: flex-start; + .title-section { gap: 12px; } - .experience-header h1 { + .company-logo { + height: 36px; + } + + h1 { font-size: 22px; } + .role { + font-size: 14px; + } + + .article-content { + font-size: 13px; + } + .image-grid { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: 1fr; } .experience-image { - max-height: 120px; + height: 200px; } } </style> diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro @@ -9,7 +9,7 @@ import Footer from "../layouts/components/Footer.astro"; import { about, currentJob } from "../config"; import { fetchWakaTimeData } from "../external/wakatime"; -import { URL } from "../constants/website"; +import { SITE_URL } from "../constants/website"; import schemas from "../schemas/index"; const wakaTimeData = await fetchWakaTimeData(); @@ -21,7 +21,7 @@ const { personSchema, websiteSchema } = schemas; title={`${about.name} | ${about.profession_short}`} description={`${about.name} - ${about.profession}${currentJob?.company ? ` at ${currentJob.company}` : ""}`} type="website" - url={URL} + url={SITE_URL} keywords={[ about.name, about.profession, diff --git a/web/src/pages/projects/[id].astro b/web/src/pages/projects/[id].astro @@ -3,7 +3,7 @@ import Layout from "../../layouts/Layout.astro"; import { projects } from "../../config"; import { fetchGitMetadataForProject } from "../../external/git"; -import { URL } from "../../constants/website"; +import { SITE_URL } from "../../constants/website"; import schemas from "../../schemas/index"; import { getTagClass } from "../../utils/tags"; @@ -27,7 +27,7 @@ const { getSchemaForProject, getBreadcrumbSchema } = schemas.project; title={`${project.name} | Damian Myrda`} description={project.subtitle} type="website" - url={`${URL}/projects/${project.id}`} + url={`${SITE_URL}/projects/${project.id}`} keywords={keywords} > <main id="content"> diff --git a/web/src/schemas/blog.ts b/web/src/schemas/blog.ts @@ -1,5 +1,5 @@ import type { Blog } from "../blogs"; -import { URL } from "../constants/website"; +import { SITE_URL } from "../constants/website"; import personSchema from "./person"; export function getSchemaForBlog(blog: Blog) { @@ -10,12 +10,12 @@ export function getSchemaForBlog(blog: Blog) { description: blog.description, datePublished: blog.date, dateModified: blog.modifiedDate || blog.date, - url: `${URL}/blog/${blog.id}`, + url: `${SITE_URL}/blog/${blog.id}`, author: personSchema, publisher: personSchema, mainEntityOfPage: { "@type": "WebPage", - "@id": `${URL}/blog/${blog.id}`, + "@id": `${SITE_URL}/blog/${blog.id}`, }, articleSection: "Technology", }; @@ -23,7 +23,7 @@ export function getSchemaForBlog(blog: Blog) { if (blog.image) { schema.image = blog.image.startsWith("http") ? blog.image - : `${URL}${blog.image}`; + : `${SITE_URL}${blog.image}`; } if (blog.tags && blog.tags.length > 0) { @@ -42,19 +42,19 @@ export function getBreadcrumbSchema(blog: Blog) { "@type": "ListItem", position: 1, name: "Home", - item: URL, + item: SITE_URL, }, { "@type": "ListItem", position: 2, name: "Blog", - item: `${URL}/#blog`, + item: `${SITE_URL}/#blog`, }, { "@type": "ListItem", position: 3, name: blog.title, - item: `${URL}/blog/${blog.id}`, + item: `${SITE_URL}/blog/${blog.id}`, }, ], }; diff --git a/web/src/schemas/experience.ts b/web/src/schemas/experience.ts @@ -1,5 +1,5 @@ import type { Experience } from "../config"; -import { URL } from "../constants/website"; +import { SITE_URL } from "../constants/website"; import personSchema from "./person"; export function getSchemaForExperience(experience: Experience) { @@ -35,19 +35,19 @@ export function getBreadcrumbSchema(experience: Experience) { "@type": "ListItem", position: 1, name: "Home", - item: URL, + item: SITE_URL, }, { "@type": "ListItem", position: 2, name: "Experience", - item: `${URL}/#experience`, + item: `${SITE_URL}/#experience`, }, { "@type": "ListItem", position: 3, name: experience.company, - item: `${URL}/experience/${experience.id}`, + item: `${SITE_URL}/experience/${experience.id}`, }, ], }; diff --git a/web/src/schemas/person.ts b/web/src/schemas/person.ts @@ -1,5 +1,5 @@ import { about, contact, currentJob } from "../config"; -import { URL } from "../constants/website"; +import { SITE_URL } from "../constants/website"; export default { "@context": "https://schema.org", @@ -8,8 +8,8 @@ export default { alternateName: about.handle, email: contact.email, sameAs: [contact.gitlab, contact.linkedin], - url: URL, - image: `${URL}/me.jpg`, + url: SITE_URL, + image: `${SITE_URL}/me.jpg`, ...(currentJob && { jobTitle: currentJob.role, worksFor: { diff --git a/web/src/schemas/project.ts b/web/src/schemas/project.ts @@ -1,6 +1,6 @@ import type Project from "../config/projects"; import type { GitMetadata } from "../external/git"; -import { URL } from "../constants/website"; +import { SITE_URL } from "../constants/website"; import personSchema from "./person"; export function getSchemaForProject(project: Project, metadata?: GitMetadata) { @@ -9,7 +9,7 @@ export function getSchemaForProject(project: Project, metadata?: GitMetadata) { "@type": "SoftwareSourceCode", name: project.name, description: project.subtitle, - url: `${URL}/projects/${project.id}`, + url: `${SITE_URL}/projects/${project.id}`, codeRepository: project.url, programmingLanguage: project.tech, author: personSchema, @@ -27,19 +27,19 @@ export function getBreadcrumbSchema(project: Project) { "@type": "ListItem", position: 1, name: "Home", - item: URL, + item: SITE_URL, }, { "@type": "ListItem", position: 2, name: "Projects", - item: `${URL}/#projects`, + item: `${SITE_URL}/#projects`, }, { "@type": "ListItem", position: 3, name: project.name, - item: `${URL}/projects/${project.id}`, + item: `${SITE_URL}/projects/${project.id}`, }, ], }; diff --git a/web/src/schemas/website.ts b/web/src/schemas/website.ts @@ -1,12 +1,12 @@ import { about, currentJob } from "../config"; -import { URL } from "../constants/website"; +import { SITE_URL } from "../constants/website"; import personSchema from "./person"; export default { "@context": "https://schema.org", "@type": "WebSite", name: about.name, - url: URL, + url: SITE_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/tabs/experience.ts b/web/src/tabs/experience.ts @@ -58,10 +58,16 @@ function select(index: number): void { function setupHover(): void { $$("#experience .exp-card").forEach((card, i) => { - card.addEventListener("mouseenter", () => select(i)); + card.addEventListener("mouseenter", () => { + select(i); + setRoute("experience", getId(selectedIndex)); + }); }); $$("#experience .exp-timeline").forEach((tl, i) => { - tl.addEventListener("mouseenter", () => select(i)); + tl.addEventListener("mouseenter", () => { + select(i); + setRoute("experience", getId(selectedIndex)); + }); }); } diff --git a/web/src/ui/tabs.ts b/web/src/ui/tabs.ts @@ -44,6 +44,8 @@ export function routeHandler(route: { tab: string; id?: string }): void { updateDom(); config.setRouteId?.(route.id); + + setRoute(route.tab, config.getRouteId?.()); } export function getAllTabs(): string[] {