Fix UI issues, add tests, and patch missing auth on API routes
- 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>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
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
|
||||
@@ -21,19 +22,6 @@ interface TimelineSectionProps {
|
||||
entries: TimelineEntry[]
|
||||
}
|
||||
|
||||
function formatDate(year: string, month?: string | null, day?: string | null): string {
|
||||
if (day && month) {
|
||||
const monthNames = ['Jan', 'Feb', 'März', 'Apr', 'Mai', 'Juni', 'Juli', 'Aug', 'Sept', 'Okt', 'Nov', 'Dez']
|
||||
const monthName = monthNames[parseInt(month) - 1] || month
|
||||
return `${day}. ${monthName} ${year}`
|
||||
} else if (month) {
|
||||
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
const monthName = monthNames[parseInt(month) - 1] || `Monat ${month}`
|
||||
return `${monthName} ${year}`
|
||||
}
|
||||
return year
|
||||
}
|
||||
|
||||
export default function TimelineSection({ entries }: TimelineSectionProps) {
|
||||
const [selectedEntry, setSelectedEntry] = useState<TimelineEntry | null>(null)
|
||||
|
||||
@@ -62,99 +50,11 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
|
||||
</motion.div>
|
||||
|
||||
{/* Timeline Path */}
|
||||
<div className="relative" style={{ minHeight: entries.length > 0 ? `${entries.length * 180}px` : '400px' }}>
|
||||
{/* SVG with line AND dots - Desktop */}
|
||||
{entries.length >= 2 && (
|
||||
<svg
|
||||
className="absolute left-0 top-0 w-full h-full pointer-events-none hidden sm:block"
|
||||
style={{ zIndex: 1 }}
|
||||
viewBox="-2 -2 104 104"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="timelineGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgb(196, 160, 74)" stopOpacity="0.8" />
|
||||
<stop offset="50%" stopColor="rgb(196, 160, 74)" stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor="rgb(196, 160, 74)" stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Draw the path */}
|
||||
<path
|
||||
d={(() => {
|
||||
const totalEntries = entries.length
|
||||
let pathString = ''
|
||||
|
||||
// Calculate actual spacing: py-4 (16px) + cards with sm:space-y-16 (64px)
|
||||
const cardSpacing = 64 // space-y-16
|
||||
const topPadding = 16 // py-4
|
||||
const dotOffset = 40 // Approximate middle of card (cards ~80-100px tall)
|
||||
|
||||
for (let i = 0; i < totalEntries; i++) {
|
||||
// Calculate Y based on actual layout
|
||||
const actualPixels = topPadding + (i * (cardSpacing + 80)) + dotOffset
|
||||
const containerHeight = topPadding + ((totalEntries - 1) * (cardSpacing + 80)) + dotOffset + 20
|
||||
const y = (actualPixels / containerHeight) * 100
|
||||
const x = 50
|
||||
|
||||
if (i === 0) {
|
||||
pathString = `M ${x} ${y}`
|
||||
} else {
|
||||
const prevActualPixels = topPadding + ((i - 1) * (cardSpacing + 80)) + dotOffset
|
||||
const prevY = (prevActualPixels / containerHeight) * 100
|
||||
const midY = (prevY + y) / 2
|
||||
|
||||
// Vary the curve intensity
|
||||
const curveIntensity = 8 + (i % 3) * 3 // Varies between 8, 11, 14
|
||||
const controlX1 = 50 + (i % 2 === 0 ? -curveIntensity : curveIntensity)
|
||||
const controlX2 = 50 + (i % 2 === 0 ? curveIntensity : -curveIntensity)
|
||||
|
||||
pathString += ` C ${controlX1} ${midY}, ${controlX2} ${midY}, ${x} ${y}`
|
||||
}
|
||||
}
|
||||
|
||||
return pathString
|
||||
})()}
|
||||
stroke="url(#timelineGradient)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
{/* Draw dots at each point */}
|
||||
{entries.map((entry, i) => {
|
||||
const totalEntries = entries.length
|
||||
const cardSpacing = 64
|
||||
const topPadding = 16
|
||||
const dotOffset = 40
|
||||
|
||||
const actualPixels = topPadding + (i * (cardSpacing + 80)) + dotOffset
|
||||
const containerHeight = topPadding + ((totalEntries - 1) * (cardSpacing + 80)) + dotOffset + 20
|
||||
const y = (actualPixels / containerHeight) * 100
|
||||
const x = 50
|
||||
|
||||
const isBirth = i === birthIndex
|
||||
const isDeath = i === deathIndex
|
||||
const isSpecial = isBirth || isDeath
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isSpecial ? 1.4 : 0.9}
|
||||
fill="rgb(196, 160, 74)"
|
||||
stroke="white"
|
||||
strokeWidth="0.4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
<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 straight line */}
|
||||
{/* 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 */}
|
||||
@@ -174,9 +74,9 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className={`relative flex items-start ${
|
||||
isLeft
|
||||
? 'sm:flex-row flex-row sm:pr-[52%]'
|
||||
: 'sm:flex-row-reverse flex-row sm:pl-[52%]'
|
||||
isLeft
|
||||
? 'pl-10 sm:pl-0 sm:pr-[52%]'
|
||||
: 'pl-10 sm:pl-[52%]'
|
||||
}`}
|
||||
>
|
||||
{/* Content Card */}
|
||||
@@ -187,7 +87,7 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
|
||||
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={{ maxWidth: isSpecial ? '400px' : '360px' }}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{/* Photos */}
|
||||
{photos.length > 0 && (
|
||||
@@ -221,9 +121,9 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description preview */}
|
||||
{/* Description — no clamp, card grows with content */}
|
||||
{entry.description && (
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs leading-relaxed line-clamp-2">
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs leading-relaxed">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -241,14 +141,25 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
|
||||
</p>
|
||||
</motion.button>
|
||||
|
||||
{/* Dot - Mobile only (Desktop dots are in SVG) */}
|
||||
<div className="sm:hidden absolute left-5 top-6 -translate-x-1/2">
|
||||
<div className={`rounded-full border-2 border-white ${
|
||||
isSpecial
|
||||
? 'w-5 h-5 bg-warm-gold ring-2 ring-warm-gold/20'
|
||||
: entry.source === 'community'
|
||||
? 'w-3 h-3 bg-amber-400'
|
||||
: 'w-3 h-3 bg-warm-gold'
|
||||
{/* 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>
|
||||
|
||||
Reference in New Issue
Block a user