fix(gallery): replace CSS scroll with 3D coverflow slider everywhere; fix migration SEO columns
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

- 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:
Vadym Samoilenko 2026-05-28 14:17:12 +01:00
parent ef629dbdbe
commit 4cc6586366
2 changed files with 127 additions and 139 deletions

View file

@ -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");

View file

@ -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))}
/>
)}
</>