feat: implement Embla carousel in ProjectsTile
This commit is contained in:
Generated
+39
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user