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:
denshooter
2026-02-16 01:26:37 +01:00
commit bdcfa8f3c5
29 changed files with 3779 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Play,
Pause,
SkipForward,
SkipBack,
Volume2,
VolumeX,
Music,
X,
} from 'lucide-react'
import type { MediaItem } from '@/lib/types'
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
const [current, setCurrent] = useState(0)
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(0.35)
const [muted, setMuted] = useState(false)
const [visible, setVisible] = useState(false)
const [progress, setProgress] = useState(0)
const audioRef = useRef<HTMLAudioElement>(null)
const track = tracks[current]
useEffect(() => {
const audio = audioRef.current
if (!audio) return
audio.volume = muted ? 0 : volume
}, [volume, muted])
useEffect(() => {
const audio = audioRef.current
if (!audio) return
audio.src = `/api/files/${track.filename}`
audio.volume = muted ? 0 : volume
if (playing) {
audio.play().catch(() => setPlaying(false))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current])
const togglePlay = () => {
const audio = audioRef.current
if (!audio) return
if (playing) {
audio.pause()
setPlaying(false)
} else {
audio.play().catch(() => setPlaying(false))
setPlaying(true)
}
}
const prev = () => setCurrent((c) => (c - 1 + tracks.length) % tracks.length)
const next = () => setCurrent((c) => (c + 1) % tracks.length)
const handleTimeUpdate = () => {
const audio = audioRef.current
if (!audio || !audio.duration) return
setProgress((audio.currentTime / audio.duration) * 100)
}
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current
if (!audio || !audio.duration) return
const pct = parseFloat(e.target.value)
audio.currentTime = (pct / 100) * audio.duration
setProgress(pct)
}
const trackName =
track.original_name?.replace(/\.[^/.]+$/, '') ||
track.caption ||
`Titel ${current + 1}`
if (tracks.length === 0) return null
return (
<>
<audio
ref={audioRef}
src={`/api/files/${track.filename}`}
onEnded={next}
onTimeUpdate={handleTimeUpdate}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
/>
{/* Floating button */}
<motion.button
onClick={() => setVisible((v) => !v)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={`fixed bottom-6 right-6 z-40 rounded-full p-3.5 shadow-lg transition-colors backdrop-blur-sm ${
playing
? 'bg-amber-700/95 text-amber-100 ring-2 ring-amber-400/40'
: 'bg-stone-800/90 text-amber-200/80 hover:bg-amber-900/90'
}`}
aria-label="Musik"
>
<Music size={20} />
{playing && (
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
)}
</motion.button>
{/* Player panel */}
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ duration: 0.2 }}
className="fixed bottom-20 right-6 z-40 bg-stone-950/95 backdrop-blur-md rounded-2xl p-5 shadow-2xl w-72 border border-amber-900/20"
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1 min-w-0">
<p className="text-amber-200 font-cormorant italic text-base leading-snug truncate">
{trackName}
</p>
<p className="text-amber-600 text-xs mt-0.5">
{current + 1} / {tracks.length}
</p>
</div>
<button
onClick={() => setVisible(false)}
className="text-amber-800 hover:text-amber-500 ml-2 flex-shrink-0"
>
<X size={16} />
</button>
</div>
{/* Progress bar */}
<input
type="range"
min="0"
max="100"
step="0.1"
value={progress}
onChange={handleSeek}
className="w-full mb-4 accent-amber-500 h-1 cursor-pointer"
/>
{/* Controls */}
<div className="flex items-center justify-center gap-5 mb-4">
<button
onClick={prev}
className="text-amber-500 hover:text-amber-300 transition-colors"
>
<SkipBack size={18} />
</button>
<button
onClick={togglePlay}
className="bg-amber-700 hover:bg-amber-600 text-white rounded-full w-10 h-10 flex items-center justify-center transition-colors shadow-lg"
>
{playing ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
</button>
<button
onClick={next}
className="text-amber-500 hover:text-amber-300 transition-colors"
>
<SkipForward size={18} />
</button>
</div>
{/* Volume */}
<div className="flex items-center gap-2">
<button
onClick={() => setMuted((m) => !m)}
className="text-amber-600 hover:text-amber-400 transition-colors"
>
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={muted ? 0 : volume}
onChange={(e) => {
setVolume(parseFloat(e.target.value))
setMuted(false)
}}
className="flex-1 accent-amber-600 h-1 cursor-pointer"
/>
</div>
</motion.div>
)}
</AnimatePresence>
</>
)
}