feat: music player redesign, candle section, impressum
MusicPlayer: - Beautiful inline section with numbered track list + click-to-play - Animated waveform bars on playing track - Time display (elapsed / duration) on progress bar - Floating mini-player with track name + time, shows after first play - Play/pause in mini-player, close button CandleSection: - 7 hand-coded CSS/Framer Motion candle flames with organic flicker - Layered flame (outer glow + main + inner core) + wax drip highlight - "Ruhe in Frieden" text with subtle glow Impressum: - New /impressum page with TMG §5 structure (placeholder address) - Privacy notice (no cookies/tracking, voluntary memory data) - Copyright, liability disclaimer - Consistent cream design with Cormorant/Lora typography page.tsx: - CandleSection added between VideoGallery and MusicPlayer - Musik nav link (conditional on tracks) - Footer Impressum link - MemorySection wrapped in id="erinnerungen" section for nav anchor Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Impressum – In Erinnerung an Maria Malejka',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImpressumPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-cream py-16 px-4">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-warm-brown-light hover:text-warm-gold text-sm font-lora tracking-wider transition-colors duration-200 mb-12"
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">←</span>
|
||||||
|
Zurück zur Gedenkseite
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="font-cormorant italic text-5xl sm:text-6xl text-warm-brown mb-4">
|
||||||
|
Impressum
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-px w-16 bg-warm-gold/30" />
|
||||||
|
<span className="text-warm-gold/40 text-lg">✦</span>
|
||||||
|
<div className="h-px w-16 bg-warm-gold/30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="space-y-10 font-lora text-warm-brown/75 text-sm leading-loose">
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant text-2xl text-warm-brown mb-3">
|
||||||
|
Angaben gemäß § 5 TMG
|
||||||
|
</h2>
|
||||||
|
<address className="not-italic space-y-0.5">
|
||||||
|
<p className="font-medium text-warm-brown/90">Dennis Malejka</p>
|
||||||
|
<p className="text-warm-brown-light text-xs italic">
|
||||||
|
(Kontaktdaten auf Anfrage)
|
||||||
|
</p>
|
||||||
|
</address>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant text-2xl text-warm-brown mb-3">
|
||||||
|
Kontakt
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Bei Fragen oder Anliegen bezüglich dieser Gedenkseite wenden Sie sich bitte
|
||||||
|
per E-Mail an den Betreiber.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant text-2xl text-warm-brown mb-3">
|
||||||
|
Zweck dieser Seite
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Diese Website ist eine private, nicht-kommerzielle Gedenkseite zum ehrenvollen
|
||||||
|
Andenken an{' '}
|
||||||
|
<span className="text-warm-brown font-medium">Maria Malejka</span>{' '}
|
||||||
|
(29. November 1944 – 10. Februar 2026). Sie dient ausschließlich dem
|
||||||
|
persönlichen Gedenken und dem Austausch von Erinnerungen im Familienund
|
||||||
|
Freundeskreis.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant text-2xl text-warm-brown mb-3">
|
||||||
|
Datenschutz
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Diese Seite verwendet keine Cookies, kein externes Tracking und keine
|
||||||
|
Analyse-Dienste. Es werden ausschließlich folgende Daten gespeichert:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 space-y-1.5 list-none pl-0">
|
||||||
|
{[
|
||||||
|
'Hochgeladene Bilder, Videos und Musikdateien (durch den Administrator)',
|
||||||
|
'Freiwillig hinterlassene Erinnerungen mit optionalem Namen (öffentlich sichtbar)',
|
||||||
|
].map((item) => (
|
||||||
|
<li key={item} className="flex items-start gap-3">
|
||||||
|
<span className="text-warm-gold/50 mt-0.5 flex-shrink-0">✦</span>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-4">
|
||||||
|
Möchten Sie eine von Ihnen hinterlassene Erinnerung löschen lassen,
|
||||||
|
wenden Sie sich bitte an den Betreiber.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant text-2xl text-warm-brown mb-3">
|
||||||
|
Urheberrecht
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Alle auf dieser Seite veröffentlichten Fotos und Medien sind privates
|
||||||
|
Eigentum der Familie Malejka. Eine Weitergabe oder Veröffentlichung ohne
|
||||||
|
ausdrückliche Genehmigung ist nicht gestattet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant text-2xl text-warm-brown mb-3">
|
||||||
|
Haftungsausschluss
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Die Inhalte dieser Seite wurden mit größter Sorgfalt erstellt. Für die
|
||||||
|
Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann keine Gewähr
|
||||||
|
übernommen werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer ornament */}
|
||||||
|
<div className="mt-16 pt-8 border-t border-warm-border/50 text-center">
|
||||||
|
<p className="font-cormorant italic text-warm-gold/40 text-xl">
|
||||||
|
In liebevoller Erinnerung
|
||||||
|
</p>
|
||||||
|
<p className="font-lora text-warm-brown-light/40 text-xs mt-2 tracking-widest">
|
||||||
|
Maria Malejka · 1944 – 2026
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
+41
-19
@@ -5,6 +5,7 @@ import PhotoSlideshow from '@/components/PhotoSlideshow'
|
|||||||
import PhotoGallery from '@/components/PhotoGallery'
|
import PhotoGallery from '@/components/PhotoGallery'
|
||||||
import MemorySection from '@/components/MemorySection'
|
import MemorySection from '@/components/MemorySection'
|
||||||
import WriteSection from '@/components/WriteSection'
|
import WriteSection from '@/components/WriteSection'
|
||||||
|
import CandleSection from '@/components/CandleSection'
|
||||||
import MusicPlayer from '@/components/MusicPlayer'
|
import MusicPlayer from '@/components/MusicPlayer'
|
||||||
import VideoGallery from '@/components/VideoGallery'
|
import VideoGallery from '@/components/VideoGallery'
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export default async function HomePage() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<HeroSection heroPhoto={photos[0]?.filename ?? null} />
|
<HeroSection heroPhoto={photos[0]?.filename ?? null} />
|
||||||
|
|
||||||
{/* Navigation anchors */}
|
{/* Navigation */}
|
||||||
<nav className="sticky top-0 z-20 bg-cream/80 backdrop-blur-sm border-b border-warm-border">
|
<nav className="sticky top-0 z-20 bg-cream/80 backdrop-blur-sm border-b border-warm-border">
|
||||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-center gap-6 sm:gap-10">
|
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-center gap-6 sm:gap-10">
|
||||||
{photos.length > 0 && (
|
{photos.length > 0 && (
|
||||||
@@ -42,14 +43,12 @@ export default async function HomePage() {
|
|||||||
Bilder
|
Bilder
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{memories.length > 0 && (
|
<a
|
||||||
<a
|
href="#erinnerungen"
|
||||||
href="#erinnerungen"
|
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
||||||
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
>
|
||||||
>
|
Erinnerungen
|
||||||
Erinnerungen
|
</a>
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{videos.length > 0 && (
|
{videos.length > 0 && (
|
||||||
<a
|
<a
|
||||||
href="#videos"
|
href="#videos"
|
||||||
@@ -58,6 +57,14 @@ export default async function HomePage() {
|
|||||||
Videos
|
Videos
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{music.length > 0 && (
|
||||||
|
<a
|
||||||
|
href="#musik"
|
||||||
|
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
||||||
|
>
|
||||||
|
Musik
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -86,11 +93,16 @@ export default async function HomePage() {
|
|||||||
<WriteSection />
|
<WriteSection />
|
||||||
|
|
||||||
{/* Memories */}
|
{/* Memories */}
|
||||||
<MemorySection memories={memories} />
|
<section id="erinnerungen">
|
||||||
|
<MemorySection memories={memories} />
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Videos */}
|
{/* Videos */}
|
||||||
<VideoGallery videos={videos} />
|
<VideoGallery videos={videos} />
|
||||||
|
|
||||||
|
{/* Candle section */}
|
||||||
|
<CandleSection />
|
||||||
|
|
||||||
{/* Music player */}
|
{/* Music player */}
|
||||||
<MusicPlayer tracks={music} />
|
<MusicPlayer tracks={music} />
|
||||||
|
|
||||||
@@ -117,16 +129,26 @@ export default async function HomePage() {
|
|||||||
<p className="font-cormorant italic text-warm-brown-light/60 text-lg">
|
<p className="font-cormorant italic text-warm-brown-light/60 text-lg">
|
||||||
„Du bist nicht fort, nur ein Schritt voraus."
|
„Du bist nicht fort, nur ein Schritt voraus."
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hidden admin link */}
|
{/* Footer links */}
|
||||||
<a
|
<div className="mt-10 flex items-center justify-center gap-6">
|
||||||
href="/admin"
|
<a
|
||||||
className="mt-10 inline-block text-warm-border/30 hover:text-warm-border/60 text-xs transition-colors"
|
href="/impressum"
|
||||||
title="Verwaltung"
|
className="text-warm-brown-light/40 hover:text-warm-brown-light/70 text-xs font-lora tracking-wider transition-colors duration-200"
|
||||||
>
|
>
|
||||||
·
|
Impressum
|
||||||
</a>
|
</a>
|
||||||
|
<span className="text-warm-border/40 text-xs">·</span>
|
||||||
|
{/* Hidden admin link */}
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="text-warm-border/30 hover:text-warm-border/60 text-xs transition-colors"
|
||||||
|
title="Verwaltung"
|
||||||
|
>
|
||||||
|
·
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
|
const candleData = [
|
||||||
|
{ delay: 0.0, bodyH: 88, bodyW: 9 },
|
||||||
|
{ delay: 0.4, bodyH: 112, bodyW: 11 },
|
||||||
|
{ delay: 0.2, bodyH: 76, bodyW: 8 },
|
||||||
|
{ delay: 0.6, bodyH: 100, bodyW: 10 },
|
||||||
|
{ delay: 0.1, bodyH: 92, bodyW: 9 },
|
||||||
|
{ delay: 0.5, bodyH: 120, bodyW: 12 },
|
||||||
|
{ delay: 0.3, bodyH: 82, bodyW: 9 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Candle({ bodyH, bodyW, delay }: { bodyH: number; bodyW: number; delay: number }) {
|
||||||
|
const flameW = bodyW * 1.8
|
||||||
|
const flameH = bodyW * 2.6
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center" style={{ gap: 0 }}>
|
||||||
|
{/* Flame */}
|
||||||
|
<motion.div
|
||||||
|
style={{ width: flameW, height: flameH, position: 'relative' }}
|
||||||
|
animate={{
|
||||||
|
scaleX: [1, 0.82, 1.08, 0.9, 1.04, 1],
|
||||||
|
scaleY: [1, 1.08, 0.94, 1.06, 0.97, 1],
|
||||||
|
x: [-0.5, 0.8, -0.8, 1.2, -0.3, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2.2 + delay * 0.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
delay,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Outer glow */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: flameW * 1.3,
|
||||||
|
height: flameH * 1.3,
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse at 50% 80%, rgba(255,180,40,0.18) 0%, transparent 70%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
filter: 'blur(6px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Main flame */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: flameW,
|
||||||
|
height: flameH,
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse at 50% 90%, rgba(255,200,60,0.95) 0%, rgba(255,110,10,0.80) 45%, rgba(180,50,0,0.40) 75%, transparent 100%)',
|
||||||
|
borderRadius: '50% 50% 35% 35% / 55% 55% 45% 45%',
|
||||||
|
filter: `blur(${bodyW * 0.09}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Inner core */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 2,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: flameW * 0.45,
|
||||||
|
height: flameH * 0.55,
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse at 50% 100%, rgba(255,255,220,0.95) 0%, rgba(255,230,80,0.7) 50%, transparent 100%)',
|
||||||
|
borderRadius: '50% 50% 40% 40%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Wick */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1.5,
|
||||||
|
height: 5,
|
||||||
|
backgroundColor: 'rgba(60,30,10,0.9)',
|
||||||
|
marginBottom: -1,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Candle body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: bodyW,
|
||||||
|
height: bodyH,
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right, rgba(240,230,210,0.10) 0%, rgba(255,248,235,0.07) 40%, rgba(220,200,170,0.04) 100%)',
|
||||||
|
borderRadius: '1px 1px 0 0',
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
borderBottom: 'none',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Wax drip highlight */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: bodyW * 0.2,
|
||||||
|
width: bodyW * 0.15,
|
||||||
|
height: bodyH * 0.4,
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '0 0 50% 50%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base plate */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: bodyW + 6,
|
||||||
|
height: 3,
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
borderRadius: '0 0 2px 2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CandleSection() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="py-24 overflow-hidden"
|
||||||
|
style={{ background: 'linear-gradient(to bottom, #060304 0%, #0d0807 50%, #060304 100%)' }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 1.2 }}
|
||||||
|
className="text-center"
|
||||||
|
>
|
||||||
|
{/* Candles */}
|
||||||
|
<div className="flex items-end justify-center gap-3 sm:gap-5 mb-14">
|
||||||
|
{candleData.map((c, i) => (
|
||||||
|
<Candle key={i} {...c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<p
|
||||||
|
className="font-cormorant italic text-amber-200/40 text-2xl sm:text-3xl tracking-widest"
|
||||||
|
style={{ textShadow: '0 0 40px rgba(196,160,74,0.12)' }}
|
||||||
|
>
|
||||||
|
Ruhe in Frieden
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-5">
|
||||||
|
<div className="h-px w-20 bg-amber-400/10" />
|
||||||
|
<span className="text-amber-400/15 text-xs">✦</span>
|
||||||
|
<div className="h-px w-20 bg-amber-400/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-lora text-amber-100/20 text-xs tracking-[0.4em] uppercase mt-4">
|
||||||
|
29. November 1944 — 10. Februar 2026
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
+236
-75
@@ -14,17 +14,65 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { MediaItem } from '@/lib/types'
|
import type { MediaItem } from '@/lib/types'
|
||||||
|
|
||||||
|
function formatTime(s: number) {
|
||||||
|
if (!s || isNaN(s) || !isFinite(s)) return '--:--'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const sec = Math.floor(s % 60)
|
||||||
|
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaveformBars({ playing }: { playing: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-px h-4">
|
||||||
|
{[0.55, 1, 0.7, 0.9, 0.5].map((h, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className="w-[3px] bg-amber-400/70 rounded-full"
|
||||||
|
animate={
|
||||||
|
playing
|
||||||
|
? { height: ['30%', `${h * 100}%`, '45%', `${h * 75}%`, '30%'] }
|
||||||
|
: { height: '30%' }
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: 0.75 + i * 0.13,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
delay: i * 0.11,
|
||||||
|
}}
|
||||||
|
style={{ height: '30%' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackNumber({ index, isCurrent, playing }: { index: number; isCurrent: boolean; playing: boolean }) {
|
||||||
|
if (isCurrent && playing) return <WaveformBars playing={playing} />
|
||||||
|
return (
|
||||||
|
<span className={`font-lora text-xs tabular-nums ${isCurrent ? 'text-amber-400/60' : 'text-amber-100/20'}`}>
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||||
const [current, setCurrent] = useState(0)
|
const [current, setCurrent] = useState(0)
|
||||||
const [playing, setPlaying] = useState(false)
|
const [playing, setPlaying] = useState(false)
|
||||||
const [volume, setVolume] = useState(0.35)
|
const [volume, setVolume] = useState(0.4)
|
||||||
const [muted, setMuted] = useState(false)
|
const [muted, setMuted] = useState(false)
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
const [elapsed, setElapsed] = useState(0)
|
||||||
|
const [miniVisible, setMiniVisible] = useState(false)
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
const track = tracks[current] ?? null
|
const track = tracks[current] ?? null
|
||||||
|
|
||||||
|
const trackName = (i: number) =>
|
||||||
|
tracks[i]?.original_name?.replace(/\.[^/.]+$/, '') ||
|
||||||
|
tracks[i]?.caption ||
|
||||||
|
`Titel ${i + 1}`
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
@@ -36,9 +84,10 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
if (!audio || !track) return
|
if (!audio || !track) return
|
||||||
audio.src = `/api/files/${track.filename}`
|
audio.src = `/api/files/${track.filename}`
|
||||||
audio.volume = muted ? 0 : volume
|
audio.volume = muted ? 0 : volume
|
||||||
if (playing) {
|
setDuration(0)
|
||||||
audio.play().catch(() => setPlaying(false))
|
setElapsed(0)
|
||||||
}
|
setProgress(0)
|
||||||
|
if (playing) audio.play().catch(() => setPlaying(false))
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [current])
|
}, [current])
|
||||||
|
|
||||||
@@ -47,11 +96,19 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
if (!audio) return
|
if (!audio) return
|
||||||
if (playing) {
|
if (playing) {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
setPlaying(false)
|
|
||||||
} else {
|
} else {
|
||||||
audio.play().catch(() => setPlaying(false))
|
audio.play().catch(() => setPlaying(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playTrack = (i: number) => {
|
||||||
|
if (i === current) {
|
||||||
|
togglePlay()
|
||||||
|
} else {
|
||||||
|
setCurrent(i)
|
||||||
setPlaying(true)
|
setPlaying(true)
|
||||||
}
|
}
|
||||||
|
setMiniVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = () => setCurrent((c) => (c - 1 + tracks.length) % tracks.length)
|
const prev = () => setCurrent((c) => (c - 1 + tracks.length) % tracks.length)
|
||||||
@@ -61,6 +118,12 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio || !audio.duration) return
|
if (!audio || !audio.duration) return
|
||||||
setProgress((audio.currentTime / audio.duration) * 100)
|
setProgress((audio.currentTime / audio.duration) * 100)
|
||||||
|
setElapsed(audio.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (audio) setDuration(audio.duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -73,11 +136,6 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
|
|
||||||
if (tracks.length === 0 || !track) return null
|
if (tracks.length === 0 || !track) return null
|
||||||
|
|
||||||
const trackName =
|
|
||||||
track.original_name?.replace(/\.[^/.]+$/, '') ||
|
|
||||||
track.caption ||
|
|
||||||
`Titel ${current + 1}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<audio
|
<audio
|
||||||
@@ -85,93 +143,137 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
src={`/api/files/${track.filename}`}
|
src={`/api/files/${track.filename}`}
|
||||||
onEnded={next}
|
onEnded={next}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
onPlay={() => setPlaying(true)}
|
onPlay={() => setPlaying(true)}
|
||||||
onPause={() => setPlaying(false)}
|
onPause={() => setPlaying(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Floating button */}
|
{/* ── Inline section ── */}
|
||||||
<motion.button
|
<section
|
||||||
onClick={() => setVisible((v) => !v)}
|
id="musik"
|
||||||
whileHover={{ scale: 1.05 }}
|
className="py-20 px-4"
|
||||||
whileTap={{ scale: 0.95 }}
|
style={{ background: 'linear-gradient(to bottom, #0a0706, #060304)' }}
|
||||||
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} />
|
<div className="max-w-xl mx-auto">
|
||||||
{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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.2 }}
|
className="text-center mb-12"
|
||||||
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">
|
<p className="text-amber-200/30 text-xs tracking-[0.5em] uppercase font-lora mb-3">
|
||||||
<div className="flex-1 min-w-0">
|
Begleitung in Tönen
|
||||||
<p className="text-amber-200 font-cormorant italic text-base leading-snug truncate">
|
</p>
|
||||||
{trackName}
|
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-amber-100/70 mb-3">
|
||||||
</p>
|
Musik
|
||||||
<p className="text-amber-600 text-xs mt-0.5">
|
</h2>
|
||||||
{current + 1} / {tracks.length}
|
<div className="flex items-center justify-center gap-4">
|
||||||
</p>
|
<div className="h-px w-12 bg-amber-400/15" />
|
||||||
</div>
|
<span className="text-amber-400/25">♪</span>
|
||||||
<button
|
<div className="h-px w-12 bg-amber-400/15" />
|
||||||
onClick={() => setVisible(false)}
|
</div>
|
||||||
className="text-amber-800 hover:text-amber-500 ml-2 flex-shrink-0"
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Track list */}
|
||||||
|
<div className="space-y-0.5 mb-8">
|
||||||
|
{tracks.map((t, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={t.id}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
onClick={() => playTrack(i)}
|
||||||
|
className={`w-full flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-200 text-left group ${
|
||||||
|
i === current
|
||||||
|
? 'bg-amber-400/[0.07] border border-amber-400/15'
|
||||||
|
: 'hover:bg-white/[0.03] border border-transparent'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<div className="w-7 flex-shrink-0 flex items-center justify-center">
|
||||||
</button>
|
<TrackNumber index={i} isCurrent={i === current} playing={playing} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`font-cormorant italic text-lg flex-1 min-w-0 truncate transition-colors ${
|
||||||
|
i === current
|
||||||
|
? 'text-amber-200/80'
|
||||||
|
: 'text-amber-100/35 group-hover:text-amber-100/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{trackName(i)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{i === current && playing ? (
|
||||||
|
<Pause size={13} className="text-amber-400/50" />
|
||||||
|
) : (
|
||||||
|
<Play size={13} className="text-amber-400/40" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player controls */}
|
||||||
|
<div className="border-t border-amber-400/10 pt-6">
|
||||||
|
<p className="text-center font-cormorant italic text-amber-200/50 text-xl mb-5 truncate px-4">
|
||||||
|
{trackName(current)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<span className="font-lora text-xs text-amber-100/20 w-9 text-right tabular-nums">
|
||||||
|
{formatTime(elapsed)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 relative group/slider">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
value={progress}
|
||||||
|
onChange={handleSeek}
|
||||||
|
className="w-full cursor-pointer accent-amber-500"
|
||||||
|
style={{ height: '2px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-lora text-xs text-amber-100/20 w-9 tabular-nums">
|
||||||
|
{formatTime(duration)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Controls row */}
|
||||||
<input
|
<div className="flex items-center justify-center gap-8 mb-5">
|
||||||
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
|
<button
|
||||||
onClick={prev}
|
onClick={prev}
|
||||||
className="text-amber-500 hover:text-amber-300 transition-colors"
|
className="text-amber-400/30 hover:text-amber-400/70 transition-colors"
|
||||||
>
|
>
|
||||||
<SkipBack size={18} />
|
<SkipBack size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={togglePlay}
|
<motion.button
|
||||||
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"
|
onClick={() => { togglePlay(); setMiniVisible(true) }}
|
||||||
|
whileTap={{ scale: 0.93 }}
|
||||||
|
className="w-14 h-14 rounded-full bg-amber-400/10 hover:bg-amber-400/18 border border-amber-400/20 hover:border-amber-400/40 text-amber-300/80 flex items-center justify-center transition-all duration-200 shadow-lg"
|
||||||
|
style={{ boxShadow: playing ? '0 0 24px rgba(196,160,74,0.15)' : undefined }}
|
||||||
>
|
>
|
||||||
{playing ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
|
{playing ? <Pause size={22} /> : <Play size={22} className="ml-0.5" />}
|
||||||
</button>
|
</motion.button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={next}
|
onClick={next}
|
||||||
className="text-amber-500 hover:text-amber-300 transition-colors"
|
className="text-amber-400/30 hover:text-amber-400/70 transition-colors"
|
||||||
>
|
>
|
||||||
<SkipForward size={18} />
|
<SkipForward size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Volume */}
|
{/* Volume */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMuted((m) => !m)}
|
onClick={() => setMuted((m) => !m)}
|
||||||
className="text-amber-600 hover:text-amber-400 transition-colors"
|
className="text-amber-600/40 hover:text-amber-400/60 transition-colors"
|
||||||
>
|
>
|
||||||
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -185,9 +287,68 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
setVolume(parseFloat(e.target.value))
|
setVolume(parseFloat(e.target.value))
|
||||||
setMuted(false)
|
setMuted(false)
|
||||||
}}
|
}}
|
||||||
className="flex-1 accent-amber-600 h-1 cursor-pointer"
|
className="w-28 accent-amber-600 cursor-pointer"
|
||||||
|
style={{ height: '2px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Floating mini-player ── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{miniVisible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 16, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className="fixed bottom-6 right-6 z-50 flex items-center gap-3 bg-stone-950/96 backdrop-blur-md px-4 py-3 rounded-2xl border border-amber-900/25 shadow-2xl"
|
||||||
|
>
|
||||||
|
{/* Icon + pulse */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full bg-amber-800/40 flex items-center justify-center ${
|
||||||
|
playing ? 'ring-1 ring-amber-400/30' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Music size={14} className="text-amber-300/80" />
|
||||||
|
</div>
|
||||||
|
{playing && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
document.getElementById('musik')?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
className="max-w-[130px] text-left"
|
||||||
|
>
|
||||||
|
<p className="text-amber-200/70 font-cormorant italic text-sm truncate leading-tight">
|
||||||
|
{trackName(current)}
|
||||||
|
</p>
|
||||||
|
<p className="text-amber-600/40 text-xs font-lora mt-0.5">
|
||||||
|
{playing ? `${formatTime(elapsed)} / ${formatTime(duration)}` : 'pausiert'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Play/pause */}
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="text-amber-400/60 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
{playing ? <Pause size={16} /> : <Play size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMiniVisible(false)}
|
||||||
|
className="text-amber-800/60 hover:text-amber-500/80 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
Reference in New Issue
Block a user