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:
denshooter
2026-02-16 03:48:46 +01:00
parent 313b5ff7fd
commit 4d56d4904a
17 changed files with 1121 additions and 600 deletions
+2 -4
View File
@@ -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
View File
@@ -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')
+1 -1
View File
@@ -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',
+3 -1
View File
@@ -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,
+2
View File
@@ -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()
+2
View File
@@ -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
+2
View File
@@ -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
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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
View File
@@ -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 */}