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,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user