148 lines
6.1 KiB
TypeScript
148 lines
6.1 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
|
import { Reveal } from '../Reveal'
|
|
import { SectionHeading } from '../SectionHeading'
|
|
import { useSite } from '../../api'
|
|
import { useLang, formatNumber } from '../../i18n/lang'
|
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
|
|
|
export function Gallery() {
|
|
const { gallery } = useSite()
|
|
const { t, lang } = useLang()
|
|
const [active, setActive] = useState<number | null>(null)
|
|
|
|
const close = useCallback(() => setActive(null), [])
|
|
const step = useCallback((dir: number) => {
|
|
setActive((prev) => (prev === null ? prev : (prev + dir + gallery.length) % gallery.length))
|
|
}, [gallery.length])
|
|
|
|
useEffect(() => {
|
|
if (active === null) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') close()
|
|
if (e.key === 'ArrowRight') step(-1)
|
|
if (e.key === 'ArrowLeft') step(1)
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [active, close, step])
|
|
|
|
return (
|
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
|
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
|
<SectionHeading
|
|
title={t('fallbackGalleryTitle')}
|
|
lead={t('fallbackGalleryLead')}
|
|
headingKey="gallery"
|
|
align="center"
|
|
/>
|
|
|
|
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
|
{gallery.map((image, i) => (
|
|
<Reveal key={image.id} delay={0.04 * i}>
|
|
<EditableItem resource="gallery" item={image}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActive(i)}
|
|
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
|
aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
|
|
>
|
|
<img
|
|
src={image.src}
|
|
alt={image.alt}
|
|
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
|
/>
|
|
</button>
|
|
</EditableItem>
|
|
</Reveal>
|
|
))}
|
|
<AddItemButton resource="gallery" />
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{active !== null && gallery[active] && (
|
|
<motion.div
|
|
className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('viewImage')}
|
|
onClick={close}
|
|
>
|
|
{/* Close Button */}
|
|
<button
|
|
type="button"
|
|
onClick={close}
|
|
className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
|
aria-label={t('close')}
|
|
>
|
|
<XIcon className="h-5 w-5" />
|
|
</button>
|
|
|
|
{/* Flex Container for Prev, Image, Next */}
|
|
<div
|
|
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => step(-1)}
|
|
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
|
aria-label={t('prevImage')}
|
|
>
|
|
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
|
</button>
|
|
|
|
<motion.img
|
|
key={active}
|
|
src={gallery[active].src}
|
|
alt={gallery[active].alt}
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
|
className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => step(1)}
|
|
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
|
aria-label={t('nextImage')}
|
|
>
|
|
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modern Dot Pagination */}
|
|
<div
|
|
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{gallery.map((_, idx) => {
|
|
const isActive = idx === active;
|
|
return (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={() => setActive(idx)}
|
|
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
|
|
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
|
|
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
|
|
}`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</section>
|
|
)
|
|
}
|