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:
denshooter
2026-02-21 23:20:48 +01:00
parent 6e2ef55cee
commit bf070ec47f
14 changed files with 1997 additions and 649 deletions
+80
View File
@@ -0,0 +1,80 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Memorial website for Maria Malejka (19452026), built in German. Production-deployed at `maria-malejka.de`.
## Commands
```bash
# Development
npm run dev # Start dev server (with Turbopack)
npm run build # Production build
npm run start # Start production server
# Docker (local development)
docker compose up # Start with Docker Compose
docker compose build # Rebuild Docker image
```
No linting or test scripts are configured.
## Environment Variables
```
ADMIN_PASSWORD= # Admin dashboard password (SHA256 hashed)
DATA_DIR=/data # SQLite database + uploads location
NEXT_PUBLIC_URL= # Public site URL (used for OG tags, QR codes)
OLLAMA_URL= # AI moderation endpoint (default: http://localhost:11434)
```
## Architecture
### Tech Stack
- **Next.js** (App Router) + **React 18** + **TypeScript**
- **SQLite** via Node.js built-in `node:sqlite` (no ORM)
- **Tailwind CSS** with custom warm-toned palette (`cream`, `warm-brown`, `warm-gold`)
- **Framer Motion** for animations
- **Ollama** (llama3.2) for AI content moderation
### Data Layer
Database singleton in `src/lib/db.ts` (`getDb()`). All API routes use `export const runtime = 'nodejs'` to access SQLite and the filesystem.
Key tables: `memories`, `media`, `candles`, `timeline`, `contributions`, `recipes`.
The `contributions` table is the user submission queue — content flows through `pending → approved/rejected/flagged`. Status `flagged` means the auto-moderator caught something but admin review is needed.
### Content Moderation Pipeline (`/api/contributions` POST)
1. Hardcoded German bad-word list → auto-flag
2. Ollama AI review (fire-and-forget, 15s timeout, very lenient prompt) → flag if rejected
3. Clean content → immediately `approved`
### File Uploads
`/api/upload` handles photos, videos, and audio. SHA256 deduplication prevents duplicate files. Files are stored in `$DATA_DIR/uploads/{photos,videos,music}/` with UUID filenames. The `/api/files/[...path]` route serves them with 1-year immutable cache headers.
### Home Page Data Fetching (`src/app/page.tsx`)
Server component with `dynamic = 'force-dynamic'` and `revalidate = 10`. Fetches all data server-side and merges:
- Admin memories + approved memory contributions
- Official timeline + approved timeline contributions
- Photos from media table + timeline entries + photo contributions (deduplicated by filename)
### Admin Auth
Cookie `admin_auth` with SHA256(password). Routes at `/api/auth` (GET/POST/DELETE). 30-day expiry.
### Styling Conventions
- Fonts: `font-cormorant` (headings, serif/italic) and `font-lora` (body)
- All UI text is in German
- Custom Tailwind colors: `cream`, `warm-brown`, `warm-brown-light`, `warm-gold`, `warm-gold-light`, `warm-border`
- Animations: `animate-fade-in`, `animate-float` (defined in tailwind.config.ts)
### Deployment
Docker multi-stage build → standalone Next.js output. The container runs as non-root user `nextjs` (uid 1001). Data persisted via volume at `/app/data`. Deployed behind a reverse proxy (no external port exposure). CI/CD via Gitea Actions (`.gitea/workflows/`).
+1361 -1
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -5,7 +5,9 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@types/qrcode": "^1.5.6",
@@ -23,6 +25,7 @@
"autoprefixer": "^10",
"postcss": "^8",
"tailwindcss": "^3.4.4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.18"
}
}
+21 -2
View File
@@ -1,13 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { cookies } from 'next/headers'
import { createHash } from 'crypto'
export const runtime = 'nodejs'
// PUT: Update a candle
async function isAdmin() {
const cookieStore = await cookies()
const token = cookieStore.get('admin_auth')?.value
const expected = createHash('sha256')
.update(process.env.ADMIN_PASSWORD || 'change-me')
.digest('hex')
return token === expected
}
// PUT: Update a candle (admin only)
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!await isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const { name, message } = await req.json()
const db = getDb()
@@ -18,11 +33,15 @@ export async function PUT(
return NextResponse.json(candle)
}
// DELETE: Remove a candle
// DELETE: Remove a candle (admin only)
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!await isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const db = getDb()
+22 -1
View File
@@ -1,14 +1,31 @@
import { NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { cookies } from 'next/headers'
import { createHash } from 'crypto'
async function isAdmin() {
const cookieStore = await cookies()
const token = cookieStore.get('admin_auth')?.value
const expected = createHash('sha256')
.update(process.env.ADMIN_PASSWORD || 'change-me')
.digest('hex')
return token === expected
}
export const runtime = 'nodejs'
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!await isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const body = await request.json()
const db = getDb()
// Check if only updating status
@@ -49,6 +66,10 @@ export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!await isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const db = getDb()
+4
View File
@@ -76,6 +76,10 @@ const FOLDER_TO_TYPE: Record<string, 'photo' | 'video' | 'music'> = {
}
export async function POST(req: NextRequest) {
if (!await isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await req.formData()
const files = formData.getAll('files') as File[]
const singleFile = formData.get('file') as File | null
+5
View File
@@ -40,6 +40,11 @@
100% { background-position: 0 0; }
}
/* Hide scrollbar utility */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 5px;
+41 -38
View File
@@ -173,39 +173,42 @@ 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-5xl mx-auto px-4 py-3 flex items-center justify-center gap-4 sm:gap-6 flex-wrap text-center">
<a href="#ueber-oma" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Über Oma
</a>
<a href="#kerzen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Kerzen
</a>
{timeline.length > 0 && (
<a href="#zeitstrahl" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Zeitstrahl
{/* On mobile: single scrollable row. On desktop: centered wrapping row. */}
<div className="overflow-x-auto scrollbar-hide" style={{ scrollbarWidth: 'none', WebkitOverflowScrolling: 'touch' } as React.CSSProperties}>
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center gap-5 sm:gap-6 sm:justify-center sm:flex-wrap whitespace-nowrap">
<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
<a href="#kerzen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Kerzen
</a>
)}
<a href="#erinnerungen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Erinnerungen
</a>
{videos.length > 0 && (
<a href="#videos" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Videos
{timeline.length > 0 && (
<a href="#zeitstrahl" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Zeitstrahl
</a>
)}
{photos.length > 0 && (
<a href="#bilder" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Bilder
</a>
)}
<a href="#erinnerungen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Erinnerungen
</a>
)}
{recipes.length > 0 && (
<a href="#rezepte" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Rezepte
{videos.length > 0 && (
<a href="#videos" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Videos
</a>
)}
{recipes.length > 0 && (
<a href="#rezepte" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Rezepte
</a>
)}
<a href="#teilen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Teilen
</a>
)}
<a href="#teilen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Teilen
</a>
</div>
</div>
</nav>
@@ -274,22 +277,22 @@ export default async function HomePage() {
<div className="h-px w-8 bg-warm-gold/20" />
</div>
<div className="mt-6 flex items-center justify-center gap-5">
<div className="mt-6 flex justify-center">
<a
href="/impressum"
className="text-warm-brown-light/35 hover:text-warm-brown-light/65 text-xs font-lora tracking-wider transition-colors duration-200"
>
Impressum
</a>
<span className="text-warm-border/40 text-xs">·</span>
<a
href="/admin"
className="text-warm-border/25 hover:text-warm-border/50 text-xs transition-colors"
title="Verwaltung"
>
·
</a>
</div>
{/* Hidden admin entry point — no visible content so it doesn't affect layout */}
<a
href="/admin"
aria-hidden="true"
tabIndex={-1}
className="opacity-0 absolute pointer-events-none select-none"
title="Verwaltung"
>·</a>
</div>
</footer>
</main>
+176 -418
View File
@@ -2,7 +2,8 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Flame } from 'lucide-react'
import { X } from 'lucide-react'
import { sp, relativeTime } from '@/lib/utils'
type CandleData = {
id: number
@@ -10,410 +11,201 @@ type CandleData = {
created_at: string
}
function relativeTime(created_at: string): string {
const now = Date.now()
const created = new Date(created_at + 'Z').getTime()
const diffMs = now - created
const minutes = Math.floor(diffMs / 60000)
const hours = Math.floor(diffMs / 3600000)
const days = Math.floor(diffMs / 86400000)
/**
* Animated candle flame.
*
* KEY FIX: Do NOT use `style={{ transform: 'translateX(-50%)' }}` on motion.div
* elements that also have animated transform props (scaleX, scaleY, x).
* Framer Motion overwrites the entire `transform` property when it applies
* animations, so the centering is lost. Instead, calculate `left` positions
* explicitly and use `left: 0` for elements that are the same width as the
* container.
*/
function CandleFlame({ id, size = 1 }: { id: number; size?: number }) {
const dur = 1.8 + sp(id, 8) * 0.7 // 1.82.5 s
const dur2 = dur + 0.35
if (minutes < 1) return 'gerade eben angezündet'
if (minutes < 60) return `brennt seit ${minutes} ${minutes === 1 ? 'Minute' : 'Minuten'}`
if (hours < 24) return `brennt seit ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
return `brennt seit ${days} ${days === 1 ? 'Tag' : 'Tagen'}`
}
function CandleFlame({ size = 1, delay = 0 }: { size?: number; delay?: number }) {
const flameW = 16 * size
const flameH = 24 * size
const w = Math.round(18 * size)
const h = Math.round(28 * size)
const coreW = Math.round(8 * size)
const coreH = Math.round(14 * size)
const coreLeft = Math.round((w - coreW) / 2) // centres narrower core
const coreBot = Math.round(4 * size)
const dx = size // px — flicker x-amplitude scales with size
return (
<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,
}}
>
<div
<div style={{ position: 'relative', width: w, height: h }}>
{/* Outer flame — same width as container → left:0, no translateX needed */}
<motion.div
animate={{
scaleX: [1, 0.82, 1.10, 0.86, 1.06, 1],
scaleY: [1, 1.09, 0.92, 1.10, 0.95, 1],
x: [0, dx * 0.9, -dx * 0.7, dx * 1.2, -dx * 0.4, 0],
}}
transition={{ duration: dur, repeat: Infinity, ease: 'easeInOut' }}
style={{
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
width: flameW * 1.3,
height: flameH * 1.3,
left: 0,
width: w,
height: h,
background:
'radial-gradient(ellipse at 50% 80%, rgba(255,180,40,0.18) 0%, transparent 70%)',
borderRadius: '50%',
filter: 'blur(6px)',
'radial-gradient(ellipse at 50% 88%, rgba(255,200,55,0.97) 0%, rgba(255,108,8,0.83) 46%, rgba(168,42,0,0.36) 74%, transparent 100%)',
borderRadius: '50% 50% 35% 35% / 60% 60% 40% 40%',
filter: `blur(${0.6 * size}px)`,
transformOrigin: 'bottom center',
}}
/>
<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(0.8px)',
{/* Inner bright core — narrower, so we offset left by (w-coreW)/2 */}
<motion.div
animate={{
scaleX: [1, 0.88, 1.09, 0.91, 1],
scaleY: [1, 1.06, 0.94, 1.07, 1],
}}
/>
<div
transition={{ duration: dur2, repeat: Infinity, ease: 'easeInOut' }}
style={{
position: 'absolute',
bottom: 2,
left: '50%',
transform: 'translateX(-50%)',
width: flameW * 0.45,
height: flameH * 0.55,
bottom: coreBot,
left: coreLeft,
width: coreW,
height: coreH,
background:
'radial-gradient(ellipse at 50% 100%, rgba(255,255,220,0.95) 0%, rgba(255,230,80,0.7) 50%, transparent 100%)',
'radial-gradient(ellipse at 50% 100%, rgba(255,255,230,0.98) 0%, rgba(255,232,80,0.84) 55%, transparent 100%)',
borderRadius: '50% 50% 40% 40%',
transformOrigin: 'bottom center',
}}
/>
</motion.div>
</div>
)
}
function SingleCandle({ candle, index }: { candle: CandleData; index: number }) {
const seed = candle.id * 7919
const heightVariant = ((seed % 7) / 6) * 0.3 + 0.85 // 0.85 to 1.15 (subtler)
const hueShift = (seed % 14) - 7 // -7 to +7
const brightnessShift = ((seed % 11) - 5) / 100
const rotation = ((seed % 7) - 3) / 3 // -1 to +1 degrees (subtler)
const createdTime = new Date(candle.created_at + 'Z').getTime()
const now = Date.now()
const ageInHours = (now - createdTime) / (1000 * 60 * 60)
const burnProgress = Math.min(ageInHours / 24, 0.4)
const candleHeight = 72 * heightVariant * (1 - burnProgress)
const candleWidth = 32
const flameSize = 0.95
const delay = (index % 7) * 0.15
const id = candle.id
const heightScale = 0.80 + sp(id, 1) * 0.38
const hue = 32 + Math.round(sp(id, 2) * 12)
const tilt = sp(id, 3) * 3 - 1.5
const drip1Pos = 18 + Math.round(sp(id, 4) * 28)
const drip1H = 12 + Math.round(sp(id, 5) * 16)
const drip2Pos = 55 + Math.round(sp(id, 6) * 18)
const drip2H = 7 + Math.round(sp(id, 7) * 11)
const bodyH = Math.round(62 * heightScale)
const bodyW = 22
return (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.8 }}
initial={{ opacity: 0, y: 18, scale: 0.75 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, delay: Math.min(index * 0.08, 1.2) }}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transform: `rotate(${rotation}deg)`,
filter: `brightness(${1 + brightnessShift})`,
width: 72,
}}
className="group relative"
transition={{ duration: 0.5, delay: Math.min(index * 0.07, 1.5), ease: 'backOut' }}
className="flex flex-col items-center"
style={{ width: 64, transform: `rotate(${tilt}deg)` }}
>
{/* Glow */}
<div
className="absolute -inset-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"
style={{
background: `radial-gradient(circle, rgba(255,180,40,${0.12 + brightnessShift}) 0%, transparent 70%)`,
filter: 'blur(12px)',
}}
/>
{/* Flame */}
<div className="relative z-10 mb-1">
<CandleFlame size={flameSize} delay={delay} />
{/* Flame + wick in a relative wrapper so the glow can be absolutely placed */}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{/* Ambient glow — absolutely behind the flame */}
<div
style={{
position: 'absolute',
top: -8,
left: -22,
right: -22,
bottom: 0,
borderRadius: '50%',
background:
'radial-gradient(ellipse at 50% 50%, rgba(255,175,35,0.30) 0%, transparent 68%)',
filter: 'blur(14px)',
pointerEvents: 'none',
}}
/>
{/* Flame */}
<div style={{ position: 'relative', zIndex: 1 }}>
<CandleFlame id={id} />
</div>
{/* Wick */}
<div
style={{
width: 1.5,
height: 5,
background: 'linear-gradient(to bottom, #2e1a08, #180e05)',
borderRadius: 1,
position: 'relative',
zIndex: 1,
}}
/>
</div>
{/* Wick */}
{/* Candle body */}
<div
style={{
width: 2,
height: 6,
background: 'linear-gradient(to bottom, #2b1a0a, #1a0f06)',
borderRadius: 1,
}}
/>
{/* Candle Body */}
<div
style={{
width: candleWidth,
height: candleHeight,
background: `linear-gradient(to bottom,
hsl(${35 + hueShift}, ${65 + hueShift}%, ${88 + brightnessShift * 10}%) 0%,
hsl(${32 + hueShift}, ${60 + hueShift}%, ${82 + brightnessShift * 10}%) 100%)`,
width: bodyW,
height: bodyH,
background: `linear-gradient(108deg,
hsl(${hue}, 57%, 76%) 0%,
hsl(${hue}, 50%, 92%) 30%,
hsl(${hue}, 54%, 87%) 62%,
hsl(${hue}, 52%, 78%) 100%)`,
borderRadius: '2px 2px 4px 4px',
boxShadow: `
inset 2px 0 4px rgba(255,255,255,${0.4 + brightnessShift}),
inset -2px 0 6px rgba(0,0,0,${0.2 - brightnessShift}),
0 ${4 * heightVariant}px ${12 * heightVariant}px rgba(0,0,0,0.3)
inset 3px 0 5px rgba(255,255,255,0.52),
inset -3px 0 7px rgba(0,0,0,0.11),
0 5px 14px rgba(0,0,0,0.32)
`,
position: 'relative',
overflow: 'hidden',
}}
>
{/* Wax drips */}
<div
style={{
position: 'absolute',
top: 0,
left: `${15 + (seed % 30)}%`,
width: '4px',
height: `${16 * heightVariant}px`,
background: `linear-gradient(to bottom,
hsl(${33 + hueShift}, ${60 + hueShift}%, ${82 + brightnessShift * 10}%) 0%,
hsl(${33 + hueShift}, ${55 + hueShift}%, ${85 + brightnessShift * 10}%) 50%,
transparent 100%)`,
borderRadius: '0 0 2px 2px',
position: 'absolute', top: 0,
left: `${drip1Pos}%`,
width: 4, height: drip1H,
background: `linear-gradient(to bottom, hsl(${hue}, 48%, 91%), transparent)`,
borderRadius: '0 0 3px 3px',
opacity: 0.85,
boxShadow: 'inset 1px 0 2px rgba(255,255,255,0.3)',
}}
/>
<div
style={{
position: 'absolute',
top: `${8 * (1 - burnProgress)}px`,
right: `${10 + ((seed * 3) % 25)}%`,
width: '3.5px',
height: `${20 * heightVariant}px`,
background: `linear-gradient(to bottom,
hsl(${34 + hueShift}, ${62 + hueShift}%, ${80 + brightnessShift * 10}%) 0%,
hsl(${34 + hueShift}, ${58 + hueShift}%, ${83 + brightnessShift * 10}%) 60%,
transparent 100%)`,
position: 'absolute', top: 0,
left: `${drip2Pos}%`,
width: 3, height: drip2H,
background: `linear-gradient(to bottom, hsl(${hue}, 46%, 89%), transparent)`,
borderRadius: '0 0 2px 2px',
opacity: 0.75,
boxShadow: 'inset -1px 0 2px rgba(255,255,255,0.2)',
}}
/>
<div
style={{
position: 'absolute',
top: `${4 * (1 - burnProgress)}px`,
left: `${45 + (seed % 20)}%`,
width: '2px',
height: `${10 * heightVariant}px`,
background: `linear-gradient(to bottom,
hsl(${35 + hueShift}, ${58 + hueShift}%, ${84 + brightnessShift * 10}%),
transparent)`,
borderRadius: '0 0 1px 1px',
opacity: 0.6,
opacity: 0.62,
}}
/>
</div>
{/* Name Label */}
{/* Name */}
<p
className="text-amber-200/60 font-cormorant italic mt-2 text-center leading-tight truncate"
className="font-cormorant italic text-center leading-tight"
style={{
fontSize: '11px',
textShadow: '0 0 8px rgba(196,160,74,0.15)',
maxWidth: '68px',
marginTop: 6,
color: 'rgba(255, 215, 140, 0.65)',
maxWidth: 60,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{candle.name}
</p>
<p
className="text-amber-200/30 font-lora text-center leading-tight"
style={{
fontSize: '7px',
marginTop: '2px',
}}
>
<p className="font-lora text-center" style={{ fontSize: '9px', marginTop: 2, color: 'rgba(255,200,120,0.3)' }}>
{relativeTime(candle.created_at)}
</p>
</motion.div>
)
}
function BurningNote({ message, onComplete }: { message: string; onComplete: () => void }) {
useEffect(() => {
const timer = setTimeout(onComplete, 4000)
return () => clearTimeout(timer)
}, [onComplete])
return (
<motion.div
initial={{ opacity: 1 }}
className="flex flex-col items-center gap-6"
>
{/* Paper with realistic texture and burning */}
<div className="relative" style={{ width: 280, height: 360 }}>
<motion.div
className="relative w-full h-full overflow-visible"
style={{
background: `
linear-gradient(135deg, #f9f3e6 0%, #f5ead8 25%, #f0e4ca 50%, #ebe0c4 75%, #e6d9b8 100%)
`,
boxShadow: '0 8px 30px rgba(0,0,0,0.4), inset 2px 2px 6px rgba(0,0,0,0.05)',
borderRadius: '2px',
position: 'relative',
}}
animate={{
opacity: [1, 1, 0.7, 0],
scale: [1, 1, 0.95, 0.85],
}}
transition={{ duration: 4, times: [0, 0.6, 0.85, 1] }}
>
{/* Paper texture lines */}
<div className="absolute inset-0 opacity-10" style={{
backgroundImage: `repeating-linear-gradient(
0deg,
transparent,
transparent 25px,
rgba(139, 69, 19, 0.1) 25px,
rgba(139, 69, 19, 0.1) 26px
)`
}} />
{/* Fire spreading from bottom */}
<motion.div
className="absolute inset-0"
style={{
background: `
radial-gradient(ellipse at 50% 100%,
rgba(255, 100, 0, 0.9) 0%,
rgba(255, 69, 0, 0.8) 15%,
rgba(220, 20, 0, 0.6) 30%,
rgba(139, 0, 0, 0.4) 50%,
rgba(70, 0, 0, 0.2) 70%,
transparent 85%
)
`,
mixBlendMode: 'multiply',
}}
initial={{ clipPath: 'inset(100% 0 0 0)' }}
animate={{ clipPath: 'inset(0% 0 0 0)' }}
transition={{ duration: 3.5, ease: 'easeIn' }}
/>
{/* Burning edges effect */}
<motion.div
className="absolute inset-0"
style={{
background: 'radial-gradient(circle at 50% 100%, rgba(50,20,0,0.8) 0%, transparent 60%)',
}}
initial={{ opacity: 0, y: '100%' }}
animate={{ opacity: [0, 1, 1, 0], y: ['100%', '0%', '-20%', '-40%'] }}
transition={{ duration: 3.5, times: [0, 0.3, 0.7, 1] }}
/>
{/* Orange glow */}
<motion.div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 50% 100%, rgba(255,140,0,0.6) 0%, transparent 60%)',
filter: 'blur(15px)',
}}
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.8, 0.9, 0] }}
transition={{ duration: 3.5 }}
/>
{/* Message text */}
<div className="relative z-10 p-8 h-full flex flex-col">
<motion.div
className="font-cormorant text-warm-brown/90 text-base leading-relaxed whitespace-pre-wrap"
animate={{ opacity: [1, 1, 0.3, 0] }}
transition={{ duration: 3.5, times: [0, 0.5, 0.8, 1] }}
>
{message}
</motion.div>
</div>
</motion.div>
{/* Ash particles rising */}
{Array.from({ length: 25 }).map((_, i) => {
const delay = 0.8 + Math.random() * 2
const xOffset = (Math.random() - 0.5) * 100
const rotation = Math.random() * 360
return (
<motion.div
key={i}
className="absolute"
style={{
width: Math.random() * 6 + 3,
height: Math.random() * 6 + 3,
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
background: i % 4 === 0 ? '#2a1810' : i % 4 === 1 ? '#3d2619' : i % 4 === 2 ? '#FFB347' : '#FF6B00',
bottom: Math.random() * 40,
left: `${30 + Math.random() * 40}%`,
filter: 'blur(1px)',
}}
animate={{
y: [-10, -180 - Math.random() * 120],
x: [0, xOffset],
opacity: [0, 0.8, 0.6, 0],
scale: [1, 0.8, 0.4, 0],
rotate: [0, rotation],
}}
transition={{
duration: 2.5 + Math.random() * 1.5,
delay,
ease: 'easeOut',
}}
/>
)
})}
{/* Ember particles */}
{Array.from({ length: 15 }).map((_, i) => (
<motion.div
key={`ember-${i}`}
className="absolute"
style={{
width: 2 + Math.random() * 3,
height: 2 + Math.random() * 3,
borderRadius: '50%',
background: `rgba(255, ${100 + Math.random() * 100}, 0, 0.9)`,
bottom: 0,
left: `${20 + Math.random() * 60}%`,
boxShadow: `0 0 ${4 + Math.random() * 6}px rgba(255,140,0,0.8)`,
}}
animate={{
y: [0, -100 - Math.random() * 100],
x: [0, (Math.random() - 0.5) * 80],
opacity: [0, 1, 0.8, 0],
scale: [0.8, 1.2, 0.6, 0],
}}
transition={{
duration: 1.2 + Math.random() * 0.8,
delay: 0.5 + Math.random() * 2.5,
ease: 'easeOut',
}}
/>
))}
</div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2.8 }}
className="text-amber-200/60 text-sm font-cormorant italic"
>
Deine Nachricht verbrennt für Oma...
</motion.p>
</motion.div>
)
}
export default function CandleSection() {
const [candles, setCandles] = useState<CandleData[]>([])
const [showModal, setShowModal] = useState(false)
const [name, setName] = useState('')
const [message, setMessage] = useState('')
const [burning, setBurning] = useState(false)
const [done, setDone] = useState(false)
const [candles, setCandles] = useState<CandleData[]>([])
const [showModal, setShowModal] = useState(false)
const [name, setName] = useState('')
const [message, setMessage] = useState('')
const [done, setDone] = useState(false)
const [submitting, setSubmitting] = useState(false)
const loadCandles = useCallback(async () => {
@@ -423,61 +215,36 @@ export default function CandleSection() {
const data = await res.json()
setCandles(Array.isArray(data) ? data : [])
}
} catch {
// Ignore network errors
}
} catch { /* ignore */ }
}, [])
useEffect(() => {
loadCandles()
const interval = setInterval(loadCandles, 60000)
return () => clearInterval(interval)
const id = setInterval(loadCandles, 60000)
return () => clearInterval(id)
}, [loadCandles])
const handleSubmit = async () => {
if (!name.trim()) return
setSubmitting(true)
try {
const res = await fetch('/api/candles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), message: message.trim() || null }),
})
if (res.ok) {
// Only show burning animation if there's a message
if (message.trim()) {
setBurning(true)
} else {
// Skip burning, go straight to done
setDone(true)
setName('')
setMessage('')
loadCandles()
setTimeout(() => {
setDone(false)
setShowModal(false)
}, 2500)
}
setDone(true)
setName('')
setMessage('')
loadCandles()
setTimeout(() => { setDone(false); setShowModal(false) }, 3000)
}
} finally {
setSubmitting(false)
}
}
const handleBurnComplete = useCallback(() => {
setBurning(false)
setDone(true)
setName('')
setMessage('')
loadCandles()
setTimeout(() => {
setDone(false)
setShowModal(false)
}, 2500)
}, [loadCandles])
return (
<section
id="kerzen"
@@ -491,21 +258,19 @@ export default function CandleSection() {
transition={{ duration: 1.2 }}
className="text-center max-w-5xl mx-auto px-4"
>
{/* Header */}
<p
className="font-cormorant italic text-amber-200/40 text-2xl sm:text-3xl tracking-widest mb-3"
style={{ textShadow: '0 0 40px rgba(196,160,74,0.12)' }}
>
Ruhe in Frieden
</p>
<div className="flex items-center justify-center gap-4 mb-12">
<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>
{/* Candle Grid - with better spacing for many candles */}
{/* Candles */}
{candles.length > 0 && (
<div className="flex flex-wrap items-end justify-center gap-4 sm:gap-5 mb-12 max-w-4xl mx-auto">
{candles.map((candle, i) => (
@@ -514,14 +279,14 @@ export default function CandleSection() {
</div>
)}
{/* Light a candle button */}
{/* CTA — inline flame uses size prop, no transform tricks */}
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => setShowModal(true)}
className="inline-flex items-center gap-2.5 px-7 py-3.5 rounded-full border border-amber-400/20 bg-amber-900/20 hover:bg-amber-900/40 text-amber-200/70 hover:text-amber-200 transition-all duration-300 font-cormorant italic text-lg"
className="inline-flex items-end gap-2.5 px-7 py-3.5 rounded-full border border-amber-400/20 bg-amber-900/20 hover:bg-amber-900/40 text-amber-200/70 hover:text-amber-200 transition-all duration-300 font-cormorant italic text-lg"
>
<Flame size={18} className="text-amber-400/60" />
<CandleFlame id={0} size={0.65} />
Zünde eine Kerze für Oma an
</motion.button>
@@ -541,11 +306,7 @@ export default function CandleSection() {
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ backgroundColor: 'rgba(6,3,4,0.92)' }}
onClick={(e) => {
if (e.target === e.currentTarget && !burning) {
setShowModal(false)
}
}}
onClick={(e) => { if (e.target === e.currentTarget && !done) setShowModal(false) }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -553,31 +314,35 @@ export default function CandleSection() {
exit={{ opacity: 0, scale: 0.95 }}
className="w-full max-w-md"
>
{burning ? (
<BurningNote message={message} onComplete={handleBurnComplete} />
) : done ? (
{done ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
className="text-center py-10"
>
<div className="flex justify-center mb-4">
<CandleFlame size={1.5} />
{/* Large confirmation flame */}
<div className="flex justify-center mb-6" style={{ height: 80 }}>
<CandleFlame id={42} size={2.2} />
</div>
<p className="text-amber-200/80 font-cormorant italic text-2xl">
<p className="text-amber-200/80 font-cormorant italic text-2xl mt-2">
Deine Kerze brennt jetzt für Oma
</p>
</motion.div>
) : (
<div className="bg-amber-950/60 backdrop-blur-sm rounded-2xl p-6 sm:p-8 border border-amber-800/20">
<div className="flex justify-center mb-6">
<CandleFlame size={1.2} />
<div className="flex items-end justify-between mb-6">
<CandleFlame id={7} size={0.9} />
<h3 className="text-amber-200/80 font-cormorant italic text-2xl flex-1 text-center px-3">
Eine Kerze für Oma
</h3>
<button
onClick={() => setShowModal(false)}
className="text-amber-200/30 hover:text-amber-200/60 transition-colors mb-1"
>
<X size={18} />
</button>
</div>
<h3 className="text-amber-200/80 font-cormorant italic text-2xl text-center mb-6">
Eine Kerze für Oma
</h3>
<div className="space-y-4">
<div>
<label className="block text-amber-200/40 text-xs font-lora mb-1.5 uppercase tracking-wider">
@@ -587,6 +352,7 @@ export default function CandleSection() {
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}
placeholder="z.B. Maria"
className="w-full px-4 py-3 rounded-xl bg-amber-900/30 border border-amber-700/20 text-amber-100 placeholder-amber-200/20 focus:outline-none focus:ring-2 focus:ring-amber-400/30 font-lora text-sm"
autoFocus
@@ -595,34 +361,26 @@ export default function CandleSection() {
<div>
<label className="block text-amber-200/40 text-xs font-lora mb-1.5 uppercase tracking-wider">
Deine Nachricht an Oma
Nachricht an Oma
<span className="normal-case tracking-normal text-amber-200/20 ml-1">(optional)</span>
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Was möchtest du Oma sagen..."
rows={4}
rows={3}
className="w-full px-4 py-3 rounded-xl bg-amber-900/30 border border-amber-700/20 text-amber-100 placeholder-amber-200/20 focus:outline-none focus:ring-2 focus:ring-amber-400/30 font-lora text-sm resize-none leading-relaxed"
/>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setShowModal(false)}
className="flex-1 py-3 rounded-xl border border-amber-700/20 text-amber-200/40 hover:text-amber-200/60 transition-colors font-lora text-sm"
>
Abbrechen
</button>
<button
onClick={handleSubmit}
disabled={!name.trim() || submitting}
className="flex-1 py-3 rounded-xl bg-amber-700/40 hover:bg-amber-700/60 disabled:opacity-40 disabled:cursor-not-allowed text-amber-100 transition-colors font-lora text-sm flex items-center justify-center gap-2"
>
<Flame size={14} />
{message.trim() ? 'Zettel verbrennen' : 'Kerze anzünden'}
</button>
</div>
<button
onClick={handleSubmit}
disabled={!name.trim() || submitting}
className="w-full py-3 rounded-xl bg-amber-700/40 hover:bg-amber-700/60 disabled:opacity-40 disabled:cursor-not-allowed text-amber-100 transition-colors font-cormorant italic text-lg flex items-end justify-center gap-2"
>
<CandleFlame id={3} size={0.55} />
Kerze anzünden
</button>
</div>
</div>
)}
+82 -63
View File
@@ -16,21 +16,19 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
const audioB = useRef<HTMLAudioElement>(null)
const activeRef = useRef<'A' | 'B'>('A')
const fadingRef = useRef(false)
// playingRef = true means we intend to be playing (not user-stopped)
// Even when temporarily paused by visibility change, this stays true.
const playingRef = useRef(false)
// userStoppedRef = true means the user explicitly clicked stop
const userStoppedRef = useRef(false)
const [userMuted, setUserMuted] = useState(false)
const [hasStarted, setHasStarted] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const getActive = useCallback(
() => (activeRef.current === 'A' ? audioA.current : audioB.current),
[],
)
const getInactive = useCallback(
() => (activeRef.current === 'A' ? audioB.current : audioA.current),
[],
)
const getActive = useCallback(() => activeRef.current === 'A' ? audioA.current : audioB.current, [])
const getInactive = useCallback(() => activeRef.current === 'A' ? audioB.current : audioA.current, [])
// ── Crossfade loop ────────────────────────────────────────────
// ── Crossfade ─────────────────────────────────────────────────
const crossfade = useCallback(() => {
if (fadingRef.current) return
fadingRef.current = true
@@ -43,14 +41,13 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
inp.volume = 0
inp.play().catch(() => {})
const startTime = performance.now()
const outStartVol = out.volume
const targetVol = userMuted ? 0 : VOLUME
const t0 = performance.now()
const outVol = out.volume
const step = () => {
const t = Math.min((performance.now() - startTime) / (CROSSFADE_DURATION * 1000), 1)
inp.volume = targetVol * t
out.volume = outStartVol * (1 - t)
const t = Math.min((performance.now() - t0) / (CROSSFADE_DURATION * 1000), 1)
inp.volume = VOLUME * t
out.volume = outVol * (1 - t)
if (t < 1) {
requestAnimationFrame(step)
} else {
@@ -60,18 +57,16 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
}
}
requestAnimationFrame(step)
}, [userMuted, getActive, getInactive])
}, [getActive, getInactive])
// Monitor for crossfade
// Monitor for near-end crossfade trigger
useEffect(() => {
if (!playingRef.current || !src) return
let id: number
const tick = () => {
const a = getActive()
if (a && a.duration) {
if (a.duration - a.currentTime <= TAIL_SKIP && !fadingRef.current) {
crossfade()
}
if (a?.duration && a.duration - a.currentTime <= TAIL_SKIP && !fadingRef.current) {
crossfade()
}
id = requestAnimationFrame(tick)
}
@@ -79,58 +74,85 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
return () => cancelAnimationFrame(id)
})
// Fallback loop
const handleEnded = useCallback(() => {
const a = getActive()
if (a) { a.currentTime = 0; a.play().catch(() => {}) }
}, [getActive])
// Sync volume on mute toggle
// ── Pause on hide (lock screen, app switch, tab switch) ───────
// On iOS Safari, visibilitychange fires when locking the screen.
// We pause the audio but keep playingRef=true so we resume on unlock.
useEffect(() => {
const vol = userMuted ? 0 : VOLUME
const pauseAll = () => {
audioA.current?.pause()
audioB.current?.pause()
}
const handleVisibilityChange = () => {
if (document.hidden) {
pauseAll()
} else if (playingRef.current && !userStoppedRef.current) {
// Page became visible again — resume if user didn't stop
getActive()?.play().catch(() => {})
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pagehide', pauseAll)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pagehide', pauseAll)
}
}, [getActive])
// ── Toggle play / stop ────────────────────────────────────────
const toggle = useCallback(() => {
const a = getActive()
const b = getInactive()
if (a && !fadingRef.current) a.volume = vol
if (b && !fadingRef.current && !b.paused) b.volume = vol
}, [userMuted, getActive, getInactive])
// ── Ensure playback is running ────────────────────────────────
// Called on any user interaction. Starts audio if not started yet.
const ensurePlaying = useCallback(() => {
if (playingRef.current) return
const a = audioA.current
if (!a) return
a.volume = userMuted ? 0 : VOLUME
a.play().then(() => {
playingRef.current = true
setHasStarted(true)
}).catch(() => {})
}, [userMuted])
// Try autoplay on mount
if (playingRef.current) {
// Stop
a.pause()
getInactive()?.pause()
fadingRef.current = false
playingRef.current = false
userStoppedRef.current = true
setIsPlaying(false)
} else {
// Start / resume
userStoppedRef.current = false
a.volume = VOLUME
a.play().then(() => {
playingRef.current = true
setIsPlaying(true)
}).catch(() => {})
}
}, [getActive, getInactive])
// ── Autoplay on mount ─────────────────────────────────────────
useEffect(() => {
if (!src) return
const a = audioA.current
if (!a) return
// Try to autoplay immediately (unmuted)
a.volume = userMuted ? 0 : VOLUME
a.volume = VOLUME
a.play().then(() => {
playingRef.current = true
setHasStarted(true)
setIsPlaying(true)
}).catch(() => {
// Blocked by browser — will start on first interaction
// Autoplay blocked — start on first scroll or touch (common on iOS)
const start = () => {
if (playingRef.current || userStoppedRef.current) return
a.volume = VOLUME
a.play().then(() => {
playingRef.current = true
setIsPlaying(true)
}).catch(() => {})
}
window.addEventListener('scroll', start, { once: true, passive: true })
window.addEventListener('touchstart', start, { once: true, passive: true })
})
// On any interaction, make sure audio is playing
const handler = () => ensurePlaying()
const events = ['click', 'touchstart', 'scroll', 'keydown'] as const
events.forEach((e) => window.addEventListener(e, handler, { once: true, passive: true }))
return () => {
events.forEach((e) => window.removeEventListener(e, handler))
}
}, [src, userMuted, ensurePlaying])
}, [src])
if (!track || !src) return null
@@ -140,14 +162,11 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
<audio ref={audioB} src={src} preload="auto" onEnded={handleEnded} />
<button
onClick={() => {
ensurePlaying()
setUserMuted((m) => !m)
}}
onClick={toggle}
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'}
title={isPlaying ? 'Musik stoppen' : 'Musik starten'}
>
{userMuted ? <VolumeX size={22} /> : <Volume2 size={22} />}
{isPlaying ? <Volume2 size={22} /> : <VolumeX size={22} />}
</button>
</>
)
+30 -119
View File
@@ -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>
+2 -5
View File
@@ -128,12 +128,9 @@ export default function TributeSection() {
Beerdigung am 19. Februar 2026
</p>
<a
href="/meine-oma"
className="inline-block mt-10 font-cormorant italic text-warm-brown-light/30 hover:text-warm-gold/60 text-sm transition-colors duration-300"
>
<span className="inline-block mt-10 font-cormorant italic text-warm-brown-light/30 text-sm">
Von Dennis
</a>
</span>
</motion.div>
</div>
</section>
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { sp, relativeTime, formatDate } from './utils'
// ──────────────────────────────────────────────────────────────
// sp — deterministic pseudo-random
// ──────────────────────────────────────────────────────────────
describe('sp()', () => {
it('always returns a value in [0, 1)', () => {
for (let id = 0; id < 100; id++) {
for (let salt = 0; salt < 10; salt++) {
const v = sp(id, salt)
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThan(1)
}
}
})
it('is deterministic — same inputs produce same output', () => {
expect(sp(42, 3)).toBe(sp(42, 3))
expect(sp(1, 1)).toBe(sp(1, 1))
})
it('different salts produce different values for the same id', () => {
// Won't always differ for every combination, but should for common ones
expect(sp(5, 0)).not.toBe(sp(5, 1))
expect(sp(5, 2)).not.toBe(sp(5, 3))
})
it('different ids produce different values for the same salt', () => {
expect(sp(1, 0)).not.toBe(sp(2, 0))
expect(sp(10, 4)).not.toBe(sp(11, 4))
})
})
// ──────────────────────────────────────────────────────────────
// relativeTime — German relative time string
// ──────────────────────────────────────────────────────────────
describe('relativeTime()', () => {
beforeEach(() => {
// Fix "now" to a specific point so tests are stable
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-02-21T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('returns "gerade eben" for timestamps less than 1 minute ago', () => {
const ts = '2026-02-21T11:59:30' // 30 seconds ago
expect(relativeTime(ts)).toBe('gerade eben')
})
it('returns minutes for timestamps under 1 hour ago', () => {
const ts = '2026-02-21T11:45:00' // 15 minutes ago
expect(relativeTime(ts)).toBe('vor 15 Min.')
})
it('returns hours for timestamps under 24 hours ago', () => {
const ts = '2026-02-21T09:00:00' // 3 hours ago
expect(relativeTime(ts)).toBe('vor 3 Std.')
})
it('returns "1 Tag" for exactly 1 day ago', () => {
const ts = '2026-02-20T12:00:00' // exactly 1 day ago
expect(relativeTime(ts)).toBe('vor 1 Tag')
})
it('returns plural "Tagen" for multiple days ago', () => {
const ts = '2026-02-18T12:00:00' // 3 days ago
expect(relativeTime(ts)).toBe('vor 3 Tagen')
})
it('handles timestamps that already have a Z suffix', () => {
const ts = '2026-02-21T11:00:00Z' // 1 hour ago (already UTC)
expect(relativeTime(ts)).toBe('vor 1 Std.')
})
})
// ──────────────────────────────────────────────────────────────
// formatDate — German date formatting
// ──────────────────────────────────────────────────────────────
describe('formatDate()', () => {
it('returns just the year when month and day are omitted', () => {
expect(formatDate('1945')).toBe('1945')
expect(formatDate('2026', null, null)).toBe('2026')
})
it('returns full month name + year when only month is given', () => {
expect(formatDate('1965', '3')).toBe('März 1965')
expect(formatDate('2000', '12')).toBe('Dezember 2000')
expect(formatDate('1990', '1')).toBe('Januar 1990')
})
it('returns abbreviated day.Month year when day and month are given', () => {
expect(formatDate('1945', '11', '29')).toBe('29. Nov 1945')
expect(formatDate('2026', '2', '10')).toBe('10. Feb 2026')
expect(formatDate('2000', '6', '1')).toBe('1. Juni 2000')
})
it('handles all 12 abbreviated month names correctly', () => {
const expected = ['Jan', 'Feb', 'März', 'Apr', 'Mai', 'Juni', 'Juli', 'Aug', 'Sept', 'Okt', 'Nov', 'Dez']
expected.forEach((name, i) => {
const month = String(i + 1)
expect(formatDate('2000', month, '1')).toBe(`1. ${name} 2000`)
})
})
it('handles all 12 full month names correctly', () => {
const expected = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
expected.forEach((name, i) => {
const month = String(i + 1)
expect(formatDate('2000', month)).toBe(`${name} 2000`)
})
})
})
+49
View File
@@ -0,0 +1,49 @@
/**
* Deterministic pseudo-random number in [0, 1) from id + salt.
* Using a simple LCG hash so values are identical on server and client
* (avoids React hydration mismatches from Math.random()).
*/
export function sp(id: number, salt: number): number {
const n = ((id * 9301 + salt * 49297 + 1) % 233280 + 233280) % 233280
return n / 233280
}
/**
* Human-readable relative time string (German) for a UTC timestamp.
* The `created_at` value from SQLite has no timezone suffix, so we append 'Z'.
*/
export function relativeTime(created_at: string): string {
const now = Date.now()
const created = new Date(created_at.endsWith('Z') ? created_at : created_at + 'Z').getTime()
const diffMs = now - created
const minutes = Math.floor(diffMs / 60000)
const hours = Math.floor(diffMs / 3600000)
const days = Math.floor(diffMs / 86400000)
if (minutes < 1) return 'gerade eben'
if (minutes < 60) return `vor ${minutes} Min.`
if (hours < 24) return `vor ${hours} Std.`
return `vor ${days} ${days === 1 ? 'Tag' : 'Tagen'}`
}
/**
* Formats a German date string from year/month/day components.
*/
export 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
}