feat: persönliche Gedenkseite – Tribute, Autoplay-Musik, Next.js 16 Fixes
- TributeSection: zwei Perspektiven (Familie + Dennis), emotional und persönlich - MusicPlayer: Autoplay mit stummem Start + Fade-In bei Interaktion, nahtloser Crossfade-Loop (überspringt stille letzten 10s), kompakter Mute-Button - Alle API-Routes: export const runtime = 'nodejs' für node:sqlite in Next.js 16 - Admin: defensive res.ok Checks vor .json() Parsing - DB: mkdirSync erst zur Laufzeit, path.resolve für DATA_DIR - page.tsx: plain() Helper für null-prototype SQLite-Rows - erinnerungen.md: alle Familieninfos und Erinnerungen dokumentiert - Nav: Musik-Tab entfernt, "Über Oma" hinzugefügt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,10 +64,8 @@ export default function AdminPage() {
|
||||
fetch('/api/memories'),
|
||||
fetch('/api/media'),
|
||||
])
|
||||
const [memoriesData, mediaData] = await Promise.all([
|
||||
memoriesRes.json(),
|
||||
mediaRes.json(),
|
||||
])
|
||||
const memoriesData = memoriesRes.ok ? await memoriesRes.json() : []
|
||||
const mediaData = mediaRes.ok ? await mediaRes.json() : []
|
||||
setMemories(Array.isArray(memoriesData) ? memoriesData : [])
|
||||
const items: MediaItem[] = Array.isArray(mediaData) ? mediaData : []
|
||||
setPhotos(items.filter((m) => m.type === 'photo'))
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createHash } from 'crypto'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function getExpectedToken() {
|
||||
return createHash('sha256')
|
||||
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'path'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
|
||||
@@ -6,6 +6,8 @@ import { unlink } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
async function isAdmin() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('admin_auth')?.value
|
||||
@@ -15,7 +17,7 @@ async function isAdmin() {
|
||||
return token === expected
|
||||
}
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const type = req.nextUrl.searchParams.get('type')
|
||||
const db = getDb()
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getDb } from '@/lib/db'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
async function isAdmin() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('admin_auth')?.value
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET() {
|
||||
const db = getDb()
|
||||
const memories = db
|
||||
|
||||
@@ -17,7 +17,7 @@ async function isAdmin() {
|
||||
return token === expected
|
||||
}
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
const MIME_TO_FOLDER: Record<string, string> = {
|
||||
'image/jpeg': 'photos',
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function ImpressumPage() {
|
||||
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="font-medium text-warm-brown/90">Dennis Konkol</p>
|
||||
<p className="text-warm-brown-light text-xs italic">
|
||||
(Kontaktdaten auf Anfrage)
|
||||
</p>
|
||||
@@ -99,7 +99,7 @@ export default function ImpressumPage() {
|
||||
</h2>
|
||||
<p>
|
||||
Alle auf dieser Seite veröffentlichten Fotos und Medien sind privates
|
||||
Eigentum der Familie Malejka. Eine Weitergabe oder Veröffentlichung ohne
|
||||
Eigentum der Familie. Eine Weitergabe oder Veröffentlichung ohne
|
||||
ausdrückliche Genehmigung ist nicht gestattet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
+25
-16
@@ -7,24 +7,30 @@ import MemorySection from '@/components/MemorySection'
|
||||
import WriteSection from '@/components/WriteSection'
|
||||
import MusicPlayer from '@/components/MusicPlayer'
|
||||
import VideoGallery from '@/components/VideoGallery'
|
||||
import TributeSection from '@/components/TributeSection'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// node:sqlite returns null-prototype objects; convert to plain objects for Client Components
|
||||
function plain<T>(rows: unknown[]): T[] {
|
||||
return JSON.parse(JSON.stringify(rows))
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const db = getDb()
|
||||
|
||||
const photos = db
|
||||
.prepare("SELECT * FROM media WHERE type = 'photo' ORDER BY sort_order, created_at")
|
||||
.all() as MediaItem[]
|
||||
const videos = db
|
||||
.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at")
|
||||
.all() as MediaItem[]
|
||||
const music = db
|
||||
.prepare("SELECT * FROM media WHERE type = 'music' ORDER BY sort_order, created_at")
|
||||
.all() as MediaItem[]
|
||||
const memories = db
|
||||
.prepare('SELECT * FROM memories ORDER BY created_at DESC')
|
||||
.all() as Memory[]
|
||||
const photos = plain<MediaItem>(
|
||||
db.prepare("SELECT * FROM media WHERE type = 'photo' ORDER BY sort_order, created_at").all()
|
||||
)
|
||||
const videos = plain<MediaItem>(
|
||||
db.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at").all()
|
||||
)
|
||||
const music = plain<MediaItem>(
|
||||
db.prepare("SELECT * FROM media WHERE type = 'music' ORDER BY sort_order, created_at").all()
|
||||
)
|
||||
const memories = plain<Memory>(
|
||||
db.prepare('SELECT * FROM memories ORDER BY created_at DESC').all()
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-cream">
|
||||
@@ -34,6 +40,9 @@ export default async function HomePage() {
|
||||
{/* Navigation */}
|
||||
<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">
|
||||
<a href="#ueber-oma" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Über Oma
|
||||
</a>
|
||||
{photos.length > 0 && (
|
||||
<a href="#bilder" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Bilder
|
||||
@@ -47,12 +56,12 @@ export default async function HomePage() {
|
||||
Videos
|
||||
</a>
|
||||
)}
|
||||
<a href="#musik" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Musik
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Personal tribute */}
|
||||
<TributeSection />
|
||||
|
||||
{/* Photos */}
|
||||
{photos.length > 0 && (
|
||||
<section id="bilder" className="py-16 sm:py-20">
|
||||
@@ -84,7 +93,7 @@ export default async function HomePage() {
|
||||
{/* Videos */}
|
||||
<VideoGallery videos={videos} />
|
||||
|
||||
{/* Music – always rendered (ambient fallback when no tracks) */}
|
||||
{/* Floating music player */}
|
||||
<MusicPlayer tracks={music} />
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
+132
-452
@@ -1,481 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipForward,
|
||||
SkipBack,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Volume2, VolumeX } from 'lucide-react'
|
||||
import type { MediaItem } from '@/lib/types'
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ambient audio via Web Audio API ────────────────────────────────────────
|
||||
//
|
||||
// "Singing bowls" synthesis: each tone is a struck resonance with
|
||||
// natural exponential decay – sounds like real crystal/tibetan bowls,
|
||||
// not oscillator drones. Appropriate and peaceful for a memorial.
|
||||
//
|
||||
// Scale: A-minor pentatonic (A3 C4 D4 E4 G4 A4 C5 E5)
|
||||
// Timing: random gaps of 3–8 s between strikes, so it breathes naturally.
|
||||
// Reverb: two long delay tails for a large, warm hall.
|
||||
|
||||
function useAmbient() {
|
||||
const ctxRef = useRef<AudioContext | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
const [playing, setPlaying] = useState(false)
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (ctxRef.current) return
|
||||
const ctx = new AudioContext()
|
||||
ctxRef.current = ctx
|
||||
|
||||
// ── Signal chain ────────────────────────────────────────────────────
|
||||
const master = ctx.createGain()
|
||||
master.gain.value = 0.55
|
||||
master.connect(ctx.destination)
|
||||
|
||||
// Long hall reverb (two delay tails, no feedback)
|
||||
const mkTail = (t: number, wet: number) => {
|
||||
const d = ctx.createDelay(6.0)
|
||||
d.delayTime.value = t
|
||||
const g = ctx.createGain()
|
||||
g.gain.value = wet
|
||||
d.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
return d
|
||||
}
|
||||
const tail1 = mkTail(2.4, 0.30)
|
||||
const tail2 = mkTail(4.8, 0.18)
|
||||
|
||||
// ── Bowl strike ──────────────────────────────────────────────────────
|
||||
// Two slightly detuned sine waves → natural "shimmer beat" of a real bowl.
|
||||
// Gain follows exponential decay → sounds exactly like a struck bowl.
|
||||
const strike = (freq: number, vol: number) => {
|
||||
if (!ctxRef.current) return
|
||||
const now = ctx.currentTime
|
||||
const decay = 7 + Math.random() * 5 // 7–12 s natural ring
|
||||
const detune = freq * 0.0025 // 0.25 % → slow shimmer beat
|
||||
|
||||
;[freq, freq + detune].forEach((f, i) => {
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.value = f
|
||||
|
||||
const g = ctx.createGain()
|
||||
// Instant attack (bowl is struck), then pure exponential decay
|
||||
g.gain.setValueAtTime(vol * (i === 0 ? 1 : 0.6), now)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + decay)
|
||||
|
||||
osc.connect(g)
|
||||
g.connect(master)
|
||||
g.connect(tail1)
|
||||
g.connect(tail2)
|
||||
osc.start(now)
|
||||
osc.stop(now + decay + 0.05)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Scale ───────────────────────────────────────────────────────────
|
||||
// A-minor pentatonic – every note here sounds consonant with every other.
|
||||
// Weighted toward lower, warmer tones (more prominent) vs. higher (softer).
|
||||
const bowls: { freq: number; vol: number }[] = [
|
||||
{ freq: 220.00, vol: 0.38 }, // A3 – deep, warm anchor
|
||||
{ freq: 261.63, vol: 0.32 }, // C4
|
||||
{ freq: 293.66, vol: 0.28 }, // D4
|
||||
{ freq: 329.63, vol: 0.30 }, // E4
|
||||
{ freq: 392.00, vol: 0.26 }, // G4
|
||||
{ freq: 440.00, vol: 0.22 }, // A4
|
||||
{ freq: 523.25, vol: 0.18 }, // C5 – bright, light
|
||||
{ freq: 659.25, vol: 0.14 }, // E5 – delicate shimmer
|
||||
]
|
||||
|
||||
const pickBowl = () => bowls[Math.floor(Math.random() * bowls.length)]
|
||||
|
||||
// ── Scheduler ───────────────────────────────────────────────────────
|
||||
// Occasional harmonic pairs (two bowls together) feel more musical.
|
||||
const scheduleNext = () => {
|
||||
if (!ctxRef.current) return
|
||||
|
||||
const gap = 3000 + Math.random() * 5000 // 3–8 s between strikes
|
||||
|
||||
// ~30 % chance of a soft harmonic pair (two bowls a fifth apart)
|
||||
const b = pickBowl()
|
||||
strike(b.freq, b.vol)
|
||||
if (Math.random() < 0.30) {
|
||||
const fifth = b.freq * 1.5 // perfect fifth
|
||||
strike(fifth, b.vol * 0.55) // softer companion tone
|
||||
}
|
||||
|
||||
timerRef.current.push(setTimeout(scheduleNext, gap))
|
||||
}
|
||||
|
||||
// Open with three spaced strikes to fill the silence gently
|
||||
strike(220.00, 0.38) // A3 immediately
|
||||
timerRef.current.push(setTimeout(() => { if (ctxRef.current) strike(329.63, 0.30) }, 2800)) // E4
|
||||
timerRef.current.push(setTimeout(() => { if (ctxRef.current) strike(261.63, 0.28) }, 5200)) // C4
|
||||
timerRef.current.push(setTimeout(scheduleNext, 7500)) // then random
|
||||
|
||||
setPlaying(true)
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
timerRef.current.forEach(clearTimeout)
|
||||
timerRef.current = []
|
||||
const ctx = ctxRef.current
|
||||
if (ctx) {
|
||||
// Brief master fade so the currently ringing bowls die out naturally
|
||||
const fade = ctx.createGain()
|
||||
fade.gain.setValueAtTime(1, ctx.currentTime)
|
||||
fade.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5)
|
||||
fade.connect(ctx.destination)
|
||||
setTimeout(() => { ctx.close(); ctxRef.current = null }, 2700)
|
||||
}
|
||||
setPlaying(false)
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (playing) stop()
|
||||
else start()
|
||||
}, [playing, start, stop])
|
||||
|
||||
return { playing, toggle, start, stop }
|
||||
}
|
||||
|
||||
// ─── component ──────────────────────────────────────────────────────────────
|
||||
const TAIL_SKIP = 10
|
||||
const CROSSFADE_DURATION = 3
|
||||
const VOLUME = 0.4
|
||||
const FADE_IN_DURATION = 2000 // ms to fade in after first interaction
|
||||
|
||||
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
const [current, setCurrent] = useState(0)
|
||||
const track = tracks[0] ?? null
|
||||
const src = track ? `/api/files/${track.filename}` : null
|
||||
|
||||
const audioA = useRef<HTMLAudioElement>(null)
|
||||
const audioB = useRef<HTMLAudioElement>(null)
|
||||
const activeRef = useRef<'A' | 'B'>('A')
|
||||
const fadingRef = useRef(false)
|
||||
const unmutedRef = useRef(false)
|
||||
|
||||
const [userMuted, setUserMuted] = useState(false)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [volume, setVolume] = useState(0.4)
|
||||
const [muted, setMuted] = useState(false)
|
||||
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 ambient = useAmbient()
|
||||
|
||||
const hasTrack = tracks.length > 0
|
||||
const track = tracks[current] ?? null
|
||||
const getActive = useCallback(
|
||||
() => (activeRef.current === 'A' ? audioA.current : audioB.current),
|
||||
[],
|
||||
)
|
||||
const getInactive = useCallback(
|
||||
() => (activeRef.current === 'A' ? audioB.current : audioA.current),
|
||||
[],
|
||||
)
|
||||
|
||||
const trackName = (i: number) =>
|
||||
tracks[i]?.original_name?.replace(/\.[^/.]+$/, '') ||
|
||||
tracks[i]?.caption ||
|
||||
`Titel ${i + 1}`
|
||||
const getVolume = useCallback(
|
||||
() => (userMuted ? 0 : VOLUME),
|
||||
[userMuted],
|
||||
)
|
||||
|
||||
// Crossfade to loop back
|
||||
const crossfade = useCallback(() => {
|
||||
if (fadingRef.current) return
|
||||
fadingRef.current = true
|
||||
|
||||
const out = getActive()!
|
||||
const inp = getInactive()!
|
||||
activeRef.current = activeRef.current === 'A' ? 'B' : 'A'
|
||||
|
||||
inp.currentTime = 0
|
||||
inp.volume = 0
|
||||
inp.play().catch(() => {})
|
||||
|
||||
const startTime = performance.now()
|
||||
const outStartVol = out.volume
|
||||
const targetVol = getVolume()
|
||||
|
||||
const step = () => {
|
||||
const t = Math.min((performance.now() - startTime) / (CROSSFADE_DURATION * 1000), 1)
|
||||
inp.volume = targetVol * t
|
||||
out.volume = outStartVol * (1 - t)
|
||||
if (t < 1) {
|
||||
requestAnimationFrame(step)
|
||||
} else {
|
||||
out.pause()
|
||||
out.currentTime = 0
|
||||
fadingRef.current = false
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
}, [getVolume, getActive, getInactive])
|
||||
|
||||
// Monitor playback for crossfade trigger
|
||||
useEffect(() => {
|
||||
const a = audioRef.current
|
||||
if (!a) return
|
||||
a.volume = muted ? 0 : volume
|
||||
}, [volume, muted])
|
||||
if (!playing || !src) return
|
||||
let id: number
|
||||
const tick = () => {
|
||||
const a = getActive()
|
||||
if (a && a.duration) {
|
||||
const remaining = a.duration - a.currentTime
|
||||
if (remaining <= TAIL_SKIP && !fadingRef.current) {
|
||||
crossfade()
|
||||
}
|
||||
}
|
||||
id = requestAnimationFrame(tick)
|
||||
}
|
||||
id = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [playing, src, getActive, crossfade])
|
||||
|
||||
// Fallback: if audio ends without crossfade, restart
|
||||
const handleEnded = useCallback(() => {
|
||||
const a = getActive()
|
||||
if (a) {
|
||||
a.currentTime = 0
|
||||
a.play().catch(() => {})
|
||||
}
|
||||
}, [getActive])
|
||||
|
||||
// Sync volume when user toggles mute
|
||||
useEffect(() => {
|
||||
const a = audioRef.current
|
||||
if (!a || !track) return
|
||||
a.src = `/api/files/${track.filename}`
|
||||
a.volume = muted ? 0 : volume
|
||||
setDuration(0); setElapsed(0); setProgress(0)
|
||||
if (playing) a.play().catch(() => setPlaying(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [current])
|
||||
if (!unmutedRef.current) return
|
||||
const vol = getVolume()
|
||||
const a = getActive()
|
||||
const b = getInactive()
|
||||
if (a && !fadingRef.current) a.volume = vol
|
||||
if (b && !fadingRef.current && !b.paused) b.volume = vol
|
||||
}, [userMuted, getVolume, getActive, getInactive])
|
||||
|
||||
const togglePlay = () => {
|
||||
const a = audioRef.current
|
||||
// Autoplay strategy:
|
||||
// 1. Start muted (browsers allow this)
|
||||
// 2. On first user interaction (scroll/click/touch/key), fade volume in
|
||||
useEffect(() => {
|
||||
if (!src) return
|
||||
const a = audioA.current
|
||||
if (!a) return
|
||||
if (playing) a.pause()
|
||||
else a.play().catch(() => setPlaying(false))
|
||||
}
|
||||
|
||||
const playTrack = (i: number) => {
|
||||
if (i === current) togglePlay()
|
||||
else { setCurrent(i); setPlaying(true) }
|
||||
setMiniVisible(true)
|
||||
}
|
||||
// Start muted playback immediately
|
||||
a.volume = 0
|
||||
a.play().then(() => setPlaying(true)).catch(() => {})
|
||||
|
||||
const prev = () => setCurrent((c) => (c - 1 + tracks.length) % tracks.length)
|
||||
const next = () => setCurrent((c) => (c + 1) % tracks.length)
|
||||
// Fade in on first interaction
|
||||
const fadeIn = () => {
|
||||
if (unmutedRef.current) return
|
||||
unmutedRef.current = true
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const a = audioRef.current
|
||||
if (!a || !a.duration) return
|
||||
setProgress((a.currentTime / a.duration) * 100)
|
||||
setElapsed(a.currentTime)
|
||||
}
|
||||
const active = activeRef.current === 'A' ? audioA.current : audioB.current
|
||||
if (!active) return
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const a = audioRef.current
|
||||
if (!a || !a.duration) return
|
||||
const pct = parseFloat(e.target.value)
|
||||
a.currentTime = (pct / 100) * a.duration
|
||||
setProgress(pct)
|
||||
}
|
||||
const startTime = performance.now()
|
||||
const step = () => {
|
||||
const t = Math.min((performance.now() - startTime) / FADE_IN_DURATION, 1)
|
||||
active.volume = VOLUME * t
|
||||
if (t < 1) requestAnimationFrame(step)
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
// Decide what the mini-player shows
|
||||
const miniLabel = hasTrack ? trackName(current) : 'Stille Begleitung'
|
||||
const miniPlaying = hasTrack ? playing : ambient.playing
|
||||
const events = ['scroll', 'click', 'touchstart', 'keydown'] as const
|
||||
events.forEach((e) => window.addEventListener(e, fadeIn, { once: true, passive: true }))
|
||||
|
||||
return () => {
|
||||
events.forEach((e) => window.removeEventListener(e, fadeIn))
|
||||
}
|
||||
}, [src])
|
||||
|
||||
if (!track || !src) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasTrack && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={`/api/files/${track!.filename}`}
|
||||
onEnded={next}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={() => { const a = audioRef.current; if (a) setDuration(a.duration) }}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
/>
|
||||
)}
|
||||
<audio ref={audioA} src={src} preload="auto" onEnded={handleEnded} />
|
||||
<audio ref={audioB} src={src} preload="auto" onEnded={handleEnded} />
|
||||
|
||||
{/* ── Inline section ─────────────────────────────────────── */}
|
||||
<section
|
||||
id="musik"
|
||||
className="py-20 px-4"
|
||||
style={{ background: 'linear-gradient(to bottom, #0a0706, #060304)' }}
|
||||
<button
|
||||
onClick={() => setUserMuted((m) => !m)}
|
||||
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-stone-950/85 backdrop-blur-sm border border-amber-900/20 shadow-lg flex items-center justify-center text-amber-400/60 hover:text-amber-300 transition-colors"
|
||||
title={userMuted ? 'Ton an' : 'Ton aus'}
|
||||
>
|
||||
<div className="max-w-xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<p className="text-amber-200/30 text-xs tracking-[0.5em] uppercase font-lora mb-3">
|
||||
Begleitung in Tönen
|
||||
</p>
|
||||
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-amber-100/70 mb-3">
|
||||
Musik
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="h-px w-12 bg-amber-400/15" />
|
||||
<span className="text-amber-400/25">♪</span>
|
||||
<div className="h-px w-12 bg-amber-400/15" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── No uploads: ambient player ── */}
|
||||
{!hasTrack && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<p className="font-lora text-amber-100/30 text-sm mb-8 leading-relaxed max-w-xs mx-auto">
|
||||
Sanfte Klangschalen begleiten dich —
|
||||
lade eigene Musik hoch, um sie hier abzuspielen.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<motion.button
|
||||
onClick={() => { ambient.toggle(); setMiniVisible(true) }}
|
||||
whileTap={{ scale: 0.94 }}
|
||||
className="w-16 h-16 rounded-full bg-amber-400/10 hover:bg-amber-400/18 border border-amber-400/20 hover:border-amber-400/40 text-amber-300/70 flex items-center justify-center transition-all duration-200"
|
||||
style={{ boxShadow: ambient.playing ? '0 0 28px rgba(196,160,74,0.14)' : undefined }}
|
||||
>
|
||||
{ambient.playing
|
||||
? <Pause size={22} />
|
||||
: <Play size={22} className="ml-1" />
|
||||
}
|
||||
</motion.button>
|
||||
|
||||
{ambient.playing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-end gap-1"
|
||||
>
|
||||
<WaveformBars playing={ambient.playing} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<p className="font-cormorant italic text-amber-200/35 text-lg">
|
||||
Klangschalen
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ── With uploads: track list + controls ── */}
|
||||
{hasTrack && (
|
||||
<>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<div className="w-7 flex-shrink-0 flex items-center justify-center">
|
||||
{i === current && playing
|
||||
? <WaveformBars playing={playing} />
|
||||
: <span className={`font-lora text-xs tabular-nums ${i === current ? 'text-amber-400/60' : 'text-amber-100/20'}`}>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="range" min="0" max="100" step="0.1" value={progress}
|
||||
onChange={handleSeek}
|
||||
className="flex-1 cursor-pointer accent-amber-500"
|
||||
style={{ height: '2px' }}
|
||||
/>
|
||||
<span className="font-lora text-xs text-amber-100/20 w-9 tabular-nums">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-8 mb-5">
|
||||
<button onClick={prev} className="text-amber-400/30 hover:text-amber-400/70 transition-colors">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<motion.button
|
||||
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"
|
||||
style={{ boxShadow: playing ? '0 0 24px rgba(196,160,74,0.15)' : undefined }}
|
||||
>
|
||||
{playing ? <Pause size={22} /> : <Play size={22} className="ml-0.5" />}
|
||||
</motion.button>
|
||||
<button onClick={next} className="text-amber-400/30 hover:text-amber-400/70 transition-colors">
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button onClick={() => setMuted((m) => !m)} className="text-amber-600/40 hover:text-amber-400/60 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="w-28 accent-amber-600 cursor-pointer"
|
||||
style={{ height: '2px' }}
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className={`w-8 h-8 rounded-full bg-amber-800/40 flex items-center justify-center ${miniPlaying ? 'ring-1 ring-amber-400/30' : ''}`}>
|
||||
{miniPlaying
|
||||
? <WaveformBars playing />
|
||||
: <Play size={12} className="text-amber-300/70 ml-0.5" />
|
||||
}
|
||||
</div>
|
||||
{miniPlaying && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{miniLabel}
|
||||
</p>
|
||||
<p className="text-amber-600/40 text-xs font-lora mt-0.5">
|
||||
{hasTrack
|
||||
? (playing ? `${formatTime(elapsed)} / ${formatTime(duration)}` : 'pausiert')
|
||||
: (ambient.playing ? 'läuft …' : 'pausiert')
|
||||
}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={hasTrack ? togglePlay : () => { ambient.toggle() }}
|
||||
className="text-amber-400/60 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
{miniPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setMiniVisible(false)}
|
||||
className="text-amber-800/60 hover:text-amber-500/80 transition-colors ml-1"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{userMuted ? <VolumeX size={22} /> : <Volume2 size={22} />}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const fade = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { once: true },
|
||||
transition: { duration: 0.8 },
|
||||
}
|
||||
|
||||
export default function TributeSection() {
|
||||
return (
|
||||
<section id="ueber-oma" className="py-20 sm:py-28">
|
||||
<div className="max-w-2xl mx-auto px-6">
|
||||
|
||||
{/* ── Family perspective ─────────────────────────────────── */}
|
||||
<motion.div {...fade} className="text-center mb-16">
|
||||
<p className="text-warm-gold/50 text-xs tracking-[0.5em] uppercase font-lora mb-4">
|
||||
Für immer in unseren Herzen
|
||||
</p>
|
||||
<h2 className="font-cormorant italic text-5xl sm:text-6xl text-warm-brown mb-4">
|
||||
Unsere Oma
|
||||
</h2>
|
||||
<div className="flex items-center justify-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>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-8 font-lora text-warm-brown/80 text-base sm:text-lg leading-relaxed">
|
||||
<motion.p {...fade}>
|
||||
Maria Malejka war keine Frau der großen Worte. Sie hat nicht viel
|
||||
geredet über das, was sie fühlt. Sie hat es gezeigt. Mit Essen, das
|
||||
stundenlang auf dem Herd stand. Mit einem Haus, in dem es immer warm
|
||||
war. Mit einer Tür, die für jeden offen stand.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Zusammen mit Josef, ihrem Mann, ist sie aus Polen nach Deutschland
|
||||
gekommen. Über 60 Jahre waren sie verheiratet. Diamantene Hochzeit.
|
||||
Drei Töchter hat sie großgezogen: Beate, Renate und Margret. Sie
|
||||
hat hier ein Zuhause aufgebaut. Nicht nur vier Wände, sondern einen
|
||||
Ort, an den man immer zurückkommen konnte.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Sie hat nichts weggeworfen. Alles hatte noch einen Zweck, alles
|
||||
wurde wiederverwendet. Kein Schickimicki, kein Trara. Einfach
|
||||
und ehrlich, so war sie. Und genau so hat sie auch geliebt.
|
||||
Nicht laut, aber immer da.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Weihnachten bei Oma und Opa war jedes Jahr das Gleiche, und genau
|
||||
das hat es so besonders gemacht. Der Kamin lief. Der Karpfen
|
||||
schwamm in der Badewanne. Die Geschenke lagen im Wohnzimmer,
|
||||
aber die Tür blieb zu, bis alle fertig gegessen hatten. Berge
|
||||
von Essen. Und sie mittendrin, glücklich wenn alle satt waren.
|
||||
Sie hat es geliebt, zu geben.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Sie war stur. Das wissen alle, die sie kannten. Sie hat die Dinge
|
||||
auf ihre Weise gemacht und sich nichts sagen lassen. Aber genau
|
||||
diese Sturheit hat auch die Familie zusammengehalten. Sie war der
|
||||
Anker. Der feste Punkt, um den sich alles gedreht hat.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Ihre Tiere hat sie geliebt. Ihre Vögel, ihren Hund Ronny. Und
|
||||
Pico, den Familienhund, als wäre er ihrer gewesen. Pico ist
|
||||
letztes Jahr auch von uns gegangen. Vielleicht hat er jetzt
|
||||
wieder seinen Platz bei ihr gefunden.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Ihr Haus, in dem so viel passiert ist, wird jetzt von Jacky und
|
||||
Niklas weitergeführt. Bald wird dort ein neues Leben beginnen.
|
||||
Ein Kind, das seine Uroma nie kennenlernen wird. Aber dessen
|
||||
Ultraschallbild bei ihr im Sarg liegt. So nah, wie es nur geht.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
{...fade}
|
||||
className="border-l-2 border-warm-gold/30 pl-6 py-2 my-12"
|
||||
>
|
||||
<p className="font-cormorant italic text-2xl sm:text-3xl text-warm-brown/70 leading-snug">
|
||||
Du hast uns ein Zuhause gegeben, Oma.
|
||||
<br />
|
||||
Das bleibt. Für immer.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Nach einem Oberschenkelhalsbruch wurde sie operiert. Es ging ihr
|
||||
gut. Sie lief schon wieder. Im Aufenthaltsraum hat sie Blumenvasen
|
||||
gesehen und gemeint, die könnte man doch mitnehmen. So war sie.
|
||||
In einer Woche hätte die Reha angefangen.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Dann eine Lungenembolie. Plötzlich. Ohne Vorwarnung. Am 10. Februar
|
||||
2026 mussten wir sie gehen lassen.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Diese Seite ist für dich, Oma. Und für alle, die dich kannten.
|
||||
Damit wir nicht vergessen. Damit deine Geschichten weitererzählt
|
||||
werden. Damit noch einmal jemand sagt, wie gut deine Rahmsauce
|
||||
war.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Divider between family and personal */}
|
||||
<motion.div {...fade} className="my-24 flex items-center justify-center gap-4">
|
||||
<div className="h-px w-20 bg-warm-gold/15" />
|
||||
<span className="text-warm-gold/25 text-sm">✽✽✽</span>
|
||||
<div className="h-px w-20 bg-warm-gold/15" />
|
||||
</motion.div>
|
||||
|
||||
{/* ── Dennis's perspective ───────────────────────────────── */}
|
||||
<motion.div {...fade} className="text-center mb-16">
|
||||
<p className="text-warm-gold/50 text-xs tracking-[0.5em] uppercase font-lora mb-4">
|
||||
Von Dennis
|
||||
</p>
|
||||
<h2 className="font-cormorant italic text-5xl sm:text-6xl text-warm-brown mb-4">
|
||||
Meine Oma
|
||||
</h2>
|
||||
<div className="flex items-center justify-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>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-8 font-lora text-warm-brown/80 text-base sm:text-lg leading-relaxed">
|
||||
<motion.p {...fade}>
|
||||
Ich kam aus der Schule und das Essen stand schon da. Jedes Mal.
|
||||
Rahmsauce. Ich weiß nicht, wie oft ich die gegessen habe, aber
|
||||
es war nie genug. Ich würde alles dafür geben, noch einmal an
|
||||
ihrem Küchentisch zu sitzen.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Oma roch nach Oma. Ich weiß nicht, wie ich das anders beschreiben
|
||||
soll. Nicht nach Parfum. Nicht nach irgendwas, das man kaufen kann.
|
||||
Einfach nach ihr. Wenn ich an sie denke, ist das Erste, was
|
||||
kommt, dieses Gefühl. Diese Wärme. Der Geruch von ihrem Haus.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Bei ihr war es immer heiß. Immer. Der Kamin lief, die Heizung
|
||||
lief, man hat geschwitzt und es war trotzdem schön. An Weihnachten
|
||||
war der Karpfen in der Badewanne und die Geschenke im Wohnzimmer.
|
||||
Aber die Tür blieb zu. Erst essen, dann Geschenke. Das war Gesetz.
|
||||
Und wir haben uns jedes Mal gefreut, als wären wir fünf.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Wenn Pico bei ihr war, hab ich nicht mehr existiert. Sie hat mit
|
||||
ihm geredet, ihn gefüttert, ihn verwöhnt. Ich saß daneben und
|
||||
war Luft. Aber das war okay. Weil sie so glücklich war dabei.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Pico ist letztes Jahr gestorben. Und jetzt Oma. Ich stelle mir
|
||||
vor, wie sie irgendwo sitzt und er neben ihr liegt und sie ihm
|
||||
wieder irgendwas erzählt, was er nicht versteht. Und er hört
|
||||
trotzdem zu.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Sie war stur. Richtig stur. Deswegen hatte sie auch den
|
||||
Oberschenkelhalsbruch. Weil sie alles alleine machen wollte.
|
||||
Weil sie sich nichts sagen lassen hat. Man konnte sich aufregen.
|
||||
Aber im Nachhinein war das auch das, was sie so stark gemacht hat.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
{...fade}
|
||||
className="border-l-2 border-warm-gold/30 pl-6 py-2 my-12"
|
||||
>
|
||||
<p className="font-cormorant italic text-2xl sm:text-3xl text-warm-brown/70 leading-snug">
|
||||
Ich habe es dir viel zu selten gesagt, Oma.
|
||||
<br />
|
||||
Aber ich habe dich geliebt. Sehr.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Das letzte Mal hab ich sie im Krankenhaus besucht. In der
|
||||
Geriatrie. Sie lief schon wieder, es ging ihr gut. Sie hat
|
||||
im Aufenthaltsraum Blumenvasen gesehen und meinte, die könnte
|
||||
man ja mitnehmen. Ich hab gelacht. So war sie halt.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Ihr Zimmer war gegenüber von dem Zimmer, in dem meine Mutter
|
||||
wochenlang gelegen hatte, als sie mit mir schwanger war. Mama
|
||||
durfte sich kaum bewegen damals. Im selben Flur, in dem ich auf
|
||||
die Welt kam, ist Oma gegangen. Das lässt mich nicht los.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Am 10. Februar, um 13:13 Uhr, schrieb mir meine Schwester
|
||||
auf Telegram: <span className="italic text-warm-brown">„Sie
|
||||
möchten die Reanimation abbrechen."</span>
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Jacky ist schwanger. Sie hat den Ultraschall mit in den Sarg
|
||||
gelegt. Ein Kind, das Oma nie treffen wird, aber das in ihrem
|
||||
Haus aufwachsen wird. In den Wänden, die nach Oma riechen. In
|
||||
der Küche, in der die Rahmsauce stand.
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...fade}>
|
||||
Diese Seite ist für dich. Damit ich nicht vergesse. Damit
|
||||
niemand vergisst.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Closing details */}
|
||||
<motion.div
|
||||
{...fade}
|
||||
className="mt-20 text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
<div className="h-px w-12 bg-warm-gold/20" />
|
||||
<span className="text-warm-gold/30">✽</span>
|
||||
<div className="h-px w-12 bg-warm-gold/20" />
|
||||
</div>
|
||||
<p className="font-cormorant italic text-warm-brown/50 text-lg">
|
||||
Maria Malejka
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/40 text-sm mt-1">
|
||||
29. November 1944 · 10. Februar 2026
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/30 text-xs mt-3 tracking-wide">
|
||||
Beerdigung am 20. Februar 2026
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+6
-8
@@ -4,19 +4,17 @@ import fs from 'fs'
|
||||
|
||||
export type { Memory, MediaItem } from './types'
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||
|
||||
// Ensure upload directories exist
|
||||
for (const dir of ['uploads/photos', 'uploads/videos', 'uploads/music']) {
|
||||
fs.mkdirSync(path.join(DATA_DIR, dir), { recursive: true })
|
||||
}
|
||||
|
||||
const dbPath = path.join(DATA_DIR, 'oma.db')
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
let _db: DatabaseSync | null = null
|
||||
|
||||
export function getDb(): DatabaseSync {
|
||||
if (!_db) {
|
||||
// Ensure upload directories exist (only at runtime, not build time)
|
||||
for (const dir of ['uploads/photos', 'uploads/videos', 'uploads/music']) {
|
||||
fs.mkdirSync(path.join(DATA_DIR, dir), { recursive: true })
|
||||
}
|
||||
const dbPath = path.join(DATA_DIR, 'oma.db')
|
||||
_db = new DatabaseSync(dbPath)
|
||||
initDb(_db)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user