prime8dev

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

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:
Dweb/src/components/pages/Experience.astro | 650-------------------------------------------------------------------------------
Rweb/src/components/pages/About.astro -> web/src/components/tabs/About.astro | 0
Rweb/src/components/pages/Blog.astro -> web/src/components/tabs/Blog.astro | 0
Aweb/src/components/tabs/Experience.astro | 523+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rweb/src/components/pages/Projects.astro -> web/src/components/tabs/Projects.astro | 0
Mweb/src/pages/index.astro | 8++++----
Mweb/src/scripts/ui/clicks.ts | 21+--------------------
Mweb/src/scripts/ui/index.ts | 3+--
Mweb/src/scripts/ui/keyboard.ts | 14+++-----------
Mweb/src/scripts/ui/navigation.ts | 41++++++++++++++++-------------------------
Mweb/src/scripts/ui/state.ts | 26++++++++++----------------
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;