feat: complete memorial website features

- Add user contribution system (memories, timeline entries)
- Add AI content moderation with Ollama (bad word detection + qwen3:4b)
- Add family photo/video upload with admin approval
- Add candle lighting feature
- Add timeline and recipe sections
- Add QR code page and OG image
- Add site authentication (password-protected access)
- Add proxy middleware for auth routing
- Add admin dashboard for content management
- Remove email fields, make name optional (default: Anonym)
- Add CI/CD pipeline for Gitea Actions
- Add Docker deployment configuration
- Optimize Ollama RAM usage (42GB → 2.9GB)
- Fix API routes accessibility through proxy middleware

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
denshooter
2026-02-18 12:20:33 +01:00
parent 43e9d49620
commit a34d406375
54 changed files with 5989 additions and 248 deletions
+342
View File
@@ -0,0 +1,342 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { MapPin, X, Calendar, User } from 'lucide-react'
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[]
}
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)
// 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" 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>
)}
{/* Mobile straight 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
? 'sm:flex-row flex-row sm:pr-[52%]'
: 'sm:flex-row-reverse flex-row 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={{ maxWidth: isSpecial ? '400px' : '360px' }}
>
{/* 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 h-24 object-cover rounded-lg"
/>
))}
</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 preview */}
{entry.description && (
<p className="font-lora text-warm-brown-light/60 text-xs leading-relaxed line-clamp-2">
{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 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'
}`} />
</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 h-48 object-cover rounded-lg"
/>
))}
</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>
)
}