9223a2bfbb
- Remove duplicate FamilyUploadSection from public page - Remove 'Von Anonym' caption from user-uploaded gallery photos - Add SHA-256 duplicate detection in upload route (same file → same path) - Fix timeline photos: use object-contain instead of object-cover (no clipping) - Fix timeline modal photos: remove fixed h-48 height - Add photo display support to MemorySection component - Include media_filenames in memory contribution queries - Add media_filenames to Memory type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import { getDb } from '@/lib/db'
|
|
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 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 RecipeSection from '@/components/RecipeSection'
|
|
import RecipeUploadSection from '@/components/RecipeUploadSection'
|
|
|
|
export const revalidate = 10 // Revalidate every 10 seconds
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
// node:sqlite returns null-prototype objects; convert to plain objects for Client Components
|
|
function plain<T>(rows: unknown[]): T[] {
|
|
return JSON.parse(JSON.stringify(rows))
|
|
}
|
|
|
|
export default async function HomePage() {
|
|
const db = getDb()
|
|
|
|
const photos = plain<MediaItem>(
|
|
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' AND status = 'approved' ORDER BY sort_order, created_at DESC").all()
|
|
)
|
|
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, media_filenames, 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,
|
|
author: m.name || null,
|
|
media_filenames: m.media_filenames || null,
|
|
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,
|
|
filename,
|
|
original_name: null,
|
|
type: 'photo' as const,
|
|
caption: 'Aus dem Zeitstrahl',
|
|
sort_order: 9999,
|
|
status: 'approved' as const,
|
|
created_at: new Date().toISOString(),
|
|
}))
|
|
|
|
// Fetch approved media contributions (user-uploaded photos)
|
|
let mediaContribPhotos: MediaItem[] = []
|
|
try {
|
|
const mediaContribs = plain(
|
|
db.prepare("SELECT id, media_filenames, name, created_at FROM contributions WHERE status = 'approved' AND media_filenames IS NOT NULL AND media_filenames != ''").all()
|
|
)
|
|
mediaContribPhotos = mediaContribs.flatMap((c: any) =>
|
|
c.media_filenames.split(',').filter(Boolean).map((filename: string, i: number) => ({
|
|
id: 998000 + c.id * 10 + i,
|
|
filename: filename.trim(),
|
|
original_name: null,
|
|
type: 'photo' as const,
|
|
caption: null,
|
|
sort_order: 9998,
|
|
status: 'approved' as const,
|
|
created_at: c.created_at,
|
|
}))
|
|
)
|
|
} catch {}
|
|
|
|
// Merge all photos
|
|
const allPhotos = [...photos, ...timelinePhotos, ...mediaContribPhotos]
|
|
|
|
const recipes = plain<Recipe>(
|
|
db.prepare('SELECT * FROM recipes ORDER BY sort_order, title').all()
|
|
)
|
|
|
|
return (
|
|
<main className="min-h-screen bg-cream">
|
|
{/* Hero */}
|
|
<HeroSection heroPhoto={photos[0]?.filename ?? null} />
|
|
|
|
{/* 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
|
|
</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>
|
|
{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>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Personal tribute */}
|
|
<TributeSection />
|
|
|
|
{/* Candles */}
|
|
<CandleSection />
|
|
|
|
{/* Timeline */}
|
|
<TimelineSection entries={combinedTimeline} />
|
|
|
|
{/* Timeline Upload */}
|
|
<TimelineUploadSection />
|
|
|
|
{/* Photos */}
|
|
{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">
|
|
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
|
Erinnerungen in Bildern
|
|
</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>
|
|
</div>
|
|
{allPhotos.length > 1 && <PhotoSlideshow photos={allPhotos} />}
|
|
<PhotoGallery photos={allPhotos} />
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Photo Upload */}
|
|
<PhotoUploadSection />
|
|
|
|
{/* Memories */}
|
|
<section id="erinnerungen">
|
|
<MemorySection memories={combinedMemories} />
|
|
</section>
|
|
|
|
{/* Memory Upload */}
|
|
<MemoryUploadSection />
|
|
|
|
{/* Videos */}
|
|
<VideoGallery videos={videos} />
|
|
|
|
{/* 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">
|
|
„Du bist nicht fort, nur ein Schritt voraus."
|
|
</p>
|
|
|
|
<div className="flex items-center justify-center gap-3 mt-5">
|
|
<div className="h-px w-8 bg-warm-gold/20" />
|
|
<span className="text-warm-gold/30 text-xs">✦</span>
|
|
<div className="h-px w-8 bg-warm-gold/20" />
|
|
</div>
|
|
|
|
<div className="mt-6 flex items-center justify-center gap-5">
|
|
<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>
|
|
</div>
|
|
</footer>
|
|
</main>
|
|
)
|
|
}
|