feat(artists/mobile): add lightbox swipe, preserve scroll on modal, animated filter pills, carousel dots indicators, and a11y roles; desktop untouched
This commit is contained in:
parent
17f1bd678e
commit
21da20d927
@ -5,11 +5,11 @@ import Image from "next/image"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Instagram, ExternalLink, Loader2, DollarSign } from "lucide-react"
|
import { Instagram, ExternalLink, Loader2, DollarSign } from "lucide-react"
|
||||||
import { useArtist } from "@/hooks/use-artist-data"
|
import { useArtist } from "@/hooks/use-artist-data"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Carousel, CarouselContent, CarouselItem } from "@/components/ui/carousel"
|
import { type CarouselApi, Carousel, CarouselContent, CarouselItem } from "@/components/ui/carousel"
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
interface ArtistPortfolioProps {
|
interface ArtistPortfolioProps {
|
||||||
@ -22,6 +22,10 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
const [scrollY, setScrollY] = useState(0)
|
const [scrollY, setScrollY] = useState(0)
|
||||||
const [mobileView, setMobileView] = useState<"grid" | "carousel">("grid")
|
const [mobileView, setMobileView] = useState<"grid" | "carousel">("grid")
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
// carousel indicator state (mobile)
|
||||||
|
const [carouselApi, setCarouselApi] = useState<CarouselApi | null>(null)
|
||||||
|
const [carouselCount, setCarouselCount] = useState(0)
|
||||||
|
const [carouselCurrent, setCarouselCurrent] = useState(0)
|
||||||
|
|
||||||
// Fetch artist data from API
|
// Fetch artist data from API
|
||||||
const { data: artist, isLoading, error } = useArtist(artistId)
|
const { data: artist, isLoading, error } = useArtist(artistId)
|
||||||
@ -29,6 +33,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
// keep a reference to the last focused thumbnail so we can return focus on modal close
|
// keep a reference to the last focused thumbnail so we can return focus on modal close
|
||||||
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
||||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
const touchStartX = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Enable parallax only on desktop to avoid jank on mobile
|
// Enable parallax only on desktop to avoid jank on mobile
|
||||||
@ -38,6 +43,38 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
return () => window.removeEventListener("scroll", handleScroll)
|
return () => window.removeEventListener("scroll", handleScroll)
|
||||||
}, [isMobile])
|
}, [isMobile])
|
||||||
|
|
||||||
|
// Preserve scroll position when modal opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedImage) return
|
||||||
|
const y = window.scrollY
|
||||||
|
const { body } = document
|
||||||
|
body.style.position = "fixed"
|
||||||
|
body.style.top = `-${y}px`
|
||||||
|
body.style.left = "0"
|
||||||
|
body.style.right = "0"
|
||||||
|
return () => {
|
||||||
|
const top = body.style.top
|
||||||
|
body.style.position = ""
|
||||||
|
body.style.top = ""
|
||||||
|
body.style.left = ""
|
||||||
|
body.style.right = ""
|
||||||
|
const restoreY = Math.abs(parseInt(top || "0", 10))
|
||||||
|
window.scrollTo(0, restoreY)
|
||||||
|
}
|
||||||
|
}, [selectedImage])
|
||||||
|
|
||||||
|
// Carousel indicators state wiring
|
||||||
|
useEffect(() => {
|
||||||
|
if (!carouselApi) return
|
||||||
|
setCarouselCount(carouselApi.scrollSnapList().length)
|
||||||
|
setCarouselCurrent(carouselApi.selectedScrollSnap())
|
||||||
|
const onSelect = () => setCarouselCurrent(carouselApi.selectedScrollSnap())
|
||||||
|
carouselApi.on("select", onSelect)
|
||||||
|
return () => {
|
||||||
|
carouselApi.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [carouselApi])
|
||||||
|
|
||||||
// Derived lists (safe when `artist` is undefined during initial renders)
|
// Derived lists (safe when `artist` is undefined during initial renders)
|
||||||
const portfolioImages = artist?.portfolioImages || []
|
const portfolioImages = artist?.portfolioImages || []
|
||||||
|
|
||||||
@ -401,8 +438,8 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
{/* Category Filter - horizontal pills */}
|
{/* Category Filter - horizontal pills */}
|
||||||
{categories.length > 1 && (
|
{categories.length > 1 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<ScrollArea className="w-full whitespace-nowrap">
|
<ScrollArea className="w-full whitespace-nowrap" aria-label="Filter by style">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" role="list">
|
||||||
{categories.map((category) => {
|
{categories.map((category) => {
|
||||||
const count = category === "All"
|
const count = category === "All"
|
||||||
? portfolioImages.length
|
? portfolioImages.length
|
||||||
@ -412,8 +449,8 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
<button
|
<button
|
||||||
key={category}
|
key={category}
|
||||||
onClick={() => setSelectedCategory(category)}
|
onClick={() => setSelectedCategory(category)}
|
||||||
className={`rounded-full px-3 py-1 text-xs border transition-colors ${
|
className={`rounded-full px-3 py-1 text-xs border transition-all duration-200 ${
|
||||||
isActive ? "bg-white text-black border-white" : "text-white/80 border-white/20"
|
isActive ? "bg-white text-black border-white scale-95" : "text-white/80 border-white/20 hover:border-white/40"
|
||||||
}`}
|
}`}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
>
|
>
|
||||||
@ -485,8 +522,8 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
<p className="text-gray-400">No portfolio images available</p>
|
<p className="text-gray-400">No portfolio images available</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<div className="relative" aria-label="Portfolio carousel">
|
||||||
<Carousel opts={{ align: "start", loop: true }} className="w-full">
|
<Carousel opts={{ align: "start", loop: true }} className="w-full" setApi={setCarouselApi}>
|
||||||
<CarouselContent>
|
<CarouselContent>
|
||||||
{filteredPortfolio.map((item) => (
|
{filteredPortfolio.map((item) => (
|
||||||
<CarouselItem key={item.id} className="basis-full">
|
<CarouselItem key={item.id} className="basis-full">
|
||||||
@ -506,6 +543,18 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
<div className="pointer-events-none absolute top-2 right-3 rounded-full bg-white/10 backdrop-blur px-2 py-1 text-xs text-white">
|
<div className="pointer-events-none absolute top-2 right-3 rounded-full bg-white/10 backdrop-blur px-2 py-1 text-xs text-white">
|
||||||
{filteredPortfolio.length} pieces
|
{filteredPortfolio.length} pieces
|
||||||
</div>
|
</div>
|
||||||
|
{/* Dots indicators */}
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-2" role="tablist" aria-label="Carousel indicators">
|
||||||
|
{Array.from({ length: carouselCount }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => carouselApi?.scrollTo(i)}
|
||||||
|
aria-current={carouselCurrent === i}
|
||||||
|
aria-label={`Go to slide ${i + 1}`}
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${carouselCurrent === i ? "bg-white" : "bg-white/40"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@ -572,6 +621,24 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
<div
|
<div
|
||||||
className="relative max-w-6xl max-h-[90vh] w-full flex items-center justify-center"
|
className="relative max-w-6xl max-h-[90vh] w-full flex items-center justify-center"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX
|
||||||
|
}}
|
||||||
|
onTouchEnd={(e) => {
|
||||||
|
if (touchStartX.current == null) return
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStartX.current
|
||||||
|
const threshold = 40
|
||||||
|
if (Math.abs(dx) > threshold) {
|
||||||
|
if (dx < 0) {
|
||||||
|
const next = (currentIndex + 1) % filteredPortfolio.length
|
||||||
|
goToIndex(next)
|
||||||
|
} else {
|
||||||
|
const prev = (currentIndex - 1 + filteredPortfolio.length) % filteredPortfolio.length
|
||||||
|
goToIndex(prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
touchStartX.current = null
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Prev */}
|
{/* Prev */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user