156 lines
5.4 KiB
TypeScript
156 lines
5.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
export interface GalerieBild { id: string; src: string; alt: string }
|
|
|
|
interface Slot { bild: GalerieBild; phase: 'idle' | 'out' | 'in' }
|
|
|
|
const VISIBLE = 12
|
|
const INTERVAL_MS = 1500
|
|
const FLIP_MS = 360
|
|
const GAP = 8
|
|
const ROW_H = 165
|
|
|
|
function shuffle<T>(arr: T[]): T[] {
|
|
const a = [...arr]
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[a[i], a[j]] = [a[j], a[i]]
|
|
}
|
|
return a
|
|
}
|
|
|
|
export function GalerieAnzeige({ bilder }: { bilder: GalerieBild[] }) {
|
|
const n = Math.min(bilder.length, VISIBLE)
|
|
|
|
// Kein shuffle im initializer → Server und Client rendern identisch → kein Hydration-Mismatch
|
|
const [slots, setSlots] = useState<Slot[]>(() =>
|
|
bilder.slice(0, n).map(b => ({ bild: b, phase: 'idle' as const }))
|
|
)
|
|
|
|
const slotsRef = useRef<Slot[]>(slots)
|
|
useEffect(() => { slotsRef.current = slots }, [slots])
|
|
|
|
const flipping = useRef(new Set<number>())
|
|
const poolRef = useRef<GalerieBild[]>([])
|
|
const poolIdxRef = useRef(0)
|
|
|
|
useEffect(() => {
|
|
// Läuft nur auf dem Client, nach der Hydration
|
|
const shuffled = shuffle([...bilder])
|
|
poolRef.current = shuffled
|
|
poolIdxRef.current = n
|
|
|
|
setSlots(shuffled.slice(0, n).map(b => ({ bild: b, phase: 'idle' as const })))
|
|
|
|
if (bilder.length < 2) return
|
|
|
|
const id = setInterval(() => {
|
|
const cur = slotsRef.current
|
|
const free = cur.map((_, i) => i).filter(i => !flipping.current.has(i))
|
|
if (free.length === 0) return
|
|
|
|
const si = free[Math.floor(Math.random() * free.length)]
|
|
|
|
if (bilder.length > VISIBLE) {
|
|
const next = poolRef.current[poolIdxRef.current % poolRef.current.length]
|
|
poolIdxRef.current++
|
|
flip(si, next)
|
|
} else {
|
|
const others = free.filter(i => i !== si)
|
|
if (others.length === 0) return
|
|
const oi = others[Math.floor(Math.random() * others.length)]
|
|
flip(si, cur[oi].bild)
|
|
flip(oi, cur[si].bild)
|
|
}
|
|
}, INTERVAL_MS)
|
|
|
|
return () => clearInterval(id)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []) // bewusst leer: läuft genau einmal nach Mount
|
|
|
|
function flip(i: number, newBild: GalerieBild) {
|
|
flipping.current.add(i)
|
|
setSlots(p => p.map((s, j) => j === i ? { ...s, phase: 'out' } : s))
|
|
setTimeout(() => {
|
|
setSlots(p => p.map((s, j) => j === i ? { bild: newBild, phase: 'in' } : s))
|
|
setTimeout(() => {
|
|
setSlots(p => p.map((s, j) => j === i ? { ...s, phase: 'idle' } : s))
|
|
flipping.current.delete(i)
|
|
}, FLIP_MS)
|
|
}, FLIP_MS)
|
|
}
|
|
|
|
const imgStyle = (phase: string): React.CSSProperties => ({
|
|
position: 'absolute', inset: 0, width: '100%', height: '100%',
|
|
objectFit: 'cover', objectPosition: 'center',
|
|
animation:
|
|
phase === 'out' ? `galFlipOut ${FLIP_MS}ms ease-in forwards` :
|
|
phase === 'in' ? `galFlipIn ${FLIP_MS}ms ease-out forwards` :
|
|
'none',
|
|
})
|
|
|
|
const numGroups = Math.ceil(slots.length / 4)
|
|
|
|
return (
|
|
<>
|
|
<style>{`
|
|
@keyframes galFlipOut {
|
|
from { transform: perspective(700px) rotateY(0deg); }
|
|
to { transform: perspective(700px) rotateY(90deg); }
|
|
}
|
|
@keyframes galFlipIn {
|
|
from { transform: perspective(700px) rotateY(-90deg); }
|
|
to { transform: perspective(700px) rotateY(0deg); }
|
|
}
|
|
.galerie-cell:hover .galerie-hl { opacity: 1 !important; }
|
|
`}</style>
|
|
|
|
{/* Mobile */}
|
|
<div className="grid grid-cols-2 gap-2 md:hidden">
|
|
{slots.map((slot, si) => (
|
|
<div key={si} className="galerie-cell relative aspect-[4/3] overflow-hidden rounded-lg">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={slot.bild.src} alt={slot.bild.alt} style={imgStyle(slot.phase)} />
|
|
<div className="galerie-hl absolute inset-0 transition-opacity duration-300"
|
|
style={{ background: 'rgba(76,175,80,0.15)', opacity: 0 }} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Desktop */}
|
|
<div className="hidden md:flex flex-col" style={{ gap: GAP }}>
|
|
{Array.from({ length: numGroups }, (_, gi) => {
|
|
const bigLeft = gi % 2 === 0
|
|
return (
|
|
<div key={gi} style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: bigLeft ? '2fr 1fr' : '1fr 2fr',
|
|
gridTemplateRows: `repeat(3, ${ROW_H}px)`,
|
|
gap: GAP,
|
|
}}>
|
|
{[0, 1, 2, 3].map(idx => {
|
|
const si = gi * 4 + idx
|
|
const slot = slots[si]
|
|
if (!slot) return null
|
|
const cellStyle: React.CSSProperties = bigLeft
|
|
? { gridColumn: idx === 0 ? '1' : '2', gridRow: idx === 0 ? '1/4' : `${idx}` }
|
|
: { gridColumn: idx === 3 ? '2' : '1', gridRow: idx === 3 ? '1/4' : `${idx + 1}` }
|
|
return (
|
|
<div key={si} className="galerie-cell relative overflow-hidden rounded-lg" style={cellStyle}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={slot.bild.src} alt={slot.bild.alt} style={imgStyle(slot.phase)} />
|
|
<div className="galerie-hl absolute inset-0 transition-opacity duration-300"
|
|
style={{ background: 'rgba(76,175,80,0.15)', opacity: 0 }} />
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|