feat: implement Embla carousel in ProjectsTile

This commit is contained in:
2026-02-16 11:00:46 +00:00
parent 98d767fa7f
commit 5fa01b8d66
3 changed files with 214 additions and 76 deletions
+39
View File
@@ -12,6 +12,8 @@
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
@@ -3286,6 +3288,43 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+2
View File
@@ -17,6 +17,8 @@
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
+173 -76
View File
@@ -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 { investigations } from '@/data/investigations'
import { Card, CardHeader } from '../Card' import { Card, CardHeader } from '../Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext' import { useDetailPanel } from '@/contexts/DetailPanelContext'
@@ -12,10 +14,11 @@ const statusColorMap: Record<string, string> = {
interface ProjectItemProps { interface ProjectItemProps {
project: Investigation project: Investigation
slideBasis: string
onClick: () => void onClick: () => void
} }
function ProjectItem({ project, onClick }: ProjectItemProps) { function ProjectItem({ project, slideBasis, onClick }: ProjectItemProps) {
const dotColor = statusColorMap[project.status] || '#0D6E6E' const dotColor = statusColorMap[project.status] || '#0D6E6E'
const isLive = project.status === 'Live' const isLive = project.status === 'Live'
@@ -31,112 +34,206 @@ function ProjectItem({ project, onClick }: ProjectItemProps) {
return ( return (
<div <div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
style={{ style={{
display: 'flex', flex: `0 0 ${slideBasis}`,
flexDirection: 'column', minWidth: 0,
background: 'var(--surface)', paddingRight: '10px',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
padding: '12px 16px',
minHeight: '44px',
fontSize: '13px',
color: 'var(--text-primary)',
transition: 'border-color 0.15s, box-shadow 0.15s',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
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'
}} }}
> >
{/* Row: status dot + name + year */}
<div <div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'flex-start', flexDirection: 'column',
gap: '8px', gap: '10px',
marginBottom: '8px', background: 'var(--surface)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
padding: '12px',
minHeight: '176px',
fontSize: '13px',
color: 'var(--text-primary)',
transition: 'border-color 0.15s, box-shadow 0.15s',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
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'
}} }}
> >
<div <div
style={{ style={{
width: '8px', height: '72px',
height: '8px', borderRadius: '6px',
borderRadius: '50%', border: '1px solid var(--border-light)',
backgroundColor: dotColor, background:
flexShrink: 0, 'linear-gradient(135deg, rgba(19, 94, 94, 0.12), rgba(212, 171, 46, 0.18))',
marginTop: '4px', display: 'flex',
animation: isLive ? 'pulse 2s infinite' : undefined, alignItems: 'center',
}} justifyContent: 'center',
aria-hidden="true"
/>
<span style={{ flex: 1, fontWeight: 500 }}>{project.name}</span>
<span
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)', fontFamily: 'var(--font-geist-mono)',
fontSize: '10px',
letterSpacing: '0.08em',
color: 'var(--text-tertiary)', color: 'var(--text-tertiary)',
flexShrink: 0, textTransform: 'uppercase',
}} }}
> >
{project.requestedYear} Thumbnail Pending
</span> </div>
</div>
{/* Tech stack tags */}
{project.techStack && project.techStack.length > 0 && (
<div <div
style={{ style={{
display: 'flex', display: 'flex',
flexWrap: 'wrap', alignItems: 'flex-start',
gap: '4px', gap: '8px',
marginBottom: '8px',
}} }}
> >
{project.techStack.map((tech) => ( <div
<span style={{
key={tech} width: '8px',
style={{ height: '8px',
fontSize: '10px', borderRadius: '50%',
fontFamily: 'var(--font-geist-mono)', backgroundColor: dotColor,
padding: '3px 8px', flexShrink: 0,
borderRadius: '3px', marginTop: '4px',
background: 'var(--amber-light)', animation: isLive ? 'pulse 2s infinite' : undefined,
color: '#92400E', }}
border: '1px solid var(--amber-border)', aria-hidden="true"
}} />
> <span style={{ flex: 1, fontWeight: 500 }}>{project.name}</span>
{tech} <span
</span> style={{
))} fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
flexShrink: 0,
}}
>
{project.requestedYear}
</span>
</div> </div>
)}
{project.techStack && project.techStack.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
marginTop: 'auto',
}}
>
{project.techStack.map((tech) => (
<span
key={tech}
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '3px',
background: 'var(--amber-light)',
color: '#92400E',
border: '1px solid var(--amber-border)',
}}
>
{tech}
</span>
))}
</div>
)}
</div>
</div> </div>
) )
} }
export function ProjectsTile() { export function ProjectsTile() {
const { openPanel } = useDetailPanel() 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 ( return (
<Card tileId="projects"> <Card tileId="projects">
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" /> <CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}> <div ref={emblaRef} style={{ overflow: 'hidden' }}>
{investigations.map((project) => ( <div style={{ display: 'flex', marginRight: '-10px' }}>
<ProjectItem {investigations.map((project) => (
key={project.id} <ProjectItem
project={project} key={project.id}
onClick={() => openPanel({ type: 'project', investigation: project })} project={project}
/> slideBasis={slideBasis}
))} onClick={() => openPanel({ type: 'project', investigation: project })}
/>
))}
</div>
</div> </div>
</Card> </Card>
) )