From 5fa01b8d661181bee799d4e2b5cbe0d45394a091 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 11:00:46 +0000 Subject: [PATCH] feat: implement Embla carousel in ProjectsTile --- package-lock.json | 39 ++++ package.json | 2 + src/components/tiles/ProjectsTile.tsx | 249 ++++++++++++++++++-------- 3 files changed, 214 insertions(+), 76 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4642ae2..537ea53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@xenova/transformers": "^2.17.2", "concurrently": "^9.2.1", "d3": "^7.9.0", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", "framer-motion": "^11.15.0", "fuse.js": "^7.1.0", "lucide-react": "^0.468.0", @@ -3286,6 +3288,43 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz", + "integrity": "sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/package.json b/package.json index ac8c121..53d9ef3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@xenova/transformers": "^2.17.2", "concurrently": "^9.2.1", "d3": "^7.9.0", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", "framer-motion": "^11.15.0", "fuse.js": "^7.1.0", "lucide-react": "^0.468.0", diff --git a/src/components/tiles/ProjectsTile.tsx b/src/components/tiles/ProjectsTile.tsx index 7f6f110..4d946f9 100644 --- a/src/components/tiles/ProjectsTile.tsx +++ b/src/components/tiles/ProjectsTile.tsx @@ -1,4 +1,6 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import useEmblaCarousel from 'embla-carousel-react' +import Autoplay from 'embla-carousel-autoplay' import { investigations } from '@/data/investigations' import { Card, CardHeader } from '../Card' import { useDetailPanel } from '@/contexts/DetailPanelContext' @@ -12,10 +14,11 @@ const statusColorMap: Record = { interface ProjectItemProps { project: Investigation + slideBasis: string onClick: () => void } -function ProjectItem({ project, onClick }: ProjectItemProps) { +function ProjectItem({ project, slideBasis, onClick }: ProjectItemProps) { const dotColor = statusColorMap[project.status] || '#0D6E6E' const isLive = project.status === 'Live' @@ -31,112 +34,206 @@ function ProjectItem({ project, onClick }: ProjectItemProps) { return (
{ - e.currentTarget.style.borderColor = 'var(--accent-border)' - e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)' - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'var(--border-light)' - e.currentTarget.style.boxShadow = 'none' + flex: `0 0 ${slideBasis}`, + minWidth: 0, + paddingRight: '10px', }} > - {/* Row: status dot + name + year */}
{ + e.currentTarget.style.borderColor = 'var(--accent-border)' + e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--border-light)' + e.currentTarget.style.boxShadow = 'none' }} > + Thumbnail Pending +
- {/* Tech stack tags */} - {project.techStack && project.techStack.length > 0 && (
- {project.techStack.map((tech) => ( - - {tech} - - ))} + - )} + + {project.techStack && project.techStack.length > 0 && ( +
+ {project.techStack.map((tech) => ( + + {tech} + + ))} +
+ )} +
) } export function ProjectsTile() { const { openPanel } = useDetailPanel() + const [viewportWidth, setViewportWidth] = useState( + typeof window !== 'undefined' ? window.innerWidth : 1200, + ) + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) + + const [emblaRef] = useEmblaCarousel( + { + align: 'start', + containScroll: 'trimSnaps', + loop: true, + dragFree: true, + }, + useMemo( + () => + prefersReducedMotion + ? [] + : [ + Autoplay({ + delay: 3500, + stopOnInteraction: false, + stopOnMouseEnter: true, + stopOnFocusIn: true, + }), + ], + [prefersReducedMotion], + ), + ) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + const resizeHandler = () => setViewportWidth(window.innerWidth) + resizeHandler() + window.addEventListener('resize', resizeHandler) + + return () => window.removeEventListener('resize', resizeHandler) + }, []) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + const syncMotionPreference = () => setPrefersReducedMotion(mediaQuery.matches) + + syncMotionPreference() + mediaQuery.addEventListener('change', syncMotionPreference) + + return () => mediaQuery.removeEventListener('change', syncMotionPreference) + }, []) + + const slideBasis = useMemo(() => { + if (viewportWidth < 768) { + return '100%' + } + if (viewportWidth < 1200) { + return '50%' + } + return '33.3333%' + }, [viewportWidth]) return ( -
- {investigations.map((project) => ( - openPanel({ type: 'project', investigation: project })} - /> - ))} +
+
+ {investigations.map((project) => ( + openPanel({ type: 'project', investigation: project })} + /> + ))} +
)