diff --git a/src/components/ProjectsSection.module.css b/src/components/ProjectsSection.module.css index 30e839a..dcdf92d 100644 --- a/src/components/ProjectsSection.module.css +++ b/src/components/ProjectsSection.module.css @@ -75,6 +75,8 @@ overscroll-behavior-inline: contain; touch-action: pan-y; user-select: none; + container-type: inline-size; + container-name: carousel; } .carouselViewport::-webkit-scrollbar { @@ -89,23 +91,28 @@ .dragging { cursor: grabbing; + scroll-snap-type: none !important; + scroll-behavior: auto !important; + user-select: none; } .carouselTrack { display: flex; align-items: stretch; width: max-content; - gap: 24px; + gap: 20px; padding: 4px 0 8px; } .card { display: flex; - flex: 0 0 var(--size-project-width); + flex: 0 0 clamp(300px, 24vw, 340px); + flex-shrink: 0; flex-direction: column; - width: var(--size-project-width); - min-height: 480px; + width: clamp(300px, 24vw, 340px); + min-height: 460px; overflow: hidden; + box-sizing: border-box; border: 1px solid var(--border-default); border-radius: var(--radius-lg); background: var(--bg-surface); @@ -116,13 +123,14 @@ .mediaContainer { position: relative; width: 100%; - height: var(--size-project-media-height); + aspect-ratio: 16 / 10; overflow: hidden; background: var(--color-blue-900); } .projectImage { object-fit: cover; + object-position: center; pointer-events: none; transition: transform 0.3s ease; } @@ -131,7 +139,7 @@ display: flex; flex: 1; flex-direction: column; - padding: 24px; + padding: 18px 16px; } .cardTitleRow { @@ -143,9 +151,9 @@ .cardTitle { color: var(--text-primary); - font-size: var(--font-size-h3); + font-size: 18px; font-weight: 700; - line-height: var(--line-height-h3); + line-height: 1.25; } .titleLinkIcon { @@ -158,9 +166,9 @@ } .cardDate { - margin-bottom: 14px; + margin-bottom: 10px; color: var(--text-muted); - font-size: var(--font-size-label-small); + font-size: 11px; font-weight: 700; line-height: var(--line-height-label-small); letter-spacing: var(--letter-spacing-eyebrow); @@ -169,21 +177,24 @@ .cardDesc { display: -webkit-box; - margin-bottom: 20px; + margin-bottom: 16px; overflow: hidden; color: var(--text-primary); - font-size: var(--font-size-body-small); + font-size: 13.5px; font-weight: 400; - line-height: var(--line-height-body-small); + line-height: 1.55; -webkit-box-orient: vertical; - -webkit-line-clamp: 4; + -webkit-line-clamp: 3; + white-space: normal; + word-break: normal; + overflow-wrap: break-word; } .tagList { display: flex; flex-wrap: wrap; - gap: 8px; - margin-bottom: 24px; + gap: 6px; + margin-bottom: 18px; } .tagBadge { @@ -233,7 +244,9 @@ } .scrollbar { + --thumb-width: 216px; width: 480px; + max-width: 100%; height: 20px; margin: 0; appearance: none; @@ -248,7 +261,7 @@ } .scrollbar::-webkit-slider-thumb { - width: 216px; + width: var(--thumb-width, 216px); height: 6px; margin-top: 0; appearance: none; @@ -265,7 +278,7 @@ } .scrollbar::-moz-range-thumb { - width: 216px; + width: var(--thumb-width, 216px); height: 6px; border: 0; border-radius: var(--radius-pill); @@ -294,65 +307,192 @@ background: var(--color-blue-300); } -@media (max-width: 1024px) { +/* ================================================================ + LAPTOP 1101px – 1440px + Comfortable ~290px-330px cards (~3 to 4 visible) + ================================================================ */ +@media (max-width: 1440px) and (min-width: 1101px) { + .carouselTrack { + gap: 20px; + } + + .card { + flex: 0 0 clamp(290px, 23vw, 330px); + width: clamp(290px, 23vw, 330px); + } + + .scrollbar { + width: min(480px, 50vw); + --thumb-width: 120px; + } +} + +/* ================================================================ + TABLET 651px – 1100px + Comfortable ~310px-350px cards (~2 cards visible) + ================================================================ */ +@media (max-width: 1100px) and (min-width: 651px) { .projectsSection { padding-top: var(--space-8); padding-bottom: var(--space-9); } - .carouselTrack { - gap: var(--space-4); - } - - .card { - flex-basis: min(340px, calc(100vw - (2 * var(--layout-page-gutter)))); - width: min(340px, calc(100vw - (2 * var(--layout-page-gutter)))); - } -} - -@media (max-width: 640px) { .headerRow { - align-items: stretch; - flex-direction: column; - gap: var(--space-3); - margin-bottom: var(--space-6); - } - - .arrowButtons { - align-self: flex-end; - gap: var(--space-2); - } - - .navButton { - width: 40px; - height: 40px; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 32px; } .carouselViewport { - margin-inline: calc(-1 * var(--layout-page-gutter)); - width: calc(100% + (2 * var(--layout-page-gutter))); - padding-inline: var(--layout-page-gutter); - scroll-padding-inline: var(--layout-page-gutter); scroll-snap-type: x mandatory; + scroll-padding-inline: 0; + } + + .carouselTrack { + gap: 20px; } .card { - flex-basis: min(320px, calc(100vw - 40px)); - width: min(320px, calc(100vw - 40px)); - min-height: 460px; + flex: 0 0 clamp(310px, 42vw, 350px); + width: clamp(310px, 42vw, 350px); + min-height: 450px; + box-sizing: border-box; + scroll-snap-align: start; } .mediaContainer { - height: 200px; + aspect-ratio: 16 / 10; } .cardBody { - padding: var(--space-5); + padding: 18px 16px; + } + + .cardTitle { + font-size: 18px; + } + + .cardDesc { + font-size: 13.5px; + line-height: 1.55; + margin-bottom: 14px; + -webkit-line-clamp: 3; + white-space: normal; + word-break: normal; + overflow-wrap: break-word; + } + + .tagList { + gap: 6px; + margin-bottom: 16px; + } + + .tagBadge { + padding: 3px 9px; + font-size: 11px; } .scrollbarWrapper { + display: flex; + justify-content: center; + margin-top: 32px; + } + + .scrollbar { + width: min(360px, 60vw); + --thumb-width: 90px; + } +} + +/* ================================================================ + MOBILE ≤ 650px + Comfortable ~310px-330px card (~1 card visible with clean margins) + ================================================================ */ +@media (max-width: 650px) { + .projectsSection { + padding-top: 48px; + padding-bottom: 64px; + } + + .headerRow { + margin-bottom: 24px; + } + + .arrowButtons { display: none; } + + .carouselViewport { + width: 100%; + margin-inline: 0; + padding-inline: 0; + scroll-padding-inline: 0; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + } + + .carouselTrack { + gap: 16px; + width: max-content; + } + + .card { + flex: 0 0 calc(100vw - (2 * var(--layout-page-gutter))); + flex: 0 0 100cqw; + width: calc(100vw - (2 * var(--layout-page-gutter))); + width: 100cqw; + min-width: calc(100vw - (2 * var(--layout-page-gutter))); + min-width: 100cqw; + max-width: 100%; + min-height: 420px; + box-sizing: border-box; + scroll-snap-align: start; + scroll-snap-stop: always; + } + + .mediaContainer { + aspect-ratio: 16 / 10; + } + + .cardBody { + padding: 18px 16px; + } + + .cardTitle { + font-size: 18px; + } + + .cardDesc { + font-size: 13px; + line-height: 1.5; + margin-bottom: 14px; + white-space: normal; + word-break: normal; + overflow-wrap: break-word; + -webkit-line-clamp: 3; + } + + .tagList { + gap: 6px; + margin-bottom: 16px; + } + + .tagBadge { + padding: 3px 9px; + font-size: 11px; + } + + .scrollbarWrapper { + display: flex; + justify-content: center; + margin-top: 24px; + } + + .scrollbar { + width: min(200px, 60vw); + height: 16px; + --thumb-width: 60px; + } } @media (hover: hover) and (pointer: fine) { diff --git a/src/components/ProjectsSection.tsx b/src/components/ProjectsSection.tsx index aaf4e8a..107708d 100644 --- a/src/components/ProjectsSection.tsx +++ b/src/components/ProjectsSection.tsx @@ -12,7 +12,13 @@ export const ProjectsSection: React.FC = () => { active: false, moved: false, startX: 0, + startY: 0, startScrollLeft: 0, + lastX: 0, + lastTime: 0, + velocity: 0, + pointerId: -1, + directionLocked: null as 'horizontal' | 'vertical' | null, }); const [isDragging, setIsDragging] = useState(false); const [scrollProgress, setScrollProgress] = useState(0); @@ -51,7 +57,7 @@ export const ProjectsSection: React.FC = () => { }; const handlePointerDown = (event: React.PointerEvent) => { - if (event.button !== 0) return; + if (event.button !== 0 && event.pointerType === 'mouse') return; const viewport = viewportRef.current; if (!viewport) return; @@ -60,30 +66,112 @@ export const ProjectsSection: React.FC = () => { active: true, moved: false, startX: event.clientX, + startY: event.clientY, startScrollLeft: viewport.scrollLeft, + lastX: event.clientX, + lastTime: performance.now(), + velocity: 0, + pointerId: event.pointerId, + directionLocked: null, }; - viewport.setPointerCapture(event.pointerId); - setIsDragging(true); }; const handlePointerMove = (event: React.PointerEvent) => { const viewport = viewportRef.current; if (!viewport || !dragState.current.active) return; - const distance = event.clientX - dragState.current.startX; - if (Math.abs(distance) > 3) dragState.current.moved = true; - viewport.scrollLeft = dragState.current.startScrollLeft - distance; + const deltaX = event.clientX - dragState.current.startX; + const deltaY = event.clientY - dragState.current.startY; + + // Detect user intent: horizontal carousel swipe vs vertical page scroll + if (!dragState.current.directionLocked) { + if (Math.abs(deltaX) < 6 && Math.abs(deltaY) < 6) return; + + if (Math.abs(deltaY) > Math.abs(deltaX)) { + // User intends to scroll page vertically, release touch + dragState.current.directionLocked = 'vertical'; + dragState.current.active = false; + return; + } + + // User intends horizontal carousel drag + dragState.current.directionLocked = 'horizontal'; + try { + viewport.setPointerCapture(event.pointerId); + } catch { + // Ignore if pointer capture unavailable + } + setIsDragging(true); + } + + if (dragState.current.directionLocked === 'horizontal') { + const now = performance.now(); + const dt = Math.max(now - dragState.current.lastTime, 1); + const dx = event.clientX - dragState.current.lastX; + dragState.current.velocity = dx / dt; + dragState.current.lastX = event.clientX; + dragState.current.lastTime = now; + + if (Math.abs(deltaX) > 4) dragState.current.moved = true; + viewport.scrollLeft = dragState.current.startScrollLeft - deltaX; + } }; const stopDragging = (event: React.PointerEvent) => { const viewport = viewportRef.current; - if (!viewport || !dragState.current.active) return; + if (!viewport || !dragState.current.active) { + setIsDragging(false); + return; + } + + const wasHorizontal = dragState.current.directionLocked === 'horizontal'; + const moved = dragState.current.moved; + const velocity = dragState.current.velocity; + const clientX = event.clientX ?? dragState.current.lastX; + const totalDeltaX = clientX - dragState.current.startX; + const pointerId = dragState.current.pointerId; dragState.current.active = false; - if (viewport.hasPointerCapture(event.pointerId)) { - viewport.releasePointerCapture(event.pointerId); + if (viewport.hasPointerCapture(pointerId)) { + try { + viewport.releasePointerCapture(pointerId); + } catch { + // Ignore + } } setIsDragging(false); + + if (!wasHorizontal || !moved) return; + + // Calculate snap target with velocity fling + const track = viewport.firstElementChild as HTMLElement | null; + const card = track?.firstElementChild as HTMLElement | null; + if (!track || !card) return; + + const cardWidth = card.getBoundingClientRect().width; + const gap = Number.parseFloat(window.getComputedStyle(track).columnGap) || 0; + const step = cardWidth + gap; + if (step <= 0) return; + + const currentScroll = viewport.scrollLeft; + let targetIndex: number; + if (Math.abs(velocity) > 0.2 || Math.abs(totalDeltaX) > cardWidth * 0.18) { + if (totalDeltaX < 0 || velocity < -0.2) { + targetIndex = Math.ceil(currentScroll / step); + } else { + targetIndex = Math.floor(currentScroll / step); + } + } else { + targetIndex = Math.round(currentScroll / step); + } + + const maxIndex = projects.length - 1; + targetIndex = Math.max(0, Math.min(targetIndex, maxIndex)); + + viewport.scrollTo({ + left: targetIndex * step, + behavior: 'smooth', + }); }; const handleClickCapture = (event: React.MouseEvent) => { @@ -102,6 +190,16 @@ export const ProjectsSection: React.FC = () => { viewport.scrollLeft = (Number(event.target.value) / 100) * maxScroll; }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollByCard(-1); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollByCard(1); + } + }; + return (
{ onPointerUp={stopDragging} onPointerCancel={stopDragging} onClickCapture={handleClickCapture} + onKeyDown={handleKeyDown} tabIndex={0} aria-label="Scrollable project cards" > @@ -163,6 +262,8 @@ export const ProjectsSection: React.FC = () => { src={project.image} alt={project.title} fill + sizes="(max-width: 650px) 100vw, (max-width: 1100px) 100vw, 360px" + priority={project.id === 'qlockify'} draggable={false} className={styles.projectImage} />