bf070ec47f
- Rewrote CandleSection with properly aligned animated flames (fix Framer Motion transform-overwrite bug by using left:0 / pixel offsets instead of translateX(-50%)); added size prop for inline flames - Fixed Timeline: CSS dots/line replacing broken SVG estimates, mobile padding (pl-10), larger dots, cards grow with content - Fixed MusicPlayer: actual play/pause (not mute), visibilitychange + pagehide handlers so music stops on iOS lock screen / app switch - Fixed nav: horizontal scroll on mobile (overflow-x-auto whitespace-nowrap) - Fixed footer: Impressum centered, admin link moved to absolute/hidden - Removed meine-oma link from TributeSection (page still accessible) - Added admin auth (isAdmin cookie check) to /api/upload POST, /api/contributions/[id] PUT/DELETE, and /api/candles/[id] PUT/DELETE - Extracted sp(), relativeTime(), formatDate() to src/lib/utils.ts - Added Vitest with 15 unit tests for utility functions (npm test) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { MapPin, X, Calendar, User } from 'lucide-react'
|
|
import { formatDate } from '@/lib/utils'
|
|
|
|
type TimelineEntry = {
|
|
id: number
|
|
year: string
|
|
month: string | null
|
|
day: string | null
|
|
title: string
|
|
description: string | null
|
|
location: string | null
|
|
media_filenames: string | null
|
|
source: 'official' | 'community'
|
|
contributorName?: string
|
|
}
|
|
|
|
interface TimelineSectionProps {
|
|
entries: TimelineEntry[]
|
|
}
|
|
|
|
export default function TimelineSection({ entries }: TimelineSectionProps) {
|
|
const [selectedEntry, setSelectedEntry] = useState<TimelineEntry | null>(null)
|
|
|
|
// Find birth and death indices
|
|
const birthIndex = entries.findIndex(e => e.title.toLowerCase().includes('geburt'))
|
|
const deathIndex = entries.findIndex(e => e.title.toLowerCase().includes('tod') || e.title.toLowerCase().includes('verstorben'))
|
|
|
|
return (
|
|
<section id="zeitstrahl" className="py-16 sm:py-24 px-4 bg-gradient-to-b from-amber-50/30 to-cream">
|
|
<div className="max-w-5xl mx-auto">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ duration: 0.6 }}
|
|
className="text-center mb-16"
|
|
>
|
|
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-4">
|
|
Lebensreise
|
|
</h2>
|
|
<div className="flex items-center justify-center gap-4">
|
|
<div className="h-px w-16 bg-warm-gold/40" />
|
|
<span className="text-warm-gold text-xl">✦</span>
|
|
<div className="h-px w-16 bg-warm-gold/40" />
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Timeline Path */}
|
|
<div className="relative">
|
|
{/* Desktop: centered vertical line — w-0.5 (2 px) for better visibility */}
|
|
<div className="absolute left-1/2 top-0 bottom-0 w-0.5 -translate-x-1/2 bg-gradient-to-b from-warm-gold/20 via-warm-gold/50 to-warm-gold/20 hidden sm:block" />
|
|
|
|
{/* Mobile: left-side vertical line */}
|
|
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gradient-to-b from-warm-gold/40 via-warm-gold/25 to-warm-gold/40 sm:hidden" />
|
|
|
|
{/* Entries */}
|
|
<div className="space-y-12 sm:space-y-16 py-4 relative" style={{ zIndex: 10 }}>
|
|
{entries.map((entry, index) => {
|
|
const isLeft = index % 2 === 0
|
|
const isBirth = index === birthIndex
|
|
const isDeath = index === deathIndex
|
|
const isSpecial = isBirth || isDeath
|
|
const photos = entry.media_filenames ? entry.media_filenames.split(',') : []
|
|
|
|
return (
|
|
<motion.div
|
|
key={`${entry.source}-${entry.id}`}
|
|
initial={{ opacity: 0, x: isLeft ? -30 : 30 }}
|
|
whileInView={{ opacity: 1, x: 0 }}
|
|
viewport={{ once: true, margin: '-50px' }}
|
|
transition={{ duration: 0.6, delay: 0.1 }}
|
|
className={`relative flex items-start ${
|
|
isLeft
|
|
? 'pl-10 sm:pl-0 sm:pr-[52%]'
|
|
: 'pl-10 sm:pl-[52%]'
|
|
}`}
|
|
>
|
|
{/* Content Card */}
|
|
<motion.button
|
|
onClick={() => setSelectedEntry(entry)}
|
|
whileHover={{ scale: 1.02, y: -2 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
className={`group cursor-pointer text-left w-full bg-white/80 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-4 sm:p-5 hover:shadow-lg transition-all ${
|
|
isLeft ? 'sm:mr-auto' : 'sm:ml-auto'
|
|
} ${isSpecial ? 'ring-2 ring-warm-gold/30' : ''}`}
|
|
style={{ minWidth: 0 }}
|
|
>
|
|
{/* Photos */}
|
|
{photos.length > 0 && (
|
|
<div className={`grid gap-2 mb-3 ${photos.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
|
{photos.slice(0, 2).map((filename, i) => (
|
|
<img
|
|
key={i}
|
|
src={`/api/files/${filename.trim()}`}
|
|
alt=""
|
|
className="w-full max-h-40 object-contain rounded-lg bg-warm-brown/5"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Date */}
|
|
<div className={`font-cormorant mb-1 ${isSpecial ? 'text-3xl text-warm-gold' : 'text-2xl text-warm-gold'}`}>
|
|
{formatDate(entry.year, entry.month, entry.day)}
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<h3 className={`font-cormorant italic mb-1 group-hover:text-warm-gold transition-colors ${isSpecial ? 'text-xl text-warm-brown font-medium' : 'text-lg text-warm-brown'}`}>
|
|
{entry.title}
|
|
</h3>
|
|
|
|
{/* Location */}
|
|
{entry.location && (
|
|
<div className="flex items-center gap-1 text-warm-brown-light/50 mb-2 text-xs">
|
|
<MapPin size={11} />
|
|
<span className="font-lora">{entry.location}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Description — no clamp, card grows with content */}
|
|
{entry.description && (
|
|
<p className="font-lora text-warm-brown-light/60 text-xs leading-relaxed">
|
|
{entry.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* Contributor */}
|
|
{entry.source === 'community' && entry.contributorName && (
|
|
<div className="flex items-center gap-1 text-amber-600/60 text-[10px] mt-2 italic font-lora">
|
|
<User size={10} />
|
|
<span>{entry.contributorName}</span>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-warm-gold/40 group-hover:text-warm-gold/70 text-[10px] font-lora mt-2 transition-colors">
|
|
{photos.length > 2 && `+${photos.length - 2} weitere · `}Details →
|
|
</p>
|
|
</motion.button>
|
|
|
|
{/* Dot - Mobile (left-side line) */}
|
|
<div className="sm:hidden absolute left-5 top-5 -translate-x-1/2 z-20">
|
|
<div className={`rounded-full border-2 border-white shadow-sm ${
|
|
isSpecial
|
|
? 'w-5 h-5 bg-warm-gold ring-2 ring-warm-gold/30'
|
|
: entry.source === 'community'
|
|
? 'w-3.5 h-3.5 bg-amber-400'
|
|
: 'w-3.5 h-3.5 bg-warm-gold'
|
|
}`} />
|
|
</div>
|
|
|
|
{/* Dot - Desktop (centred line) */}
|
|
<div className="hidden sm:block absolute left-1/2 top-5 -translate-x-1/2 z-20">
|
|
<div className={`rounded-full border-2 border-white shadow-sm ${
|
|
isSpecial
|
|
? 'w-5 h-5 bg-warm-gold ring-2 ring-warm-gold/30'
|
|
: entry.source === 'community'
|
|
? 'w-4 h-4 bg-amber-400'
|
|
: 'w-4 h-4 bg-warm-gold'
|
|
}`} />
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail Modal */}
|
|
<AnimatePresence>
|
|
{selectedEntry && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setSelectedEntry(null)}
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, y: 20 }}
|
|
animate={{ scale: 1, y: 0 }}
|
|
exit={{ scale: 0.9, y: 20 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="bg-cream rounded-2xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-y-auto"
|
|
>
|
|
{/* Header */}
|
|
<div className="sticky top-0 bg-cream/95 backdrop-blur-sm border-b border-warm-border p-6 flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 text-warm-gold text-sm font-lora mb-2">
|
|
<Calendar size={14} />
|
|
{formatDate(selectedEntry.year, selectedEntry.month, selectedEntry.day)}
|
|
</div>
|
|
<h3 className="font-cormorant italic text-3xl text-warm-brown">
|
|
{selectedEntry.title}
|
|
</h3>
|
|
{selectedEntry.location && (
|
|
<div className="flex items-center gap-1 text-warm-brown-light/60 text-sm mt-1">
|
|
<MapPin size={13} />
|
|
<span className="font-lora">{selectedEntry.location}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setSelectedEntry(null)}
|
|
className="text-warm-brown-light/40 hover:text-warm-brown transition-colors p-1"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6 space-y-6">
|
|
{/* Photos */}
|
|
{selectedEntry.media_filenames && (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{selectedEntry.media_filenames.split(',').map((filename, i) => (
|
|
<img
|
|
key={i}
|
|
src={`/api/files/${filename.trim()}`}
|
|
alt=""
|
|
className="w-full object-contain rounded-lg bg-warm-brown/5"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Description */}
|
|
{selectedEntry.description && (
|
|
<p className="font-lora text-warm-brown-light/80 text-sm leading-relaxed whitespace-pre-wrap">
|
|
{selectedEntry.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* Contributor info */}
|
|
{selectedEntry.source === 'community' && selectedEntry.contributorName && (
|
|
<div className="pt-4 border-t border-warm-border">
|
|
<p className="font-lora text-xs text-amber-600/60 italic flex items-center gap-1.5">
|
|
<User size={12} />
|
|
Beitrag von {selectedEntry.contributorName}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</section>
|
|
)
|
|
}
|