Initial commit: Maria Malejka memorial website
Next.js 14 + node:sqlite memorial site with: - Hero section, photo slideshow & gallery - Memory/thoughts editor (admin) - Music player with upload - Video gallery - Docker Compose deployment - Responsive warm earth tone design
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type { MediaItem } from '@/lib/types'
|
||||
|
||||
export default function PhotoSlideshow({ photos }: { photos: MediaItem[] }) {
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
const next = useCallback(
|
||||
() => setCurrent((c) => (c + 1) % photos.length),
|
||||
[photos.length]
|
||||
)
|
||||
const prev = useCallback(
|
||||
() => setCurrent((c) => (c - 1 + photos.length) % photos.length),
|
||||
[photos.length]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || photos.length <= 1) return
|
||||
const id = setInterval(next, 5500)
|
||||
return () => clearInterval(id)
|
||||
}, [paused, photos.length, next])
|
||||
|
||||
if (photos.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-2xl shadow-2xl mb-10"
|
||||
style={{ aspectRatio: '16/7' }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={photos[current].id}
|
||||
className="absolute inset-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1.4, ease: 'easeInOut' }}
|
||||
>
|
||||
<img
|
||||
src={`/api/files/${photos[current].filename}`}
|
||||
alt={photos[current].caption || 'Maria Malejka'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
|
||||
{photos[current].caption && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="absolute bottom-0 inset-x-0 p-6 text-center"
|
||||
>
|
||||
<p className="text-white font-cormorant italic text-lg drop-shadow">
|
||||
{photos[current].caption}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{photos.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prev}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/30 hover:bg-black/50 text-white rounded-full p-2 transition-colors z-10 backdrop-blur-sm"
|
||||
aria-label="Vorheriges Bild"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={next}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/30 hover:bg-black/50 text-white rounded-full p-2 transition-colors z-10 backdrop-blur-sm"
|
||||
aria-label="Nächstes Bild"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dots */}
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-10">
|
||||
{photos.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrent(i)}
|
||||
className={`rounded-full transition-all duration-500 ${
|
||||
i === current
|
||||
? 'bg-amber-300 w-6 h-2'
|
||||
: 'bg-white/50 w-2 h-2 hover:bg-white/80'
|
||||
}`}
|
||||
aria-label={`Bild ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user