fix(gallery): replace CSS scroll with 3D coverflow slider everywhere; fix migration SEO columns
- GallerySlider: rewrite to 3D coverflow (rotateY perspective, 3.5s auto-rotate, dots + arrows, lightbox on center click) — matches DyvoLisGallery style - migration 0006: add meta_title/description/image_id columns for seoPlugin Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ef629dbdbe
commit
4cc6586366
2 changed files with 127 additions and 139 deletions
|
|
@ -108,3 +108,9 @@ VALUES (
|
|||
true, true, false, 20
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
-- SEO plugin meta columns (added by seoPlugin for globals)
|
||||
ALTER TABLE "dinosaur_page" ADD COLUMN IF NOT EXISTS "meta_title" varchar;
|
||||
ALTER TABLE "dinosaur_page" ADD COLUMN IF NOT EXISTS "meta_description" varchar;
|
||||
ALTER TABLE "dinosaur_page" ADD COLUMN IF NOT EXISTS "meta_image_id" integer REFERENCES "media"("id") ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS "dinosaur_page_meta_meta_image_idx" ON "dinosaur_page" ("meta_image_id");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ImageLightbox } from '@/components/ui/ImageLightbox'
|
||||
|
||||
export interface GalleryImage {
|
||||
|
|
@ -14,165 +14,147 @@ export interface GalleryImage {
|
|||
|
||||
interface GallerySliderProps {
|
||||
images: GalleryImage[]
|
||||
/** Pixels per second. Default 50. */
|
||||
/** @deprecated kept for API compatibility */
|
||||
speed?: number
|
||||
}
|
||||
|
||||
export function GallerySlider({ images, speed = 50 }: GallerySliderProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pausedRef = useRef(false)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const pauseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
export function GallerySlider({ images }: GallerySliderProps) {
|
||||
const n = images.length
|
||||
const [active, setActive] = useState(0)
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||
|
||||
const doubled = [...images, ...images]
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
)
|
||||
return
|
||||
if (n <= 1) return
|
||||
const t = setInterval(() => setActive((p) => (p + 1) % n), 3500)
|
||||
return () => clearInterval(t)
|
||||
}, [n])
|
||||
|
||||
let last = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const dt = now - last
|
||||
last = now
|
||||
if (!pausedRef.current) {
|
||||
const half = el.scrollWidth / 2
|
||||
if (half > 0) {
|
||||
el.scrollLeft += (dt / 1000) * speed
|
||||
if (el.scrollLeft >= half) el.scrollLeft -= half
|
||||
}
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
if (n === 0) return null
|
||||
|
||||
function getOffset(i: number): number {
|
||||
const d = (((i - active) % n) + n) % n
|
||||
return d > n / 2 ? d - n : d
|
||||
}
|
||||
|
||||
function handleSlideClick(i: number, d: number) {
|
||||
if (d === 0) {
|
||||
setLightboxIndex(i)
|
||||
} else {
|
||||
setActive(i)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [speed])
|
||||
|
||||
function scrollByCard(dir: 1 | -1) {
|
||||
const cardWidth = 380 + 26
|
||||
scrollRef.current?.scrollBy({ left: dir * cardWidth, behavior: 'smooth' })
|
||||
pausedRef.current = true
|
||||
if (pauseTimer.current) clearTimeout(pauseTimer.current)
|
||||
pauseTimer.current = setTimeout(() => {
|
||||
pausedRef.current = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function openLightbox(idx: number) {
|
||||
setLightboxIndex(idx % images.length)
|
||||
pausedRef.current = true
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
setLightboxIndex(null)
|
||||
pausedRef.current = false
|
||||
}
|
||||
|
||||
function prevImage() {
|
||||
setLightboxIndex((i) => (i === null ? 0 : (i - 1 + images.length) % images.length))
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
setLightboxIndex((i) => (i === null ? 0 : (i + 1) % images.length))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => scrollByCard(-1)}
|
||||
className="absolute top-1/2 left-0 z-10 hidden h-12 w-12 -translate-x-6 -translate-y-1/2 items-center justify-center rounded-full bg-[#396817] text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-[#f28b4a] md:flex"
|
||||
aria-label="Попередні фото"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M13 4L7 10L13 16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* 3D Coverflow */}
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative mx-auto h-[260px] lg:h-[420px]" style={{ perspective: '1100px' }}>
|
||||
{images.map((img, i) => {
|
||||
const d = getOffset(i)
|
||||
const absD = Math.abs(d)
|
||||
const sign = d >= 0 ? 1 : -1
|
||||
|
||||
<div
|
||||
className="relative w-full overflow-hidden"
|
||||
style={{
|
||||
maskImage:
|
||||
'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
pausedRef.current = true
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
pausedRef.current = false
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-[26px] overflow-x-scroll pb-2"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{doubled.map((img, i) => {
|
||||
const w = img.width ?? 384
|
||||
const h = img.height ?? 280
|
||||
const r = img.radius ?? 20
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex-none shrink-0 cursor-pointer overflow-hidden"
|
||||
style={{ width: `${w}px`, height: `${h}px`, borderRadius: `${r}px` }}
|
||||
onClick={() => openLightbox(i)}
|
||||
role="button"
|
||||
aria-label={`Відкрити фото: ${img.alt}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && openLightbox(i)}
|
||||
>
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
const cfg =
|
||||
absD === 0
|
||||
? { tx: 0, ry: 0, scale: 1, opacity: 1, z: 20 }
|
||||
: absD === 1
|
||||
? { tx: sign * 270, ry: -sign * 48, scale: 0.82, opacity: 0.62, z: 10 }
|
||||
: { tx: sign * 440, ry: -sign * 62, scale: 0.6, opacity: 0.18, z: 1 }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={d === 0 ? `Відкрити фото: ${img.alt}` : `Показати фото: ${img.alt}`}
|
||||
aria-current={d === 0 ? true : undefined}
|
||||
onClick={() => handleSlideClick(i, d)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSlideClick(i, d)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: 'clamp(200px, 30vw, 380px)',
|
||||
aspectRatio: '4/3',
|
||||
transform: `translate(calc(-50% + ${cfg.tx}px), -50%) rotateY(${cfg.ry}deg) scale(${cfg.scale})`,
|
||||
opacity: cfg.opacity,
|
||||
zIndex: cfg.z,
|
||||
transition: 'transform 0.65s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.65s ease',
|
||||
borderRadius: `${img.radius ?? 16}px`,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
boxShadow:
|
||||
d === 0 ? '0 20px 60px rgba(57,104,23,0.28)' : '0 8px 24px rgba(0,0,0,0.14)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={img.src}
|
||||
alt={d === 0 ? img.alt : ''}
|
||||
aria-hidden={d !== 0 ? true : undefined}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => scrollByCard(1)}
|
||||
className="absolute top-1/2 right-0 z-10 hidden h-12 w-12 translate-x-6 -translate-y-1/2 items-center justify-center rounded-full bg-[#396817] text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-[#f28b4a] md:flex"
|
||||
aria-label="Наступні фото"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 4L13 10L7 16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Navigation */}
|
||||
<div className="mt-10 flex items-center justify-center gap-6">
|
||||
<button
|
||||
onClick={() => setActive((p) => (p - 1 + n) % n)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
|
||||
aria-label="Попереднє фото"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M11 4L6 9L11 14"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{images.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActive(i)}
|
||||
className="h-2.5 w-2.5 rounded-full transition-all duration-300"
|
||||
style={{ background: i === active ? '#396817' : '#b8d8a0' }}
|
||||
aria-label={`Фото ${i + 1}`}
|
||||
aria-current={i === active ? true : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setActive((p) => (p + 1) % n)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
|
||||
aria-label="Наступне фото"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 4L12 9L7 14"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={images}
|
||||
index={lightboxIndex}
|
||||
onClose={closeLightbox}
|
||||
onPrev={prevImage}
|
||||
onNext={nextImage}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onPrev={() => setLightboxIndex((i) => (i === null ? 0 : (i - 1 + n) % n))}
|
||||
onNext={() => setLightboxIndex((i) => (i === null ? 0 : (i + 1) % n))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue