349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import Link from "next/link"
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
import { artists as staticArtists } from "@/data/artists"
|
|
import { useActiveArtists } from "@/hooks/use-artists"
|
|
import type { PublicArtist } from "@/types/database"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
type ArtistGridSet = {
|
|
key: string
|
|
items: PublicArtist[]
|
|
}
|
|
|
|
const GRID_SIZE = 8
|
|
const GRID_INTERVAL = 12000
|
|
|
|
export function ArtistsSection() {
|
|
const { data: dbArtistsData, isLoading, error } = useActiveArtists()
|
|
|
|
const artists = useMemo(() => {
|
|
if (isLoading || error || !dbArtistsData) {
|
|
return staticArtists
|
|
}
|
|
|
|
return staticArtists.map((staticArtist) => {
|
|
const dbArtist = dbArtistsData.artists.find(
|
|
(db) => db.slug === staticArtist.slug || db.name === staticArtist.name
|
|
)
|
|
|
|
if (dbArtist && dbArtist.portfolioImages.length > 0) {
|
|
return {
|
|
...staticArtist,
|
|
workImages: dbArtist.portfolioImages.map((img) => img.url),
|
|
}
|
|
}
|
|
|
|
return staticArtist
|
|
})
|
|
}, [dbArtistsData, error, isLoading])
|
|
|
|
const artistsRef = useRef(artists)
|
|
const gridSetsRef = useRef<ArtistGridSet[]>([])
|
|
const activeSetRef = useRef(0)
|
|
|
|
const [gridSets, setGridSets] = useState<ArtistGridSet[]>([])
|
|
const [activeSetIndex, setActiveSetIndex] = useState(0)
|
|
const [previousSetIndex, setPreviousSetIndex] = useState<number | null>(null)
|
|
const [centerIndex, setCenterIndex] = useState(0)
|
|
|
|
artistsRef.current = artists
|
|
gridSetsRef.current = gridSets
|
|
activeSetRef.current = activeSetIndex
|
|
|
|
const shuffleArtists = useCallback((input: PublicArtist[]) => {
|
|
const array = [...input]
|
|
for (let i = array.length - 1; i > 0; i -= 1) {
|
|
const j = Math.floor(Math.random() * (i + 1))
|
|
;[array[i], array[j]] = [array[j], array[i]]
|
|
}
|
|
return array
|
|
}, [])
|
|
|
|
const ensureGridCount = useCallback(
|
|
(pool: PublicArtist[], chunk: PublicArtist[]) => {
|
|
if (chunk.length >= GRID_SIZE) {
|
|
return chunk.slice(0, GRID_SIZE)
|
|
}
|
|
const topUpSource = shuffleArtists(pool)
|
|
const needed = GRID_SIZE - chunk.length
|
|
return [...chunk, ...topUpSource.slice(0, needed)]
|
|
},
|
|
[shuffleArtists]
|
|
)
|
|
|
|
const createKey = () => Math.random().toString(36).slice(2)
|
|
|
|
const regenerateSets = useCallback(() => {
|
|
const pool = artistsRef.current
|
|
if (pool.length === 0) {
|
|
setGridSets([])
|
|
setActiveSetIndex(0)
|
|
setPreviousSetIndex(null)
|
|
return
|
|
}
|
|
|
|
const shuffled = shuffleArtists(pool)
|
|
const batches: ArtistGridSet[] = []
|
|
|
|
for (let i = 0; i < shuffled.length; i += GRID_SIZE) {
|
|
const slice = ensureGridCount(pool, shuffled.slice(i, i + GRID_SIZE))
|
|
batches.push({ key: `${createKey()}-${i}`, items: slice })
|
|
}
|
|
|
|
if (batches.length === 1) {
|
|
const alternate = ensureGridCount(pool, shuffleArtists(pool))
|
|
batches.push({ key: `${createKey()}-alt`, items: alternate })
|
|
}
|
|
|
|
setGridSets(batches)
|
|
setActiveSetIndex(0)
|
|
setPreviousSetIndex(null)
|
|
}, [ensureGridCount, shuffleArtists])
|
|
|
|
useEffect(() => {
|
|
regenerateSets()
|
|
}, [artists, regenerateSets])
|
|
|
|
const advanceSet = useCallback(() => {
|
|
if (gridSetsRef.current.length === 0) {
|
|
return
|
|
}
|
|
|
|
setPreviousSetIndex(activeSetRef.current)
|
|
setActiveSetIndex((prev) => {
|
|
const next = prev + 1
|
|
if (next >= gridSetsRef.current.length) {
|
|
regenerateSets()
|
|
setCenterIndex(0)
|
|
return 0
|
|
}
|
|
setCenterIndex(0)
|
|
return next
|
|
})
|
|
}, [regenerateSets])
|
|
|
|
const rotateCarousel = useCallback((direction: 'next' | 'prev') => {
|
|
if (gridSetsRef.current.length === 0) return
|
|
|
|
const currentSet = gridSetsRef.current[activeSetRef.current]
|
|
if (!currentSet) return
|
|
|
|
setCenterIndex((prev) => {
|
|
const nextIndex = direction === 'next'
|
|
? (prev + 1) % currentSet.items.length
|
|
: (prev - 1 + currentSet.items.length) % currentSet.items.length
|
|
|
|
// If we've looped back to start on 'next', advance to next set
|
|
if (direction === 'next' && nextIndex === 0 && prev === currentSet.items.length - 1) {
|
|
setTimeout(() => advanceSet(), 0)
|
|
}
|
|
|
|
return nextIndex
|
|
})
|
|
}, [advanceSet])
|
|
|
|
useEffect(() => {
|
|
if (gridSets.length === 0) {
|
|
return
|
|
}
|
|
|
|
const interval = window.setInterval(() => {
|
|
rotateCarousel('next')
|
|
}, GRID_INTERVAL)
|
|
|
|
return () => window.clearInterval(interval)
|
|
}, [rotateCarousel, gridSets.length])
|
|
|
|
const displayIndices = useMemo(() => {
|
|
const indices = new Set<number>()
|
|
indices.add(activeSetIndex)
|
|
if (previousSetIndex !== null && previousSetIndex !== activeSetIndex) {
|
|
indices.add(previousSetIndex)
|
|
}
|
|
return Array.from(indices)
|
|
}, [activeSetIndex, previousSetIndex])
|
|
|
|
const getArtistImage = (artist: PublicArtist) => {
|
|
// Try faceImage from static data first
|
|
if ((artist as any).faceImage) {
|
|
return (artist as any).faceImage
|
|
}
|
|
// Fall back to first work image
|
|
if (artist.workImages && artist.workImages.length > 0) {
|
|
return artist.workImages[0]
|
|
}
|
|
// Final fallback
|
|
return "/placeholder.svg"
|
|
}
|
|
|
|
return (
|
|
<section id="artists" className="relative isolate overflow-hidden pb-32 pt-32 lg:pb-40 lg:pt-40">
|
|
<div className="pointer-events-none absolute inset-0 -z-10">
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(14,11,9,0)_0%,rgba(14,11,9,0.85)_20%,rgba(14,11,9,0.92)_55%,rgba(14,11,9,0.98)_100%)]" />
|
|
<div
|
|
className="absolute -left-16 top-[8%] h-[480px] w-[420px] rotate-[-8deg] rounded-[36px] opacity-40 blur-[1px]"
|
|
style={{
|
|
backgroundImage:
|
|
"image-set(url('/assets/liberty/mural-portrait-sun.avif') type('image/avif'), url('/assets/liberty/mural-portrait-sun.webp') type('image/webp'))",
|
|
backgroundSize: "cover",
|
|
backgroundPosition: "center",
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute -right-24 top-[35%] hidden h-[540px] w-[420px] rotate-[6deg] rounded-[36px] opacity-30 lg:block"
|
|
style={{
|
|
backgroundImage:
|
|
"image-set(url('/assets/liberty/mural-orange-wall.avif') type('image/avif'), url('/assets/liberty/mural-orange-wall.webp') type('image/webp'))",
|
|
backgroundSize: "cover",
|
|
backgroundPosition: "center",
|
|
}}
|
|
/>
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_55%)]" />
|
|
</div>
|
|
|
|
<div className="relative mx-auto flex max-w-7xl flex-col gap-16 px-6 lg:px-10 xl:flex-row">
|
|
<div className="xl:w-[40%] space-y-10 text-white">
|
|
<div className="space-y-8">
|
|
<span className="inline-flex items-center gap-3 text-[0.7rem] font-semibold uppercase tracking-[0.55em] text-white/55">
|
|
<span className="h-px w-8 bg-white/35" /> Resident & Guest Artists
|
|
</span>
|
|
<h2 className="font-playfair text-4xl leading-[1.1] tracking-tight sm:text-5xl lg:text-[3.6rem]">
|
|
Artists Who Know What They're Doing
|
|
</h2>
|
|
<p className="max-w-xl text-base leading-relaxed text-white/70 sm:text-lg">
|
|
Cover-up specialists, illustrative work, anime, and fine line. Each artist brings years of experience and their own style.
|
|
Custom work and flash drops.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
|
<Button
|
|
asChild
|
|
className="w-full bg-white px-8 py-4 text-sm font-semibold uppercase tracking-wide text-black transition-colors hover:bg-white/90 sm:w-auto"
|
|
>
|
|
<Link href="/book">Book Your Session</Link>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
asChild
|
|
className="w-full justify-start border border-white/20 bg-white/5 px-6 py-4 text-sm font-medium uppercase tracking-wide text-white/80 backdrop-blur hover:bg-white/10 sm:w-auto"
|
|
>
|
|
<Link href="/artists">View All Artists</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative xl:w-[60%]">
|
|
<div className="relative h-[500px] sm:h-[550px] lg:h-[600px] flex items-center justify-center">
|
|
{gridSets[activeSetIndex] && (
|
|
<div className="relative w-full h-full flex items-center justify-center">
|
|
{gridSets[activeSetIndex].items.map((artist, index) => {
|
|
const href = `/artists/${artist.slug}`
|
|
const image = getArtistImage(artist)
|
|
|
|
// Calculate position relative to center with wrapping for infinite loop
|
|
let positionFromCenter = index - centerIndex
|
|
const totalCards = gridSets[activeSetIndex].items.length
|
|
|
|
// Wrap around for continuous loop effect
|
|
if (positionFromCenter < -2) {
|
|
positionFromCenter += totalCards
|
|
} else if (positionFromCenter > totalCards / 2) {
|
|
positionFromCenter -= totalCards
|
|
}
|
|
|
|
// Only show center + 2 behind on each side
|
|
const isVisible = Math.abs(positionFromCenter) <= 2
|
|
|
|
// Calculate transforms for stacked deck effect
|
|
const isCenterCard = positionFromCenter === 0
|
|
|
|
// Cards stack behind based on position
|
|
let translateY = 0
|
|
let translateX = 0
|
|
let scale = 1
|
|
let opacity = 1
|
|
let zIndex = 10
|
|
|
|
if (positionFromCenter > 0) {
|
|
// Cards to the right (future cards) - stack behind on right
|
|
translateY = positionFromCenter * 20
|
|
translateX = positionFromCenter * 40
|
|
scale = 1 - positionFromCenter * 0.08
|
|
opacity = Math.max(0, 1 - positionFromCenter * 0.4)
|
|
zIndex = 10 - positionFromCenter
|
|
} else if (positionFromCenter < 0) {
|
|
// Cards to the left (past cards) - slide out to left and fade
|
|
translateX = positionFromCenter * 150
|
|
opacity = Math.max(0, 1 + positionFromCenter * 0.5)
|
|
zIndex = 10 + positionFromCenter
|
|
}
|
|
|
|
if (!isVisible) return null
|
|
|
|
return (
|
|
<Link
|
|
key={`${artist.id}-${artist.slug}-${index}`}
|
|
href={href}
|
|
className="group absolute aspect-[3/4] w-[280px] sm:w-[320px] lg:w-[380px] overflow-hidden rounded-2xl border border-white/12 text-left transition-all duration-700 ease-out hover:border-white/25"
|
|
style={{
|
|
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
|
|
opacity,
|
|
zIndex,
|
|
pointerEvents: isCenterCard ? 'auto' : 'none',
|
|
}}
|
|
>
|
|
<img
|
|
src={image}
|
|
alt={`${artist.name} portfolio sample`}
|
|
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.06]"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0b0907] via-transparent to-transparent" />
|
|
<div className="absolute right-5 top-5 h-12 w-12 rounded-full border border-white/20 bg-white/10 backdrop-blur">
|
|
<span className="absolute inset-2 rounded-full border border-white/15" />
|
|
</div>
|
|
<div className="absolute bottom-0 left-0 right-0 p-6 space-y-2">
|
|
<p className="text-lg font-semibold uppercase tracking-wider text-white">
|
|
{artist.name}
|
|
</p>
|
|
<p className="text-sm uppercase tracking-wide text-white/60">
|
|
{(artist as any).specialty || "Tattoo Artist"}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
)
|
|
})}
|
|
|
|
{/* Navigation buttons */}
|
|
<button
|
|
onClick={() => rotateCarousel('prev')}
|
|
className="absolute left-0 z-20 p-3 text-white/60 transition-colors hover:text-white"
|
|
aria-label="Previous artist"
|
|
>
|
|
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => rotateCarousel('next')}
|
|
className="absolute right-0 z-20 p-3 text-white/60 transition-colors hover:text-white"
|
|
aria-label="Next artist"
|
|
>
|
|
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|