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:
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[] {