commit a138b692b05337e797c2ef8653b49a15a0981d13
parent 8f26035ceddbbcd9d155125283112a43d04eff15
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Jan 2026 21:17:45 -0600
refactor: redesign experience page with vertical timeline
- Replace horizontal timeline with vertical timeline on right side of cards
- Add hover-based selection highlighting
- Show end year with terminal-style brackets
- Use dashed connector lines between timeline nodes
- Add scroll-snap and smooth navigation with arrow keys
Diffstat:
11 files changed, 558 insertions(+), 728 deletions(-)
diff --git a/web/src/components/pages/Experience.astro b/web/src/components/pages/Experience.astro
@@ -1,650 +0,0 @@
----
-import rawExperience from "../../../config/experience.json";
-import type Experience from "../../lib/types/experience";
-import currentJob from "../../lib/utils/job";
-import { MONTH_NAMES } from "../../constants/calendar";
-import { calculateMonths } from "../../lib/utils/period";
-import { getTagClass } from "../../lib/utils/tags";
-
-const experience = (rawExperience as Experience[]).sort((a, b) =>
- a.startDate.localeCompare(b.startDate),
-);
-
-const currentRoleIndex = currentJob
- ? experience.findIndex((exp) => exp.id === currentJob.id)
- : 0;
-
-const dates = experience.map((exp) => {
- const [year, month] = exp.startDate.split("-").map(Number);
- return new Date(year, month - 1, 1);
-});
-const endDates = experience.map((exp) =>
- exp.endDate
- ? new Date(
- parseInt(exp.endDate.split("-")[0]),
- parseInt(exp.endDate.split("-")[1]) - 1,
- 28,
- )
- : new Date(),
-);
-const earliestDate = new Date(Math.min(...dates.map((d) => d.getTime())));
-const latestDate = new Date(Math.max(...endDates.map((d) => d.getTime())));
-
-const timelineStart = new Date(
- earliestDate.getFullYear(),
- earliestDate.getMonth(),
- 1,
-);
-const timelineEnd = latestDate;
-const totalDays = Math.ceil(
- (timelineEnd.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24),
-);
-
-const startYear = timelineStart.getFullYear();
-const endYear = timelineEnd.getFullYear();
-const years = Array.from(
- { length: endYear - startYear + 1 },
- (_, i) => startYear + i,
-);
-
-const monthTicks: string[] = [];
-let current = new Date(timelineStart);
-while (current <= timelineEnd) {
- monthTicks.push(MONTH_NAMES[current.getMonth()][0]);
- current.setMonth(current.getMonth() + 1);
-}
----
-
-<section id="experience" class="page">
- <article class="experience-layout">
- <div class="timeline-container">
- <div class="timeline-years">
- {years.map((year) => <span class="year-marker">{year}</span>)}
- </div>
-
- <div class="timeline-bar">
- {
- experience.map((exp) => {
- const [startYear, startMonth] = exp.startDate
- .split("-")
- .map(Number);
- const expStart = new Date(startYear, startMonth - 1, 1);
- const expEnd = exp.endDate
- ? new Date(
- parseInt(exp.endDate.split("-")[0]),
- parseInt(exp.endDate.split("-")[1]) - 1,
- 28,
- )
- : new Date();
-
- const startOffset = Math.max(
- 0,
- (expStart.getTime() - timelineStart.getTime()) /
- (1000 * 60 * 60 * 24),
- );
- const duration =
- (expEnd.getTime() - expStart.getTime()) / (1000 * 60 * 60 * 24);
-
- const leftPercent = (startOffset / totalDays) * 100;
- const widthPercent = (duration / totalDays) * 100;
- const months = calculateMonths(exp.startDate, exp.endDate);
-
- const originalIndex = experience.findIndex((e) => e.id === exp.id);
-
- return (
- <div
- class={`timeline-segment ${originalIndex === currentRoleIndex ? "selected" : ""}`}
- style={`left: ${leftPercent}%; width: ${widthPercent}%;`}
- data-index={originalIndex}
- data-id={exp.id}
- >
- <div class="segment-content">
- <span class="segment-company">{exp.company}</span>
- <span class="segment-duration">{months} mos</span>
- </div>
- </div>
- );
- })
- }
- </div>
-
- <div class="timeline-ticks">
- {monthTicks.map((m) => <span class="tick-mark">{m}</span>)}
- </div>
- </div>
-
- <div class="experience-detail">
- {
- experience.map((exp, index) => {
- const months = calculateMonths(exp.startDate, exp.endDate);
- const [startYear, startMonth] = exp.startDate.split("-").map(Number);
- const startStr = `${MONTH_NAMES[startMonth - 1]} ${startYear}`;
- const endStr = exp.endDate
- ? `${MONTH_NAMES[parseInt(exp.endDate.split("-")[1]) - 1]} ${exp.endDate.split("-")[0]}`
- : "Present";
-
- return (
- <div
- class={`exp-card ${index !== currentRoleIndex ? "hidden" : ""}`}
- data-for={index}
- >
- <div class="exp-card-header">
- <div class="exp-card-title">
- {exp.logo ? (
- <div class="exp-title-with-logo">
- <img
- src={exp.logo}
- alt={`${exp.company} logo`}
- class={`exp-logo ${exp.logoFilter || ""}`}
- />
- <span class="exp-role">{exp.role}</span>
- </div>
- ) : (
- <div class="exp-title-text">
- <h2>{exp.company}</h2>
- <span class="exp-role">{exp.role}</span>
- </div>
- )}
- </div>
- <div class="exp-card-meta">
- <span class="exp-dates">
- {startStr} → {endStr}
- </span>
- <span class="exp-duration">{months} months</span>
- {exp.url && (
- <a
- class="external-link"
- href={exp.url}
- target="_blank"
- rel="noopener noreferrer"
- >
- {new URL(exp.url).hostname} →
- </a>
- )}
- </div>
- </div>
- <div class="exp-card-tech">
- {exp.tech.map((tech) => (
- <span class={`tag ${getTagClass(tech)}`}>{tech}</span>
- ))}
- </div>
- <div class="exp-card-body">
- {exp.description.map((description) => (
- <p>{description}</p>
- ))}
- </div>
- {exp.images && exp.images.length > 0 && (
- <div class="exp-card-images">
- <img
- src={exp.images[0]}
- alt={`${exp.company} workplace, ${exp.role} experience at ${exp.company}`}
- class="exp-image-hero"
- loading="lazy"
- data-idx="0"
- />
- {exp.images.length > 1 && (
- <div class="exp-image-grid">
- {exp.images.slice(1).map((image, index) => (
- <img
- src={image}
- alt={`${exp.company} team and office environment, image ${index + 2} of ${exp.images!.length}`}
- class="exp-image-thumb"
- loading="lazy"
- data-idx={index + 1}
- />
- ))}
- </div>
- )}
- </div>
- )}
- </div>
- );
- })
- }
- </div>
- </article>
-</section>
-
-<script>
- document.addEventListener("DOMContentLoaded", () => {
- const experiencePage = document.getElementById("experience");
- const timeline = document.querySelector(".timeline-container");
-
- if (experiencePage && timeline) {
- const scrollTimelineToEnd = () => {
- timeline.scrollLeft = timeline.scrollWidth;
- };
-
- const observer = new MutationObserver(() => {
- if (experiencePage.classList.contains("active")) {
- scrollTimelineToEnd();
- }
- });
-
- observer.observe(experiencePage, {
- attributes: true,
- attributeFilter: ["class"],
- });
-
- if (experiencePage.classList.contains("active")) {
- scrollTimelineToEnd();
- }
- }
-
- document
- .querySelectorAll(".exp-image-hero, .exp-image-thumb")
- .forEach((img) => {
- img.addEventListener("click", () => {
- const container = (img as HTMLElement).closest(".exp-card-images");
- const allImages = container
- ? Array.from(container.querySelectorAll("img")).map(
- (i) => (i as HTMLImageElement).src,
- )
- : [];
- const startIndex = allImages.indexOf((img as HTMLImageElement).src);
- (
- window as Window & {
- openLightbox?: (images: string[], startIndex?: number) => void;
- }
- ).openLightbox?.(allImages, startIndex);
- });
- });
- });
-</script>
-
-<style>
- .experience-layout {
- display: flex;
- flex-direction: column;
- height: 100%;
- width: 100%;
- padding: 32px 48px;
- gap: 24px;
- }
-
- .timeline-container {
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
-
- .timeline-years {
- display: flex;
- justify-content: space-between;
- padding: 0 4px;
- font-size: 12px;
- color: var(--fg-dim);
- }
-
- .year-marker {
- font-weight: 500;
- }
-
- .timeline-bar {
- position: relative;
- height: 48px;
- background: var(--bg-alt);
- border: 1px solid var(--border);
- border-radius: 4px;
- }
-
- .timeline-segment {
- position: absolute;
- top: 4px;
- bottom: 4px;
- background: var(--border);
- border-radius: 3px;
- cursor: pointer;
- transition: all 0.2s ease;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- }
-
- .timeline-segment:hover {
- background: var(--fg-dim);
- }
-
- .timeline-segment.selected {
- background: var(--accent);
- box-shadow:
- 0 0 0 2px var(--accent),
- 0 0 12px rgba(215, 175, 95, 0.3);
- }
-
- .segment-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 2px;
- padding: 0 8px;
- min-width: 0;
- }
-
- .segment-company {
- font-size: 11px;
- font-weight: 600;
- color: var(--bg);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 100%;
- }
-
- .segment-duration {
- font-size: 9px;
- color: var(--bg);
- opacity: 0.8;
- }
-
- .timeline-segment.selected .segment-company,
- .timeline-segment.selected .segment-duration {
- color: var(--bg);
- }
-
- .timeline-ticks {
- display: flex;
- justify-content: space-between;
- padding: 0 2px;
- font-size: 9px;
- color: var(--fg-dim);
- opacity: 0.6;
- }
-
- .tick-mark {
- width: 5%;
- text-align: center;
- }
-
- .experience-detail {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- }
-
- .exp-card {
- background: var(--bg-alt);
- border: 1px solid var(--border);
- border-radius: 4px;
- padding: 24px;
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 0;
- animation: slideUp 0.2s ease;
- }
-
- @keyframes slideUp {
- from {
- opacity: 0;
- transform: translateY(8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
-
- .exp-card.hidden {
- display: none;
- }
-
- .exp-card-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 16px;
- gap: 24px;
- }
-
- .exp-card-title {
- display: flex;
- align-items: center;
- gap: 16px;
- }
-
- .exp-logo {
- height: 32px;
- width: auto;
- flex-shrink: 0;
- }
-
- .exp-logo.logo-invert {
- filter: brightness(0) invert(1);
- }
-
- .exp-title-with-logo {
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
-
- .exp-title-with-logo .exp-role {
- color: #999;
- font-size: 14px;
- }
-
- .exp-title-with-logo .exp-duration {
- color: #aaa;
- font-size: 12px;
- }
-
- .exp-title-text h2 {
- font-size: 22px;
- font-weight: 500;
- color: var(--accent);
- margin: 0 0 4px 0;
- }
-
- .exp-title-text .exp-role {
- font-size: 14px;
- color: #999;
- }
-
- .exp-card-meta {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 4px;
- font-size: 13px;
- }
-
- .exp-dates {
- color: var(--fg);
- }
-
- .exp-duration {
- color: #aaa;
- font-size: 12px;
- }
-
- .exp-card-tech {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-bottom: 20px;
- }
-
- .exp-card-body {
- color: var(--fg);
- line-height: 1.7;
- padding-bottom: 0;
- margin-bottom: 0;
- }
-
- .exp-card-body p {
- margin-bottom: 12px;
- }
-
- .exp-card-body p:last-child {
- margin-bottom: 0;
- }
-
- .exp-card-images {
- display: flex;
- gap: 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: 16px;
- }
-
- .timeline-container {
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- }
-
- .timeline-bar {
- min-width: 500px;
- height: 40px;
- }
-
- .timeline-years {
- min-width: 500px;
- font-size: 11px;
- }
-
- .timeline-ticks {
- min-width: 500px;
- font-size: 8px;
- }
-
- .segment-content {
- padding: 0 4px;
- }
-
- .segment-company {
- font-size: 9px;
- }
-
- .segment-duration {
- font-size: 8px;
- }
-
- .exp-card {
- padding: 16px;
- }
-
- .exp-card-header {
- flex-direction: column;
- gap: 12px;
- }
-
- .exp-card-meta {
- align-items: flex-start;
- }
-
- .exp-logo {
- height: 28px;
- }
-
- .exp-title-text h2 {
- font-size: 18px;
- }
-
- .exp-card-tech {
- gap: 4px;
- }
-
- .exp-card-body {
- font-size: 13px;
- }
-
- .exp-card-images {
- flex-direction: column;
- gap: 12px;
- 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) {
- .timeline-bar {
- height: 36px;
- }
-
- .exp-image-thumb {
- width: calc(50% - 4px);
- }
- }
-</style>
diff --git a/web/src/components/pages/About.astro b/web/src/components/tabs/About.astro
diff --git a/web/src/components/pages/Blog.astro b/web/src/components/tabs/Blog.astro
diff --git a/web/src/components/tabs/Experience.astro b/web/src/components/tabs/Experience.astro
@@ -0,0 +1,523 @@
+---
+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/pages/Projects.astro b/web/src/components/tabs/Projects.astro
diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro
@@ -9,10 +9,10 @@ import websiteSchema from "../lib/schemas/website";
import { URL } from "../constants/website";
import Header from "../components/Header.astro";
-import AboutPage from "../components/pages/About.astro";
-import ExperiencePage from "../components/pages/Experience.astro";
-import ProjectsPage from "../components/pages/Projects.astro";
-import BlogPage from "../components/pages/Blog.astro";
+import 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";
const wakaTimeData = await fetchWakaTimeData();
diff --git a/web/src/scripts/ui/clicks.ts b/web/src/scripts/ui/clicks.ts
@@ -1,25 +1,6 @@
import { selectedIndex } from "./state";
import { updateUrl } from "./url";
-import { updateExperienceDetail, updateDetailView } from "./detail";
-
-export function setupTimelineClicks(): void {
- document.querySelectorAll(".timeline-segment").forEach((segment) => {
- segment.addEventListener("click", () => {
- const index = parseInt(segment.getAttribute("data-index") || "0");
- selectedIndex.experience = index;
-
- document.querySelectorAll(".timeline-segment").forEach((s) => {
- s.classList.toggle(
- "selected",
- parseInt(s.getAttribute("data-index") || "0") === index,
- );
- });
-
- updateExperienceDetail();
- updateUrl();
- });
- });
-}
+import { updateDetailView } from "./detail";
export function setupListClicks(): void {
document.querySelectorAll("#projects .list-item").forEach((item) => {
diff --git a/web/src/scripts/ui/index.ts b/web/src/scripts/ui/index.ts
@@ -2,7 +2,7 @@ import { applyInitialState } from "./state";
import { loadFromUrl } from "./url";
import { setupTabs } from "./tabs";
import { setupKeyboard } from "./keyboard";
-import { setupTimelineClicks, setupListClicks } from "./clicks";
+import { setupListClicks } from "./clicks";
import { setupBlogNavigation } from "./blog";
import { updateStatusTime } from "./status";
import { playBootAnimation } from "./boot";
@@ -13,7 +13,6 @@ export async function initUI(): Promise<void> {
loadFromUrl();
setupTabs();
setupKeyboard();
- setupTimelineClicks();
setupListClicks();
setupBlogNavigation();
applyInitialState();
diff --git a/web/src/scripts/ui/keyboard.ts b/web/src/scripts/ui/keyboard.ts
@@ -1,7 +1,7 @@
import { currentTab, currentBlogId, selectedIndex } from "./state";
import { getItemId, updateUrl } from "./url";
import { switchTab, cycleTab } from "./tabs";
-import { navigateList, navigateTimeline } from "./navigation";
+import { navigateList } from "./navigation";
import { openBlogPost, closeBlogPost } from "./blog";
export function setupKeyboard(): void {
@@ -41,11 +41,7 @@ export function setupKeyboard(): void {
)
return;
e.preventDefault();
- if (currentTab === "experience") {
- navigateTimeline(-1);
- } else {
- cycleTab(-1);
- }
+ cycleTab(-1);
break;
case "ArrowRight":
if (
@@ -55,11 +51,7 @@ export function setupKeyboard(): void {
)
return;
e.preventDefault();
- if (currentTab === "experience") {
- navigateTimeline(1);
- } else {
- cycleTab(1);
- }
+ cycleTab(1);
break;
case "Tab":
e.preventDefault();
diff --git a/web/src/scripts/ui/navigation.ts b/web/src/scripts/ui/navigation.ts
@@ -1,7 +1,6 @@
import { currentTab, selectedIndex, currentBlogId } from "./state";
import { updateUrl } from "./url";
-import { updateExperienceDetail, updateDetailView } from "./detail";
-import { cycleTab } from "./tabs";
+import { updateDetailView } from "./detail";
export function navigateList(direction: number): void {
if (currentTab === "about") return;
@@ -35,37 +34,29 @@ export function navigateList(direction: number): void {
}
export function navigateTimeline(direction: number): void {
- const segments = document.querySelectorAll("#experience .timeline-segment");
+ 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 sortedSegments = Array.from(segments).sort((a, b) => {
- const aLeft = parseFloat((a as HTMLElement).style.left) || 0;
- const bLeft = parseFloat((b as HTMLElement).style.left) || 0;
- return aLeft - bLeft;
- });
-
- const sortedIndices = sortedSegments.map((s) =>
- parseInt(s.getAttribute("data-index") || "0"),
- );
- const currentPos = sortedIndices.indexOf(selectedIndex.experience);
- const newPos = currentPos + direction;
+ const newIndex = selectedIndex.experience + direction;
- if (newPos < 0) {
- cycleTab(-1);
- return;
- }
- if (newPos >= sortedIndices.length) {
- cycleTab(1);
+ if (newIndex < 0 || newIndex > maxIndex) {
return;
}
- selectedIndex.experience = sortedIndices[newPos];
+ selectedIndex.experience = newIndex;
+
+ cards.forEach((card, i) => {
+ card.classList.toggle("selected", i === newIndex);
+ });
- segments.forEach((segment) => {
- const idx = parseInt(segment.getAttribute("data-index") || "0");
- segment.classList.toggle("selected", idx === selectedIndex.experience);
+ timelines.forEach((tl, i) => {
+ tl.classList.toggle("active", i === newIndex);
});
- updateExperienceDetail();
+ rows[newIndex]?.scrollIntoView({ behavior: "smooth", block: "start" });
+
updateUrl();
}
diff --git a/web/src/scripts/ui/state.ts b/web/src/scripts/ui/state.ts
@@ -1,10 +1,10 @@
import { openBlogPost } from "./blog";
-import { updateDetailView, updateExperienceDetail } from "./detail";
+import { updateDetailView } from "./detail";
export let currentTab = "about";
export const selectedIndex: Record<string, number> = {
- experience: getInitialExperienceIndex(),
+ experience: 0,
projects: getInitialProjectsIndex(),
blog: getInitialBlogIndex(),
};
@@ -27,14 +27,15 @@ export function applyInitialState(): void {
page.classList.toggle("active", page.id === currentTab);
});
- const timelineSegments = document.querySelectorAll(
- "#experience .timeline-segment",
- );
- timelineSegments.forEach((segment) => {
- const idx = parseInt(segment.getAttribute("data-index") || "0");
- segment.classList.toggle("selected", idx === selectedIndex.experience);
+ 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);
});
- updateExperienceDetail();
const projectItems = document.querySelectorAll("#projects .list-item");
projectItems.forEach((item, i) => {
@@ -56,13 +57,6 @@ export function setCurrentTab(tab: string): void {
currentTab = tab;
}
-function getInitialExperienceIndex(): number {
- const selected = document.querySelector(
- "#experience .timeline-segment.selected",
- );
- return selected ? parseInt(selected.getAttribute("data-index") || "0") : 0;
-}
-
function getInitialProjectsIndex(): number {
const selected = document.querySelector("#projects .list-item.selected");
return selected ? parseInt(selected.getAttribute("data-index") || "0") : 0;