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
+159 -13
View File
@@ -1,12 +1,21 @@
import { getDb } from '@/lib/db'
import type { Memory, MediaItem } from '@/lib/types'
import type { Memory, MediaItem, TimelineEntry, Recipe, TimelineContribution } from '@/lib/types'
import HeroSection from '@/components/HeroSection'
import PhotoSlideshow from '@/components/PhotoSlideshow'
import PhotoGallery from '@/components/PhotoGallery'
import MemorySection from '@/components/MemorySection'
import WriteSection from '@/components/WriteSection'
import VideoGallery from '@/components/VideoGallery'
import TributeSection from '@/components/TributeSection'
import CandleSection from '@/components/CandleSection'
import TimelineSection from '@/components/TimelineSection'
import TimelineUploadSection from '@/components/TimelineUploadSection'
import MemoryUploadSection from '@/components/MemoryUploadSection'
import PhotoUploadSection from '@/components/PhotoUploadSection'
import FamilyUploadSection from '@/components/FamilyUploadSection'
import RecipeSection from '@/components/RecipeSection'
import RecipeUploadSection from '@/components/RecipeUploadSection'
export const revalidate = 10 // Revalidate every 10 seconds
export const dynamic = 'force-dynamic'
@@ -19,14 +28,117 @@ export default async function HomePage() {
const db = getDb()
const photos = plain<MediaItem>(
db.prepare("SELECT * FROM media WHERE type = 'photo' ORDER BY sort_order, created_at").all()
db.prepare("SELECT * FROM media WHERE type = 'photo' AND status = 'approved' ORDER BY sort_order, created_at DESC").all()
)
const videos = plain<MediaItem>(
db.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at").all()
db.prepare("SELECT * FROM media WHERE type = 'video' AND status = 'approved' ORDER BY sort_order, created_at DESC").all()
)
const memories = plain<Memory>(
const memories = plain<Memory>(
db.prepare('SELECT * FROM memories ORDER BY created_at DESC').all()
)
// Fetch approved user contributions (memories)
let userMemories: any[] = []
try {
userMemories = plain(
db.prepare(`
SELECT id, name, title, content, created_at
FROM contributions
WHERE status = 'approved' AND type = 'memory'
ORDER BY created_at DESC
`).all()
)
} catch (err) {
console.error('Error fetching user memories:', err)
}
// Combine admin memories + approved user contributions
const combinedMemories = [
...memories,
...userMemories.map((m: any) => ({
id: m.id,
title: m.title || 'Erinnerung',
content: m.content,
created_at: m.created_at,
updated_at: m.created_at,
}))
]
const timeline = plain<TimelineEntry>(
db.prepare('SELECT * FROM timeline ORDER BY sort_order, year').all()
)
// Fetch approved timeline contributions
let contributions: any[] = []
try {
contributions = plain(
db.prepare("SELECT * FROM contributions WHERE status = 'approved' AND type = 'timeline' ORDER BY year, month, day").all()
)
} catch {
// Fallback to old table
try {
contributions = plain(
db.prepare("SELECT * FROM timeline_contributions WHERE status = 'approved' ORDER BY year, month, day").all()
)
} catch {}
}
// Collect all timeline photo filenames for the main gallery
const timelinePhotoFilenames = new Set<string>()
// Combine official timeline + community contributions
const combinedTimeline = [
...timeline.map(t => {
// Add timeline photos to set
if (t.media_filenames) {
t.media_filenames.split(',').forEach(f => timelinePhotoFilenames.add(f.trim()))
}
return { ...t, source: 'official' as const }
}),
...contributions.map((c: any) => {
// Add contribution photos to set
if (c.media_filenames) {
c.media_filenames.split(',').forEach((f: string) => timelinePhotoFilenames.add(f.trim()))
}
return {
id: c.id,
year: c.year,
month: c.month,
day: c.day,
title: c.title,
description: c.content || c.story || null,
location: c.location || null,
media_filenames: c.media_filenames || null,
sort_order: 0,
created_at: c.created_at,
source: 'community' as const,
contributorName: c.name,
}
})
].sort((a, b) => {
const dateA = parseInt(a.year) * 10000 + parseInt(a.month || '0') * 100 + parseInt(a.day || '0')
const dateB = parseInt(b.year) * 10000 + parseInt(b.month || '0') * 100 + parseInt(b.day || '0')
return dateA - dateB
})
// Create virtual MediaItem entries for timeline photos
const timelinePhotos: MediaItem[] = Array.from(timelinePhotoFilenames).map((filename, i) => ({
id: 999000 + i, // High ID to avoid conflicts
filename,
original_name: null,
type: 'photo' as const,
caption: 'Aus dem Zeitstrahl',
sort_order: 9999,
status: 'approved' as const,
created_at: new Date().toISOString(),
}))
// Merge with existing photos
const allPhotos = [...photos, ...timelinePhotos]
const recipes = plain<Recipe>(
db.prepare('SELECT * FROM recipes ORDER BY sort_order, title').all()
)
return (
<main className="min-h-screen bg-cream">
@@ -35,10 +147,18 @@ const memories = plain<Memory>(
{/* 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">
<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
</a>
)}
{photos.length > 0 && (
<a href="#bilder" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
Bilder
@@ -52,14 +172,31 @@ const memories = plain<Memory>(
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>
</div>
</nav>
{/* Personal tribute */}
<TributeSection />
{/* Candles */}
<CandleSection />
{/* Timeline */}
<TimelineSection entries={combinedTimeline} />
{/* Timeline Upload */}
<TimelineUploadSection />
{/* Photos */}
{photos.length > 0 && (
{allPhotos.length > 0 && (
<section id="bilder" className="py-16 sm:py-20">
<div className="max-w-6xl mx-auto px-4">
<div className="text-center mb-12">
@@ -72,24 +209,33 @@ const memories = plain<Memory>(
<div className="h-px w-16 bg-warm-gold/40" />
</div>
</div>
{photos.length > 1 && <PhotoSlideshow photos={photos} />}
<PhotoGallery photos={photos} />
{allPhotos.length > 1 && <PhotoSlideshow photos={allPhotos} />}
<PhotoGallery photos={allPhotos} />
</div>
</section>
)}
{/* Write */}
<WriteSection />
{/* Photo Upload */}
<PhotoUploadSection />
{/* Memories */}
<section id="erinnerungen">
<MemorySection memories={memories} />
<MemorySection memories={combinedMemories} />
</section>
{/* Memory Upload */}
<MemoryUploadSection />
{/* Videos */}
<VideoGallery videos={videos} />
{/* Footer */}
{/* Recipe section */}
{recipes.length > 0 && <RecipeSection recipes={recipes} />}
{/* Recipe Upload */}
<RecipeUploadSection />
{/* Footer placeholder */}
<footer className="py-12 text-center border-t border-warm-border bg-amber-50/30">
<div className="max-w-lg mx-auto px-4">
<p className="font-cormorant italic text-warm-brown-light/60 text-lg">