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:
+1119
-48
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// PUT: Update a candle
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const { name, message } = await req.json()
|
||||
const db = getDb()
|
||||
|
||||
db.prepare('UPDATE candles SET name = ?, message = ? WHERE id = ?').run(name, message || null, id)
|
||||
|
||||
const candle = db.prepare('SELECT * FROM candles WHERE id = ?').get(id)
|
||||
return NextResponse.json(candle)
|
||||
}
|
||||
|
||||
// DELETE: Remove a candle
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
||||
db.prepare('DELETE FROM candles WHERE id = ?').run(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET() {
|
||||
const db = getDb()
|
||||
const candles = db
|
||||
.prepare('SELECT id, name, created_at FROM candles ORDER BY created_at DESC')
|
||||
.all()
|
||||
return NextResponse.json(candles)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { name, message } = await req.json()
|
||||
if (!name?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name ist erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare('INSERT INTO candles (name, message) VALUES (?, ?)')
|
||||
.run(name.trim(), message?.trim() || null)
|
||||
const candle = db
|
||||
.prepare('SELECT id, name, created_at FROM candles WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
return NextResponse.json(candle, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// Check if only updating status
|
||||
if (Object.keys(body).length === 1 && 'status' in body) {
|
||||
db.prepare('UPDATE contributions SET status = ? WHERE id = ?').run(body.status, id)
|
||||
} else {
|
||||
// Full update
|
||||
const { name, email, type, year, month, day, title, content, location, media_filenames, status } = body
|
||||
|
||||
db.prepare(`
|
||||
UPDATE contributions
|
||||
SET name = ?, email = ?, type = ?, year = ?, month = ?, day = ?, title = ?, content = ?, location = ?, media_filenames = ?, status = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name,
|
||||
email || null,
|
||||
type,
|
||||
year || null,
|
||||
month || null,
|
||||
day || null,
|
||||
title || null,
|
||||
content || null,
|
||||
location || null,
|
||||
media_filenames || null,
|
||||
status || 'pending',
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating contribution:', error)
|
||||
return NextResponse.json({ error: 'Failed to update contribution' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
||||
db.prepare('DELETE FROM contributions WHERE id = ?').run(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting contribution:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete contribution' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// Simple bad word check
|
||||
function hasBadWords(text: string): { flag: boolean; reason?: string } {
|
||||
const lower = text.toLowerCase()
|
||||
const badWords = [
|
||||
{ word: 'hurensohn', reason: 'Beleidigung' },
|
||||
{ word: 'arschloch', reason: 'Beleidigung' },
|
||||
{ word: 'wichser', reason: 'Beleidigung' },
|
||||
{ word: 'fotze', reason: 'Beleidigung' },
|
||||
{ word: 'spam', reason: 'Spam-Verdacht' },
|
||||
{ word: 'werbung', reason: 'Werbung' },
|
||||
{ word: 'casino', reason: 'Werbung' },
|
||||
{ word: 'viagra', reason: 'Werbung' }
|
||||
]
|
||||
|
||||
for (const { word, reason } of badWords) {
|
||||
if (lower.includes(word)) {
|
||||
return { flag: true, reason }
|
||||
}
|
||||
}
|
||||
|
||||
return { flag: false }
|
||||
}
|
||||
|
||||
// Background AI moderation with Ollama
|
||||
async function moderateWithAI(contributionId: number, content: string) {
|
||||
console.log(`[AI-Mod] Starting for ${contributionId}`)
|
||||
|
||||
// Step 1: Instant bad word check
|
||||
const lowerCheck = content.toLowerCase()
|
||||
const badWords = ['hurensohn', 'arschloch', 'wichser', 'fotze']
|
||||
const foundBadWord = badWords.find(word => lowerCheck.includes(word))
|
||||
|
||||
if (foundBadWord) {
|
||||
console.log(`[AI-Mod] ⚠️ INSTANT FLAG: "${foundBadWord}" detected!`)
|
||||
const db = getDb()
|
||||
const flagResult = db.prepare(`
|
||||
UPDATE contributions
|
||||
SET status = 'flagged', moderation_reason = ?
|
||||
WHERE id = ?
|
||||
`).run(`Unangemessene Sprache: "${foundBadWord}"`, contributionId)
|
||||
console.log(`[AI-Mod] ✅ FLAGGED ${contributionId} instantly:`, flagResult.changes, 'rows')
|
||||
return // Done, no AI needed
|
||||
}
|
||||
|
||||
// Step 2: AI check for subtle issues
|
||||
console.log(`[AI-Mod] No bad words, asking AI...`)
|
||||
|
||||
try {
|
||||
const prompt = `Ist dieser Text unangemessen für eine Gedenkseite?
|
||||
|
||||
"${content}"
|
||||
|
||||
ERLAUBT: Liebe, Vermissen, Trauer
|
||||
VERBOTEN: Beleidigungen, Spam, Hassrede
|
||||
|
||||
Antworte NUR mit JSON (keine Erklärung):
|
||||
{"appropriate": true} oder {"appropriate": false, "reason": "..."}
|
||||
|
||||
JSON:`
|
||||
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => controller.abort(), 10000) // 10sec timeout
|
||||
|
||||
const res = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: 'qwen3:4b',
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.1,
|
||||
num_predict: 50,
|
||||
num_ctx: 256
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[AI-Mod] Ollama error: ${res.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const responseText = (data.response || '').trim()
|
||||
console.log(`[AI-Mod] Response: "${responseText}"`)
|
||||
|
||||
// Parse JSON
|
||||
let result: any = null
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[^}]+\}/)
|
||||
if (jsonMatch) {
|
||||
result = JSON.parse(jsonMatch[0])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[AI-Mod] Parse error:`, e)
|
||||
}
|
||||
|
||||
// Update DB if inappropriate
|
||||
if (result && result.appropriate === false) {
|
||||
const db = getDb()
|
||||
const updateResult = db.prepare(`
|
||||
UPDATE contributions
|
||||
SET status = 'flagged', moderation_reason = ?
|
||||
WHERE id = ?
|
||||
`).run(result.reason || 'KI-Warnung', contributionId)
|
||||
|
||||
console.log(`[AI-Mod] ✅ FLAGGED ${contributionId}:`, updateResult)
|
||||
} else {
|
||||
console.log(`[AI-Mod] ✅ Passed ${contributionId}`)
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[AI-Mod] Error for ${contributionId}:`, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, type, title, content, photoUrl, date, category } = body
|
||||
|
||||
if (!content || !type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Content and type are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 1. Check bad words instantly
|
||||
const badWordCheck = hasBadWords(content + ' ' + (title || ''))
|
||||
const initialStatus = badWordCheck.flag ? 'flagged' : 'pending'
|
||||
const moderationReason = badWordCheck.flag ? badWordCheck.reason : null
|
||||
|
||||
// 2. Insert contribution
|
||||
const result = db.prepare(`
|
||||
INSERT INTO contributions (name, type, title, content, status, moderation_reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
name || 'Anonym',
|
||||
type,
|
||||
title || null,
|
||||
content,
|
||||
initialStatus,
|
||||
moderationReason || null
|
||||
)
|
||||
|
||||
const contributionId = Number(result.lastInsertRowid)
|
||||
console.log(`[API] Created contribution ${contributionId}, status: ${initialStatus}`)
|
||||
|
||||
// 3. If not already flagged, run AI check in background
|
||||
if (!badWordCheck.flag) {
|
||||
// Fire and forget - don't await
|
||||
moderateWithAI(contributionId, content).catch(e =>
|
||||
console.error('[AI-Mod] Background error:', e)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: contributionId,
|
||||
message: 'Beitrag wurde gespeichert und wird geprüft'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[API] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create contribution' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
const db = getDb()
|
||||
|
||||
let query: string
|
||||
let params: string[] = []
|
||||
|
||||
if (status) {
|
||||
query = 'SELECT * FROM contributions WHERE status = ? ORDER BY created_at DESC'
|
||||
params = [status]
|
||||
} else {
|
||||
// Return ALL contributions (admin needs all, public will filter client-side)
|
||||
query = 'SELECT * FROM contributions ORDER BY created_at DESC'
|
||||
}
|
||||
|
||||
const contributions = db.prepare(query).all(...params)
|
||||
return NextResponse.json(contributions)
|
||||
} catch (error) {
|
||||
console.error('[API] Error fetching:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch contributions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { unlink } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
// PUT: Update upload status (approve/reject)
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const { status } = await req.json()
|
||||
|
||||
if (!['approved', 'pending'].includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
db.prepare('UPDATE media SET status = ? WHERE id = ?').run(status, id)
|
||||
|
||||
const updated = db.prepare('SELECT * FROM media WHERE id = ?').get(id)
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
|
||||
// DELETE: Remove upload (and file)
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
||||
const media = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as any
|
||||
if (!media) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Delete file
|
||||
try {
|
||||
const filePath = path.join(DATA_DIR, 'uploads', media.filename)
|
||||
await unlink(filePath)
|
||||
} catch {
|
||||
// File might not exist, continue
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
db.prepare('DELETE FROM media WHERE id = ?').run(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 60
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
const PHOTO_MIMES: Record<string, boolean> = {
|
||||
'image/jpeg': true,
|
||||
'image/jpg': true,
|
||||
'image/png': true,
|
||||
'image/webp': true,
|
||||
'image/gif': true,
|
||||
'image/heic': true,
|
||||
'image/heif': true,
|
||||
}
|
||||
|
||||
const VIDEO_MIMES: Record<string, boolean> = {
|
||||
'video/mp4': true,
|
||||
'video/quicktime': true,
|
||||
'video/x-msvideo': true,
|
||||
'video/webm': true,
|
||||
}
|
||||
|
||||
// GET: List all pending/approved family uploads
|
||||
export async function GET() {
|
||||
const db = getDb()
|
||||
const uploads = db
|
||||
.prepare("SELECT * FROM media WHERE status IN ('pending', 'approved') ORDER BY created_at DESC")
|
||||
.all()
|
||||
return NextResponse.json(uploads)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const name = formData.get('name') as string | null
|
||||
const email = formData.get('email') as string | null
|
||||
const relation = formData.get('relation') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Datei erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
let mimeType = file.type?.toLowerCase() || ''
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
|
||||
if (!mimeType && (ext === '.heic' || ext === '.heif')) {
|
||||
mimeType = 'image/heic'
|
||||
}
|
||||
|
||||
let type: 'photo' | 'video'
|
||||
let uploadDir: string
|
||||
|
||||
if (PHOTO_MIMES[mimeType]) {
|
||||
type = 'photo'
|
||||
uploadDir = 'photos'
|
||||
} else if (VIDEO_MIMES[mimeType]) {
|
||||
type = 'video'
|
||||
uploadDir = 'videos'
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nur Fotos und Videos erlaubt' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const filename = `${uploadDir}/${randomUUID()}${ext || '.bin'}`
|
||||
const filePath = path.join(DATA_DIR, 'uploads', filename)
|
||||
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
// Build caption with uploader info
|
||||
let caption = `Von ${(name || 'Anonym').trim()}`
|
||||
if (relation?.trim()) caption += ` (${relation.trim()})`
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare(
|
||||
"INSERT INTO media (filename, original_name, type, caption, status) VALUES (?, ?, ?, ?, 'pending')"
|
||||
)
|
||||
.run(filename, file.name, type, caption)
|
||||
|
||||
const media = db
|
||||
.prepare('SELECT * FROM media WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
|
||||
return NextResponse.json(media, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
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 async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { status } = await req.json()
|
||||
|
||||
if (status !== 'approved' && status !== 'rejected') {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
if (status === 'rejected') {
|
||||
// Delete the file and DB record
|
||||
const media = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as { filename: string } | undefined
|
||||
if (media) {
|
||||
const path = await import('path')
|
||||
const fs = await import('fs/promises')
|
||||
const DATA_DIR = path.default.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
try {
|
||||
await fs.unlink(path.default.join(DATA_DIR, 'uploads', media.filename))
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
db.prepare('DELETE FROM media WHERE id = ?').run(id)
|
||||
}
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
db.prepare("UPDATE media SET status = 'approved' WHERE id = ?").run(id)
|
||||
const updated = db.prepare('SELECT * FROM media WHERE id = ?').get(id)
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
@@ -5,15 +5,24 @@ export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const type = req.nextUrl.searchParams.get('type')
|
||||
const status = req.nextUrl.searchParams.get('status')
|
||||
const db = getDb()
|
||||
|
||||
const query = type
|
||||
? 'SELECT * FROM media WHERE type = ? ORDER BY sort_order, created_at'
|
||||
: 'SELECT * FROM media ORDER BY sort_order, created_at'
|
||||
let query = 'SELECT * FROM media WHERE 1=1'
|
||||
const queryParams: string[] = []
|
||||
|
||||
const media = type
|
||||
? db.prepare(query).all(type)
|
||||
: db.prepare(query).all()
|
||||
if (type) {
|
||||
query += ' AND type = ?'
|
||||
queryParams.push(type)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query += ' AND status = ?'
|
||||
queryParams.push(status)
|
||||
}
|
||||
|
||||
query += ' ORDER BY sort_order, created_at'
|
||||
|
||||
const media = db.prepare(query).all(...queryParams)
|
||||
return NextResponse.json(media)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Ollama Content Moderation API
|
||||
* Prüft Beiträge mit llama3.2:1b lokal
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { text, name, title } = await req.json()
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return NextResponse.json({ appropriate: true })
|
||||
}
|
||||
|
||||
// Ollama API aufrufen
|
||||
const ollamaResponse = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'llama3.2:1b',
|
||||
prompt: `Du bist ein Moderator für eine Gedenkseite. Prüfe, ob dieser Beitrag angemessen ist.
|
||||
|
||||
Name: ${name || 'Anonym'}
|
||||
Titel: ${title || 'Kein Titel'}
|
||||
Text: "${text}"
|
||||
|
||||
Unangemessen sind:
|
||||
- Spam, Werbung, Links zu Produkten
|
||||
- Beleidigungen, Hassrede
|
||||
- Völlig irrelevanter Inhalt
|
||||
- Unseriöse oder respektlose Inhalte
|
||||
|
||||
Angemessen sind:
|
||||
- Erinnerungen, Anekdoten
|
||||
- Kondolenzen, Beileidsbekundungen
|
||||
- Persönliche Geschichten
|
||||
- Emotionale oder traurige Texte
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt:
|
||||
{
|
||||
"appropriate": true/false,
|
||||
"reason": "Kurze Begründung wenn unangemessen"
|
||||
}`,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
console.warn('Ollama not available, skipping moderation')
|
||||
return NextResponse.json({ appropriate: true, ollama_unavailable: true })
|
||||
}
|
||||
|
||||
const ollamaData = await ollamaResponse.json()
|
||||
const responseText = ollamaData.response.trim()
|
||||
|
||||
// Parse JSON response
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) throw new Error('No JSON found')
|
||||
|
||||
const result = JSON.parse(jsonMatch[0])
|
||||
|
||||
return NextResponse.json({
|
||||
appropriate: result.appropriate !== false,
|
||||
reason: result.reason || null,
|
||||
})
|
||||
} catch (parseError) {
|
||||
// Fallback: Text-based
|
||||
const inappropriate = responseText.toLowerCase().includes('unangemessen')
|
||||
|
||||
return NextResponse.json({
|
||||
appropriate: !inappropriate,
|
||||
reason: inappropriate ? 'KI hat Bedenken' : null,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Moderation error:', error)
|
||||
return NextResponse.json({
|
||||
appropriate: true,
|
||||
ollama_unavailable: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
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 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 { title, description, ingredients, instructions, sort_order } = await req.json()
|
||||
|
||||
const db = getDb()
|
||||
db.prepare(
|
||||
'UPDATE recipes SET title = ?, description = ?, ingredients = ?, instructions = ?, sort_order = ? WHERE id = ?'
|
||||
).run(title, description || null, ingredients || null, instructions || null, sort_order ?? 0, id)
|
||||
|
||||
const recipe = db.prepare('SELECT * FROM recipes WHERE id = ?').get(id)
|
||||
return NextResponse.json(recipe)
|
||||
}
|
||||
|
||||
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()
|
||||
db.prepare('DELETE FROM recipes WHERE id = ?').run(id)
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
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 async function GET() {
|
||||
const db = getDb()
|
||||
const recipes = db
|
||||
.prepare('SELECT * FROM recipes ORDER BY sort_order, created_at')
|
||||
.all()
|
||||
return NextResponse.json(recipes)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { title, description, ingredients, instructions, sort_order } = await req.json()
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Titel ist erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO recipes (title, description, ingredients, instructions, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
title.trim(),
|
||||
description?.trim() || null,
|
||||
ingredients?.trim() || null,
|
||||
instructions?.trim() || null,
|
||||
sort_order ?? 0
|
||||
)
|
||||
const recipe = db
|
||||
.prepare('SELECT * FROM recipes WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
return NextResponse.json(recipe, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function getExpectedToken() {
|
||||
return createHash('sha256')
|
||||
.update(process.env.SITE_PASSWORD || 'familie')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { password } = await req.json()
|
||||
|
||||
if (password !== (process.env.SITE_PASSWORD || 'familie')) {
|
||||
return NextResponse.json({ error: 'Falsches Passwort' }, { status: 401 })
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ success: true })
|
||||
response.cookies.set('site_auth', getExpectedToken(), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
path: '/',
|
||||
})
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// PUT: Update contribution status or edit content
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// If only status update
|
||||
if (body.status && Object.keys(body).length === 1) {
|
||||
if (!['approved', 'rejected', 'pending'].includes(body.status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
db.prepare('UPDATE timeline_contributions SET status = ? WHERE id = ?').run(body.status, id)
|
||||
} else {
|
||||
// Full edit
|
||||
const { name, email, year, month, day, title, story } = body
|
||||
db.prepare(`
|
||||
UPDATE timeline_contributions
|
||||
SET name = ?, email = ?, year = ?, month = ?, day = ?, title = ?, story = ?
|
||||
WHERE id = ?
|
||||
`).run(name, email || null, year || null, month || null, day || null, title, story, id)
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM timeline_contributions WHERE id = ?').get(id)
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
|
||||
// DELETE: Remove contribution
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
||||
db.prepare('DELETE FROM timeline_contributions WHERE id = ?').run(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// GET: All contributions (approved ones for public, all for admin)
|
||||
export async function GET(req: NextRequest) {
|
||||
const db = getDb()
|
||||
const { searchParams } = new URL(req.url)
|
||||
const includeAll = searchParams.get('all') === 'true'
|
||||
|
||||
const query = includeAll
|
||||
? "SELECT * FROM timeline_contributions ORDER BY year DESC, month DESC, day DESC, created_at DESC"
|
||||
: "SELECT * FROM timeline_contributions WHERE status = 'approved' ORDER BY year DESC, month DESC, day DESC"
|
||||
|
||||
const contributions = db.prepare(query).all()
|
||||
return NextResponse.json(contributions)
|
||||
}
|
||||
|
||||
// POST: Submit new contribution
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { name, email, year, month, day, title, story } = body
|
||||
|
||||
if (!name?.trim() || !year?.trim() || !title?.trim() || !story?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name, Jahr, Titel und Geschichte sind erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO timeline_contributions (name, email, year, month, day, title, story, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')`
|
||||
)
|
||||
.run(
|
||||
name.trim(),
|
||||
email?.trim() || null,
|
||||
year.trim(),
|
||||
month?.trim() || null,
|
||||
day?.trim() || null,
|
||||
title.trim(),
|
||||
story.trim()
|
||||
)
|
||||
|
||||
const contribution = db
|
||||
.prepare('SELECT * FROM timeline_contributions WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
|
||||
return NextResponse.json(contribution, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
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 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 { year, month, day, title, description, location, sort_order, media_filenames } = await req.json()
|
||||
|
||||
const db = getDb()
|
||||
db.prepare(
|
||||
'UPDATE timeline SET year = ?, month = ?, day = ?, title = ?, description = ?, location = ?, media_filenames = ?, sort_order = ? WHERE id = ?'
|
||||
).run(
|
||||
year,
|
||||
month || null,
|
||||
day || null,
|
||||
title,
|
||||
description || null,
|
||||
location || null,
|
||||
media_filenames || null,
|
||||
sort_order ?? 0,
|
||||
id
|
||||
)
|
||||
|
||||
const entry = db.prepare('SELECT * FROM timeline WHERE id = ?').get(id)
|
||||
return NextResponse.json(entry)
|
||||
}
|
||||
|
||||
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()
|
||||
db.prepare('DELETE FROM timeline WHERE id = ?').run(id)
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
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 async function GET() {
|
||||
const db = getDb()
|
||||
const entries = db
|
||||
.prepare('SELECT * FROM timeline ORDER BY sort_order, year')
|
||||
.all()
|
||||
return NextResponse.json(entries)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { year, month, day, title, description, location, sort_order, media_filenames } = await req.json()
|
||||
if (!year?.trim() || !title?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Jahr und Titel sind erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare('INSERT INTO timeline (year, month, day, title, description, location, media_filenames, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(
|
||||
year.trim(),
|
||||
month?.trim() || null,
|
||||
day?.trim() || null,
|
||||
title.trim(),
|
||||
description?.trim() || null,
|
||||
location?.trim() || null,
|
||||
media_filenames || null,
|
||||
sort_order ?? 0
|
||||
)
|
||||
const entry = db
|
||||
.prepare('SELECT * FROM timeline WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
return NextResponse.json(entry, { status: 201 })
|
||||
}
|
||||
+37
-37
@@ -47,49 +47,49 @@ 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 file = formData.get('file') as File | null
|
||||
const caption = formData.get('caption') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Keine Datei' }, { status: 400 })
|
||||
const files = formData.getAll('files') as File[]
|
||||
const singleFile = formData.get('file') as File | null
|
||||
|
||||
// Support both 'file' (single) and 'files' (multiple)
|
||||
const filesToProcess = files.length > 0 ? files : (singleFile ? [singleFile] : [])
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
return NextResponse.json({ error: 'Keine Dateien' }, { status: 400 })
|
||||
}
|
||||
|
||||
let mimeType = file.type?.toLowerCase() || ''
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
const uploadedFiles = []
|
||||
|
||||
if (!mimeType && (ext === '.heic' || ext === '.heif')) {
|
||||
mimeType = 'image/heic'
|
||||
for (const file of filesToProcess) {
|
||||
let mimeType = file.type?.toLowerCase() || ''
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
|
||||
if (!mimeType && (ext === '.heic' || ext === '.heif')) {
|
||||
mimeType = 'image/heic'
|
||||
}
|
||||
|
||||
const folder = MIME_TO_FOLDER[mimeType]
|
||||
if (!folder) {
|
||||
continue // Skip unsupported files
|
||||
}
|
||||
|
||||
const filename = `${folder}/${randomUUID()}${ext || '.bin'}`
|
||||
const filePath = path.join(DATA_DIR, 'uploads', filename)
|
||||
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
uploadedFiles.push(filename)
|
||||
}
|
||||
|
||||
const folder = MIME_TO_FOLDER[mimeType]
|
||||
if (!folder) {
|
||||
return NextResponse.json(
|
||||
{ error: `Dateityp "${mimeType}" nicht unterstützt` },
|
||||
{ status: 400 }
|
||||
)
|
||||
if (uploadedFiles.length === 0) {
|
||||
return NextResponse.json({ error: 'Keine Dateien konnten verarbeitet werden' }, { status: 400 })
|
||||
}
|
||||
|
||||
const filename = `${folder}/${randomUUID()}${ext || '.bin'}`
|
||||
const filePath = path.join(DATA_DIR, 'uploads', filename)
|
||||
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO media (filename, original_name, type, caption) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
.run(filename, file.name, FOLDER_TO_TYPE[folder], caption || null)
|
||||
|
||||
const media = db
|
||||
.prepare('SELECT * FROM media WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
return NextResponse.json(media, { status: 201 })
|
||||
// Return array of filenames for multi-upload
|
||||
return NextResponse.json({
|
||||
filenames: uploadedFiles,
|
||||
count: uploadedFiles.length
|
||||
}, { status: 201 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const revalidate = 60 // Revalidate every 60 seconds
|
||||
export const dynamic = 'force-static'
|
||||
export const fetchCache = 'force-cache'
|
||||
@@ -55,3 +55,81 @@
|
||||
background: #C4A04A;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
@page {
|
||||
margin: 1.5cm;
|
||||
size: A4 portrait;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* Hide interactive elements */
|
||||
nav,
|
||||
button,
|
||||
.no-print,
|
||||
[class*="hover:"],
|
||||
[href="#"],
|
||||
footer a:not([href^="mailto"]),
|
||||
[data-noprint] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure proper page breaks */
|
||||
section {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
img, figure {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Simplify backgrounds */
|
||||
* {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Make text readable */
|
||||
body, p, span, div {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* Show links */
|
||||
a[href^="http"]:after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Optimize images */
|
||||
img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Hide decorative elements */
|
||||
.grain-overlay,
|
||||
video,
|
||||
iframe,
|
||||
canvas {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#f5f0e8"/>
|
||||
<text x="16" y="24" text-anchor="middle" font-size="22" fill="#b8860b">✦</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 207 B |
@@ -22,6 +22,7 @@ const lora = Lora({
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'),
|
||||
title: 'In Erinnerung an Maria Malejka',
|
||||
description:
|
||||
'Eine liebevolle Gedenkseite für Maria Malejka · 29. November 1944 – 10. Februar 2026',
|
||||
@@ -29,6 +30,20 @@ export const metadata: Metadata = {
|
||||
title: 'In Erinnerung an Maria Malejka',
|
||||
description: '29. November 1944 – 10. Februar 2026',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: '/og-image.jpg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'In Erinnerung an Maria Malejka',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'In Erinnerung an Maria Malejka',
|
||||
description: '29. November 1944 – 10. Februar 2026',
|
||||
images: ['/og-image.jpg'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export const alt = 'In Erinnerung an Maria Malejka'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
export const contentType = 'image/png'
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FAF7F0 0%, #E8DDD0 100%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'serif',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Ornament top */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 80, height: 1, background: '#C4A04A', opacity: 0.4 }} />
|
||||
<div style={{ fontSize: 24, color: '#C4A04A', opacity: 0.6 }}>✦</div>
|
||||
<div style={{ width: 80, height: 1, background: '#C4A04A', opacity: 0.4 }} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 300,
|
||||
color: '#3D2B1F',
|
||||
margin: 0,
|
||||
marginBottom: 20,
|
||||
fontStyle: 'italic',
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
Maria Malejka
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: '#7C6352',
|
||||
opacity: 0.8,
|
||||
letterSpacing: 4,
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
29. November 1944 – 10. Februar 2026
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: '#7C6352',
|
||||
opacity: 0.7,
|
||||
fontStyle: 'italic',
|
||||
maxWidth: 700,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
„Wer im Herzen der Menschen weiterlebt,
|
||||
<br />
|
||||
der ist nicht wirklich fort."
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ornament bottom */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 80, height: 1, background: '#C4A04A', opacity: 0.4 }} />
|
||||
<div style={{ fontSize: 24, color: '#C4A04A', opacity: 0.6 }}>✦</div>
|
||||
<div style={{ width: 80, height: 1, background: '#C4A04A', opacity: 0.4 }} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
+159
-13
@@ -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">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
export default function QRPage() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
const url = typeof window !== 'undefined' ? window.location.origin : 'https://maria-malejka.de'
|
||||
QRCode.toCanvas(canvasRef.current, url, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#7C3A0E',
|
||||
light: '#FFFBF0',
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-cream flex items-center justify-center p-8">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<h1 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-4">
|
||||
Maria Malejka
|
||||
</h1>
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm mb-8">
|
||||
29. November 1944 — 10. Februar 2026
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 mb-6">
|
||||
<canvas ref={canvasRef} className="mx-auto" />
|
||||
</div>
|
||||
|
||||
<p className="font-lora text-warm-brown-light text-sm leading-relaxed">
|
||||
Scanne diesen Code, um die Gedenkseite zu besuchen
|
||||
</p>
|
||||
|
||||
<div className="mt-8 text-xs text-warm-brown-light/40 font-lora">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="px-4 py-2 border border-warm-border rounded-lg hover:bg-amber-50 transition-colors"
|
||||
>
|
||||
Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
main {
|
||||
background: white !important;
|
||||
}
|
||||
button {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ZugangPage() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/site-auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
setError('Falsches Passwort')
|
||||
setPassword('')
|
||||
}
|
||||
} catch {
|
||||
setError('Ein Fehler ist aufgetreten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-cream flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
{/* Decorative element */}
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
<div className="h-px w-12 bg-warm-gold/40" />
|
||||
<span className="text-warm-gold text-xl">✦</span>
|
||||
<div className="h-px w-12 bg-warm-gold/40" />
|
||||
</div>
|
||||
|
||||
<h1 className="font-cormorant italic text-3xl sm:text-4xl text-warm-brown mb-2">
|
||||
In Erinnerung an
|
||||
</h1>
|
||||
<p className="font-cormorant text-2xl sm:text-3xl text-warm-brown-light mb-1">
|
||||
Maria Malejka
|
||||
</p>
|
||||
<p className="font-lora text-sm text-warm-brown-light/60 mb-10">
|
||||
Familiärer Zugang
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
autoFocus
|
||||
className="w-full px-4 py-3 rounded-lg border border-warm-border bg-white/80
|
||||
text-warm-brown placeholder:text-warm-brown-light/40
|
||||
font-lora text-center text-base
|
||||
focus:outline-none focus:ring-2 focus:ring-warm-gold/30 focus:border-warm-gold/50
|
||||
transition-colors"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-600/80 text-sm font-lora">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="w-full py-3 rounded-lg bg-warm-brown text-cream font-cormorant italic text-lg
|
||||
hover:bg-warm-brown/90 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{loading ? 'Prüfe…' : 'Eintreten'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 mt-12">
|
||||
<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>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+581
-115
@@ -1,140 +1,492 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Flame } from 'lucide-react'
|
||||
|
||||
const candleData = [
|
||||
{ delay: 0.0, bodyH: 88, bodyW: 9 },
|
||||
{ delay: 0.4, bodyH: 112, bodyW: 11 },
|
||||
{ delay: 0.2, bodyH: 76, bodyW: 8 },
|
||||
{ delay: 0.6, bodyH: 100, bodyW: 10 },
|
||||
{ delay: 0.1, bodyH: 92, bodyW: 9 },
|
||||
{ delay: 0.5, bodyH: 120, bodyW: 12 },
|
||||
{ delay: 0.3, bodyH: 82, bodyW: 9 },
|
||||
]
|
||||
type CandleData = {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function Candle({ bodyH, bodyW, delay }: { bodyH: number; bodyW: number; delay: number }) {
|
||||
const flameW = bodyW * 1.8
|
||||
const flameH = bodyW * 2.6
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ gap: 0 }}>
|
||||
<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
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: flameW * 1.3,
|
||||
height: flameH * 1.3,
|
||||
background:
|
||||
'radial-gradient(ellipse at 50% 80%, rgba(255,180,40,0.18) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(6px)',
|
||||
}}
|
||||
/>
|
||||
<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)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: flameW * 0.45,
|
||||
height: flameH * 0.55,
|
||||
background:
|
||||
'radial-gradient(ellipse at 50% 100%, rgba(255,255,220,0.95) 0%, rgba(255,230,80,0.7) 50%, transparent 100%)',
|
||||
borderRadius: '50% 50% 40% 40%',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleCandle({ candle, index }: { candle: CandleData; index: number }) {
|
||||
// Generate consistent but varied properties based on candle ID
|
||||
const seed = candle.id * 7919 // Prime number for good distribution
|
||||
const sizeVariant = ((seed % 5) / 4) * 0.6 + 0.7 // 0.7 to 1.3
|
||||
const heightVariant = ((seed % 7) / 6) * 0.5 + 0.75 // 0.75 to 1.25
|
||||
const hueShift = (seed % 20) - 10 // -10 to +10 hue shift
|
||||
const brightnessShift = ((seed % 15) - 7) / 100 // -0.07 to +0.08 brightness
|
||||
const rotation = ((seed % 11) - 5) / 2 // -2.5 to +2.5 degrees
|
||||
|
||||
// Calculate burn-down based on age
|
||||
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) // Burns down max 40% over 24 hours
|
||||
|
||||
const candleHeight = 60 * heightVariant * (1 - burnProgress)
|
||||
const candleWidth = 28 * sizeVariant
|
||||
const flameSize = 0.8 * sizeVariant
|
||||
const delay = (index % 7) * 0.15
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: Math.min(index * 0.08, 1.2) }}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
filter: `brightness(${1 + brightnessShift})`,
|
||||
marginLeft: index > 0 ? '-8px' : '0', // Slight overlap for natural clustering
|
||||
}}
|
||||
className="group relative"
|
||||
>
|
||||
{/* 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 */}
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
{/* Outer glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: flameW * 1.3,
|
||||
height: flameH * 1.3,
|
||||
background:
|
||||
'radial-gradient(ellipse at 50% 80%, rgba(255,180,40,0.18) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(6px)',
|
||||
}}
|
||||
/>
|
||||
{/* Main flame */}
|
||||
<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(${bodyW * 0.09}px)`,
|
||||
}}
|
||||
/>
|
||||
{/* Inner core */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: flameW * 0.45,
|
||||
height: flameH * 0.55,
|
||||
background:
|
||||
'radial-gradient(ellipse at 50% 100%, rgba(255,255,220,0.95) 0%, rgba(255,230,80,0.7) 50%, transparent 100%)',
|
||||
borderRadius: '50% 50% 40% 40%',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className="relative z-10 mb-1">
|
||||
<CandleFlame size={flameSize} delay={delay} />
|
||||
</div>
|
||||
|
||||
{/* Wick */}
|
||||
<div
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 5,
|
||||
backgroundColor: 'rgba(60,30,10,0.9)',
|
||||
marginBottom: -1,
|
||||
zIndex: 1,
|
||||
width: 2,
|
||||
height: 6 * sizeVariant,
|
||||
background: 'linear-gradient(to bottom, #2b1a0a, #1a0f06)',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Candle body */}
|
||||
{/* Candle Body */}
|
||||
<div
|
||||
style={{
|
||||
width: bodyW,
|
||||
height: bodyH,
|
||||
background:
|
||||
'linear-gradient(to right, rgba(240,230,210,0.10) 0%, rgba(255,248,235,0.07) 40%, rgba(220,200,170,0.04) 100%)',
|
||||
borderRadius: '1px 1px 0 0',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
borderBottom: 'none',
|
||||
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%)`,
|
||||
borderRadius: `${2 * sizeVariant}px ${2 * sizeVariant}px ${4 * sizeVariant}px ${4 * sizeVariant}px`,
|
||||
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)
|
||||
`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Wax drip highlight */}
|
||||
{/* Wax drips - more visible */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: bodyW * 0.2,
|
||||
width: bodyW * 0.15,
|
||||
height: bodyH * 0.4,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: '0 0 50% 50%',
|
||||
left: `${15 + (seed % 30)}%`,
|
||||
width: `${4 * sizeVariant}px`,
|
||||
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 ${2 * sizeVariant}px ${2 * sizeVariant}px`,
|
||||
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.5 * sizeVariant}px`,
|
||||
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%)`,
|
||||
borderRadius: `0 0 ${2 * sizeVariant}px ${2 * sizeVariant}px`,
|
||||
opacity: 0.75,
|
||||
boxShadow: `inset -1px 0 2px rgba(255,255,255,0.2)`,
|
||||
}}
|
||||
/>
|
||||
{/* Additional smaller drip for realism */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${4 * (1 - burnProgress)}px`,
|
||||
left: `${45 + (seed % 20)}%`,
|
||||
width: `${2 * sizeVariant}px`,
|
||||
height: `${10 * heightVariant}px`,
|
||||
background: `linear-gradient(to bottom,
|
||||
hsl(${35 + hueShift}, ${58 + hueShift}%, ${84 + brightnessShift * 10}%),
|
||||
transparent)`,
|
||||
borderRadius: `0 0 ${1 * sizeVariant}px ${1 * sizeVariant}px`,
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Base plate */}
|
||||
<div
|
||||
{/* Name Label */}
|
||||
<p
|
||||
className="text-amber-200/50 font-cormorant italic mt-2 text-center leading-tight"
|
||||
style={{
|
||||
width: bodyW + 6,
|
||||
height: 3,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: '0 0 2px 2px',
|
||||
fontSize: `${10 * sizeVariant}px`,
|
||||
textShadow: '0 0 8px rgba(196,160,74,0.15)',
|
||||
maxWidth: `${80 * sizeVariant}px`,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
{candle.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-amber-200/35 font-lora text-center leading-tight"
|
||||
style={{
|
||||
fontSize: `${7 * sizeVariant}px`,
|
||||
marginTop: `${2 * sizeVariant}px`,
|
||||
}}
|
||||
>
|
||||
{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 [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const loadCandles = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/candles')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCandles(Array.isArray(data) ? data : [])
|
||||
}
|
||||
} catch {
|
||||
// Ignore network errors
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadCandles()
|
||||
const interval = setInterval(loadCandles, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [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)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBurnComplete = useCallback(() => {
|
||||
setBurning(false)
|
||||
setDone(true)
|
||||
setName('')
|
||||
setMessage('')
|
||||
loadCandles()
|
||||
setTimeout(() => {
|
||||
setDone(false)
|
||||
setShowModal(false)
|
||||
}, 2500)
|
||||
}, [loadCandles])
|
||||
|
||||
return (
|
||||
<section
|
||||
className="py-24 overflow-hidden"
|
||||
id="kerzen"
|
||||
className="py-20 sm:py-24 overflow-hidden"
|
||||
style={{ background: 'linear-gradient(to bottom, #060304 0%, #0d0807 50%, #060304 100%)' }}
|
||||
>
|
||||
<motion.div
|
||||
@@ -142,33 +494,147 @@ export default function CandleSection() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 1.2 }}
|
||||
className="text-center"
|
||||
className="text-center max-w-5xl mx-auto px-4"
|
||||
>
|
||||
{/* Candles */}
|
||||
<div className="flex items-end justify-center gap-3 sm:gap-5 mb-14">
|
||||
{candleData.map((c, i) => (
|
||||
<Candle key={i} {...c} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{/* Header */}
|
||||
<p
|
||||
className="font-cormorant italic text-amber-200/40 text-2xl sm:text-3xl tracking-widest"
|
||||
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 mt-5">
|
||||
<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>
|
||||
|
||||
<p className="font-lora text-amber-100/20 text-xs tracking-[0.4em] uppercase mt-4">
|
||||
29. November 1944 — 10. Februar 2026
|
||||
</p>
|
||||
{/* Candle Grid - with better spacing for many candles */}
|
||||
{candles.length > 0 && (
|
||||
<div className="flex flex-wrap items-end justify-center gap-2 sm:gap-3 mb-12 max-w-4xl mx-auto">
|
||||
{candles.map((candle, i) => (
|
||||
<SingleCandle key={candle.id} candle={candle} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Light a candle button */}
|
||||
<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"
|
||||
>
|
||||
<Flame size={18} className="text-amber-400/60" />
|
||||
Zünde eine Kerze für Oma an
|
||||
</motion.button>
|
||||
|
||||
{candles.length > 0 && (
|
||||
<p className="text-amber-200/20 text-xs font-lora mt-4">
|
||||
{candles.length} {candles.length === 1 ? 'Kerze brennt' : 'Kerzen brennen'} für Oma
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{showModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
{burning ? (
|
||||
<BurningNote message={message} onComplete={handleBurnComplete} />
|
||||
) : done ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="flex justify-center mb-4">
|
||||
<CandleFlame size={1.5} />
|
||||
</div>
|
||||
<p className="text-amber-200/80 font-cormorant italic text-2xl">
|
||||
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>
|
||||
|
||||
<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">
|
||||
Dein Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-amber-200/40 text-xs font-lora mb-1.5 uppercase tracking-wider">
|
||||
Deine 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}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Upload, CheckCircle } from 'lucide-react'
|
||||
|
||||
export default function FamilyUploadSection() {
|
||||
const [name, setName] = useState('')
|
||||
const [relation, setRelation] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name.trim() || 'Anonym')
|
||||
formData.append('relation', relation.trim())
|
||||
if (file) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
|
||||
const res = await fetch('/api/family-upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(true)
|
||||
setName('')
|
||||
setRelation('')
|
||||
setFile(null)
|
||||
setTimeout(() => setSuccess(false), 5000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Upload fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler. Bitte versuche es erneut.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="teilen" className="py-16 sm:py-20 bg-amber-50/30">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||
Teile deine Erinnerungen
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-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>
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm leading-relaxed max-w-lg mx-auto">
|
||||
Hast du Fotos oder Videos von Oma? Teile sie mit der Familie.
|
||||
Alle Beiträge werden von Dennis geprüft, bevor sie veröffentlicht werden.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 bg-green-50 border border-green-200 rounded-xl p-4 flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle className="text-green-600 flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-lora text-green-800 font-semibold text-sm">
|
||||
Erfolgreich hochgeladen!
|
||||
</p>
|
||||
<p className="font-lora text-green-700 text-xs mt-0.5">
|
||||
Dein Beitrag wird geprüft und bald veröffentlicht.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4"
|
||||
>
|
||||
<p className="font-lora text-red-800 text-sm">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-cream border border-warm-border rounded-2xl p-6 sm:p-8 shadow-sm"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Dein Name <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Maria Schmidt"
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Relation */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Deine Beziehung zu Oma <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={relation}
|
||||
onChange={(e) => setRelation(e.target.value)}
|
||||
placeholder="z.B. Enkelin, Nichte, Freundin"
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Foto oder Video <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
onChange={(e) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile)
|
||||
setError('')
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="flex items-center justify-center gap-3 w-full px-4 py-8 rounded-xl border-2 border-dashed border-warm-border bg-amber-50/50 hover:bg-amber-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Upload className="text-warm-gold" size={24} />
|
||||
<div className="text-center">
|
||||
{file ? (
|
||||
<>
|
||||
<p className="font-lora text-warm-brown text-sm font-semibold">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/50 text-xs mt-1">
|
||||
Klicke, um eine andere Datei auszuwählen
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-lora text-warm-brown text-sm font-semibold">
|
||||
Datei auswählen
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/50 text-xs mt-1">
|
||||
Foto oder Video hochladen
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
whileHover={{ scale: submitting ? 1 : 1.02 }}
|
||||
whileTap={{ scale: submitting ? 1 : 0.98 }}
|
||||
className="w-full py-4 rounded-xl bg-warm-gold hover:bg-warm-gold/90 disabled:bg-warm-brown-light/20 disabled:cursor-not-allowed text-white font-cormorant italic text-lg transition-colors shadow-sm disabled:shadow-none"
|
||||
>
|
||||
{submitting ? 'Wird hochgeladen...' : 'Hochladen'}
|
||||
</motion.button>
|
||||
|
||||
<p className="text-center font-lora text-warm-brown-light/40 text-xs leading-relaxed">
|
||||
Dein Beitrag wird von Dennis geprüft, bevor er auf der Seite erscheint.
|
||||
</p>
|
||||
</div>
|
||||
</motion.form>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export default function HeroSection({ heroPhoto }: HeroSectionProps) {
|
||||
<p className="font-lora text-amber-100/70 text-base sm:text-lg tracking-[0.25em]">
|
||||
29. November 1944
|
||||
</p>
|
||||
<p className="font-lora text-amber-100/40 text-sm tracking-[0.2em] mt-2">
|
||||
<p className="font-lora text-amber-100/40 text-base sm:text-lg tracking-[0.25em] mt-2">
|
||||
— 10. Februar 2026 —
|
||||
</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Heart, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function MemoryUploadSection() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
title: '',
|
||||
content: '',
|
||||
})
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
let uploadedFilenames: string[] = []
|
||||
if (files.length > 0) {
|
||||
const uploadFormData = new FormData()
|
||||
files.forEach(file => uploadFormData.append('files', file))
|
||||
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) throw new Error('Upload failed')
|
||||
const uploadData = await uploadRes.json()
|
||||
uploadedFilenames = uploadData.filenames || []
|
||||
}
|
||||
|
||||
await fetch('/api/contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name || 'Anonym',
|
||||
type: 'memory',
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
media_filenames: uploadedFilenames.length > 0 ? uploadedFilenames.join(',') : null,
|
||||
}),
|
||||
})
|
||||
|
||||
setSubmitSuccess(true)
|
||||
setFormData({ name: '', title: '', content: '' })
|
||||
setFiles([])
|
||||
setTimeout(() => setSubmitSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Fehler beim Absenden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 bg-amber-50/30">
|
||||
<div className="max-w-xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-2 flex items-center justify-center gap-2">
|
||||
<Heart size={24} className="text-warm-gold" />
|
||||
Erinnerung teilen
|
||||
</h3>
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs">
|
||||
Teile deine persönliche Erinnerung
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{submitSuccess ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-green-50 border border-green-200 rounded-xl p-6 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-lora text-green-700/80 text-sm">
|
||||
Erinnerung eingereicht!
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/60 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-5 space-y-4"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Dein Name (optional)"
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Titel *"
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="Deine Erinnerung *"
|
||||
rows={4}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-xs text-warm-brown file:mr-3 file:py-1 file:px-3 file:rounded-full file:border-0 file:text-xs file:bg-warm-gold/10 file:text-warm-gold hover:file:bg-warm-gold/20 file:cursor-pointer focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<p className="font-lora text-xs text-warm-brown-light/60">
|
||||
{files.length} Datei(en) ausgewählt
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-warm-gold hover:bg-warm-gold-light text-white font-lora text-sm py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sendet...
|
||||
</>
|
||||
) : (
|
||||
'Erinnerung teilen'
|
||||
)}
|
||||
</button>
|
||||
<p className="font-lora text-xs text-warm-brown-light/40 text-center">
|
||||
* Pflichtfelder
|
||||
</p>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
const playingRef = useRef(false)
|
||||
|
||||
const [userMuted, setUserMuted] = useState(false)
|
||||
const [hasStarted, setHasStarted] = useState(false)
|
||||
|
||||
const getActive = useCallback(
|
||||
() => (activeRef.current === 'A' ? audioA.current : audioB.current),
|
||||
@@ -99,35 +100,37 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
if (playingRef.current) return
|
||||
const a = audioA.current
|
||||
if (!a) return
|
||||
a.volume = VOLUME
|
||||
a.volume = userMuted ? 0 : VOLUME
|
||||
a.play().then(() => {
|
||||
playingRef.current = true
|
||||
setHasStarted(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
}, [userMuted])
|
||||
|
||||
// Try autoplay on mount (silent, then make audible on interaction)
|
||||
// Try autoplay on mount
|
||||
useEffect(() => {
|
||||
if (!src) return
|
||||
const a = audioA.current
|
||||
if (!a) return
|
||||
|
||||
// Try to autoplay
|
||||
a.volume = VOLUME
|
||||
// Try to autoplay immediately (unmuted)
|
||||
a.volume = userMuted ? 0 : VOLUME
|
||||
a.play().then(() => {
|
||||
playingRef.current = true
|
||||
setHasStarted(true)
|
||||
}).catch(() => {
|
||||
// Blocked — will start on first interaction via ensurePlaying
|
||||
// Blocked by browser — will start on first interaction
|
||||
})
|
||||
|
||||
// Safety net: on any interaction, make sure audio is playing
|
||||
// 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, { passive: true }))
|
||||
events.forEach((e) => window.addEventListener(e, handler, { once: true, passive: true }))
|
||||
|
||||
return () => {
|
||||
events.forEach((e) => window.removeEventListener(e, handler))
|
||||
}
|
||||
}, [src, ensurePlaying])
|
||||
}, [src, userMuted, ensurePlaying])
|
||||
|
||||
if (!track || !src) return null
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function PhotoSlideshow({ photos }: { photos: MediaItem[] }) {
|
||||
return (
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-2xl shadow-2xl mb-10"
|
||||
style={{ aspectRatio: '16/7' }}
|
||||
style={{ aspectRatio: '3/2' }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Image as ImageIcon, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function PhotoUploadSection() {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (files.length === 0) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const uploadFormData = new FormData()
|
||||
files.forEach(file => uploadFormData.append('files', file))
|
||||
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) throw new Error('Upload failed')
|
||||
const uploadData = await uploadRes.json()
|
||||
const uploadedFilenames = uploadData.filenames || []
|
||||
|
||||
// Create media contribution
|
||||
await fetch('/api/contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Anonym',
|
||||
type: 'media',
|
||||
media_filenames: uploadedFilenames.join(','),
|
||||
}),
|
||||
})
|
||||
|
||||
setSubmitSuccess(true)
|
||||
setFiles([])
|
||||
setTimeout(() => setSubmitSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Fehler beim Hochladen.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 bg-cream">
|
||||
<div className="max-w-xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-2 flex items-center justify-center gap-2">
|
||||
<ImageIcon size={24} className="text-warm-gold" />
|
||||
Fotos hochladen
|
||||
</h3>
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs">
|
||||
Einfach Fotos hochladen - ohne Text oder Titel
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{submitSuccess ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-green-50 border border-green-200 rounded-xl p-6 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-lora text-green-700/80 text-sm">
|
||||
Fotos hochgeladen!
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/60 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-5 space-y-4"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-xs text-warm-brown file:mr-3 file:py-1 file:px-3 file:rounded-full file:border-0 file:text-xs file:bg-warm-gold/10 file:text-warm-gold hover:file:bg-warm-gold/20 file:cursor-pointer focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<p className="font-lora text-xs text-warm-brown-light/60">
|
||||
{files.length} Foto(s) ausgewählt
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || files.length === 0}
|
||||
className="w-full bg-warm-gold hover:bg-warm-gold-light text-white font-lora text-sm py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Lädt hoch...
|
||||
</>
|
||||
) : (
|
||||
'Hochladen'
|
||||
)}
|
||||
</button>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChefHat, X } from 'lucide-react'
|
||||
|
||||
type Recipe = {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
ingredients: string | null
|
||||
instructions: string | null
|
||||
}
|
||||
|
||||
function RecipeModal({ recipe, onClose }: { recipe: Recipe; onClose: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<'ingredients' | 'instructions'>('ingredients')
|
||||
|
||||
const ingredientsList = recipe.ingredients?.split('\n').filter(Boolean) || []
|
||||
const instructionsList = recipe.instructions?.split('\n').filter(Boolean) || []
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center px-4 py-8 overflow-y-auto"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative bg-cream rounded-2xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-cream border-b border-warm-border z-10 px-6 sm:px-8 py-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-warm-brown-light/40 hover:text-warm-brown transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<div className="flex items-start gap-3">
|
||||
<ChefHat className="text-warm-gold mt-1 flex-shrink-0" size={28} />
|
||||
<div>
|
||||
<h3 className="font-cormorant italic text-3xl text-warm-brown">
|
||||
{recipe.title}
|
||||
</h3>
|
||||
{recipe.description && (
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm mt-2 leading-relaxed">
|
||||
{recipe.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-warm-border bg-amber-50/50">
|
||||
<button
|
||||
onClick={() => setActiveTab('ingredients')}
|
||||
className={`flex-1 py-3 font-cormorant italic text-lg transition-colors ${
|
||||
activeTab === 'ingredients'
|
||||
? 'text-warm-brown border-b-2 border-warm-gold bg-cream'
|
||||
: 'text-warm-brown-light/50 hover:text-warm-brown-light'
|
||||
}`}
|
||||
>
|
||||
Zutaten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('instructions')}
|
||||
className={`flex-1 py-3 font-cormorant italic text-lg transition-colors ${
|
||||
activeTab === 'instructions'
|
||||
? 'text-warm-brown border-b-2 border-warm-gold bg-cream'
|
||||
: 'text-warm-brown-light/50 hover:text-warm-brown-light'
|
||||
}`}
|
||||
>
|
||||
Zubereitung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 sm:px-8 py-6 overflow-y-auto max-h-[calc(85vh-220px)]">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'ingredients' ? (
|
||||
<motion.div
|
||||
key="ingredients"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{ingredientsList.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{ingredientsList.map((ingredient, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-3 font-lora text-warm-brown-light"
|
||||
>
|
||||
<span className="text-warm-gold mt-1 flex-shrink-0">✦</span>
|
||||
<span>{ingredient}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-warm-brown-light/50 font-lora italic">
|
||||
Keine Zutaten angegeben
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="instructions"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{instructionsList.length > 0 ? (
|
||||
<ol className="space-y-4">
|
||||
{instructionsList.map((instruction, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-3 font-lora text-warm-brown-light leading-relaxed"
|
||||
>
|
||||
<span className="font-cormorant text-warm-gold text-xl font-semibold flex-shrink-0 mt-0.5">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span>{instruction}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-warm-brown-light/50 font-lora italic">
|
||||
Keine Anleitung angegeben
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RecipeSection({ recipes }: { recipes: Recipe[] }) {
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null)
|
||||
|
||||
if (recipes.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="rezepte" className="py-16 sm:py-20 bg-cream">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||
Omas Rezepte
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-3">
|
||||
<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>
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm">
|
||||
Die Geheimnisse aus ihrer Küche
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recipe Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{recipes.map((recipe, index) => (
|
||||
<motion.div
|
||||
key={recipe.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
whileHover={{ y: -4 }}
|
||||
onClick={() => setSelectedRecipe(recipe)}
|
||||
className="bg-amber-50/50 border border-warm-border rounded-xl p-6 cursor-pointer transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<ChefHat className="text-warm-gold flex-shrink-0 mt-0.5" size={24} />
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown leading-tight">
|
||||
{recipe.title}
|
||||
</h3>
|
||||
</div>
|
||||
{recipe.description && (
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm leading-relaxed line-clamp-3">
|
||||
{recipe.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 text-warm-gold text-sm font-lora italic">
|
||||
Rezept ansehen →
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedRecipe && (
|
||||
<RecipeModal
|
||||
recipe={selectedRecipe}
|
||||
onClose={() => setSelectedRecipe(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChefHat, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function RecipeUploadSection() {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
ingredients: '',
|
||||
instructions: '',
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await fetch('/api/contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Rezept',
|
||||
type: 'recipe',
|
||||
title: formData.title,
|
||||
content: JSON.stringify({
|
||||
description: formData.description,
|
||||
ingredients: formData.ingredients,
|
||||
instructions: formData.instructions,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
setSubmitSuccess(true)
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
ingredients: '',
|
||||
instructions: '',
|
||||
})
|
||||
setTimeout(() => setSubmitSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Fehler beim Absenden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 bg-amber-50/30">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-2 flex items-center justify-center gap-2">
|
||||
<ChefHat size={24} className="text-warm-gold" />
|
||||
Rezept teilen
|
||||
</h3>
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs">
|
||||
Teile ein Rezept von Oma
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{submitSuccess ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-green-50 border border-green-200 rounded-xl p-6 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-lora text-green-700/80 text-sm">
|
||||
Rezept eingereicht!
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/60 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-5 space-y-4"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Rezept-Titel *"
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kurze Beschreibung (optional)"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.ingredients}
|
||||
onChange={(e) => setFormData({ ...formData, ingredients: e.target.value })}
|
||||
placeholder="Zutaten * (z.B. 500g Mehl, 2 Eier...)"
|
||||
rows={4}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.instructions}
|
||||
onChange={(e) => setFormData({ ...formData, instructions: e.target.value })}
|
||||
placeholder="Zubereitung * (Schritt für Schritt...)"
|
||||
rows={5}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-warm-gold hover:bg-warm-gold-light text-white font-lora text-sm py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sendet...
|
||||
</>
|
||||
) : (
|
||||
'Rezept teilen'
|
||||
)}
|
||||
</button>
|
||||
<p className="font-lora text-xs text-warm-brown-light/40 text-center">
|
||||
* Pflichtfelder
|
||||
</p>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Calendar, CheckCircle } from 'lucide-react'
|
||||
|
||||
export default function TimelineContributionSection() {
|
||||
const [name, setName] = useState('')
|
||||
const [story, setStory] = useState('')
|
||||
const [addToTimeline, setAddToTimeline] = useState(false)
|
||||
const [year, setYear] = useState('')
|
||||
const [month, setMonth] = useState('')
|
||||
const [day, setDay] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// No required fields - allow empty submissions
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/timeline-contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim() || 'Anonym',
|
||||
year: addToTimeline ? (year.trim() || null) : null,
|
||||
month: addToTimeline ? (month.trim() || null) : null,
|
||||
day: addToTimeline ? (day.trim() || null) : null,
|
||||
title: addToTimeline ? (title.trim() || 'Erinnerung') : 'Erinnerung',
|
||||
story: story.trim() || '',
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(true)
|
||||
setName('')
|
||||
setYear('')
|
||||
setMonth('')
|
||||
setDay('')
|
||||
setTitle('')
|
||||
setStory('')
|
||||
setAddToTimeline(false)
|
||||
setTimeout(() => setSuccess(false), 5000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Fehler beim Senden')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler. Bitte versuche es erneut.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="zeitstrahl-beitragen" className="py-16 sm:py-20 bg-cream">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||
Teile deine Erinnerung
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-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>
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm leading-relaxed max-w-lg mx-auto">
|
||||
Hast du eine besondere Erinnerung an Oma? Teile deine Geschichte mit uns.
|
||||
Alle Beiträge werden von Dennis geprüft.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 bg-green-50 border border-green-200 rounded-xl p-4 flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle className="text-green-600 flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-lora text-green-800 font-semibold text-sm">
|
||||
Vielen Dank für deine Erinnerung!
|
||||
</p>
|
||||
<p className="font-lora text-green-700 text-xs mt-0.5">
|
||||
Dein Beitrag wird geprüft und erscheint bald auf der Seite.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4"
|
||||
>
|
||||
<p className="font-lora text-red-800 text-sm">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-amber-50/50 border border-warm-border rounded-2xl p-6 sm:p-8 shadow-sm"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Dein Name <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Maria Schmidt"
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Deine Erinnerung <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={story}
|
||||
onChange={(e) => setStory(e.target.value)}
|
||||
placeholder="Erzähle uns deine Erinnerung an Oma..."
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add to timeline checkbox */}
|
||||
<div className="bg-white/60 rounded-xl p-4 border border-warm-border/50">
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addToTimeline}
|
||||
onChange={(e) => setAddToTimeline(e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-warm-border text-warm-gold focus:ring-warm-gold/30"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-lora text-warm-brown text-sm font-medium group-hover:text-warm-gold transition-colors">
|
||||
Als Ereignis im Zeitstrahl anzeigen
|
||||
</span>
|
||||
<p className="font-lora text-warm-brown-light/50 text-xs mt-0.5">
|
||||
Nur ankreuzen, wenn es ein bestimmtes Ereignis war (z.B. "Omas 60. Geburtstag")
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Timeline fields - only shown if checkbox is checked */}
|
||||
{addToTimeline && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 bg-amber-50 rounded-xl p-4 border border-warm-gold/20"
|
||||
>
|
||||
<p className="font-lora text-warm-brown text-xs font-medium">
|
||||
Ergänze die Details für den Zeitstrahl:
|
||||
</p>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Wann war das? <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
placeholder="Jahr"
|
||||
className="px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
placeholder="Monat"
|
||||
className="px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={day}
|
||||
onChange={(e) => setDay(e.target.value)}
|
||||
placeholder="Tag"
|
||||
className="px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-warm-brown-light/40 text-xs mt-1.5 font-lora">
|
||||
Alle Felder optional
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Ereignis-Titel <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Omas 60. Geburtstag"
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
whileHover={{ scale: submitting ? 1 : 1.02 }}
|
||||
whileTap={{ scale: submitting ? 1 : 0.98 }}
|
||||
className="w-full py-4 rounded-xl bg-warm-gold hover:bg-warm-gold/90 disabled:bg-warm-brown-light/20 disabled:cursor-not-allowed text-white font-cormorant italic text-lg transition-colors shadow-sm disabled:shadow-none flex items-center justify-center gap-2"
|
||||
>
|
||||
<Calendar size={20} />
|
||||
{submitting ? 'Wird gesendet...' : 'Erinnerung teilen'}
|
||||
</motion.button>
|
||||
|
||||
<p className="text-center font-lora text-warm-brown-light/40 text-xs leading-relaxed">
|
||||
Dein Beitrag wird von Dennis geprüft.
|
||||
</p>
|
||||
</div>
|
||||
</motion.form>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Calendar, MapPin, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function TimelineUploadSection() {
|
||||
const [eventType, setEventType] = useState<'general' | 'personal'>('general')
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
year: '',
|
||||
month: '',
|
||||
day: '',
|
||||
title: '',
|
||||
description: '',
|
||||
location: '',
|
||||
})
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
let uploadedFilenames: string[] = []
|
||||
if (files.length > 0) {
|
||||
const uploadFormData = new FormData()
|
||||
files.forEach(file => uploadFormData.append('files', file))
|
||||
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) throw new Error('Upload failed')
|
||||
const uploadData = await uploadRes.json()
|
||||
uploadedFilenames = uploadData.filenames || []
|
||||
}
|
||||
|
||||
await fetch('/api/contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: eventType === 'personal' && formData.name ? formData.name : (eventType === 'personal' ? 'Anonym' : null),
|
||||
type: 'timeline',
|
||||
year: formData.year,
|
||||
month: formData.month || null,
|
||||
day: formData.day || null,
|
||||
title: formData.title,
|
||||
content: formData.description || null,
|
||||
location: formData.location || null,
|
||||
media_filenames: uploadedFilenames.length > 0 ? uploadedFilenames.join(',') : null,
|
||||
}),
|
||||
})
|
||||
|
||||
setSubmitSuccess(true)
|
||||
setFormData({ name: '', year: '', month: '', day: '', title: '', description: '', location: '' })
|
||||
setFiles([])
|
||||
setTimeout(() => setSubmitSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Fehler beim Absenden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 bg-gradient-to-b from-cream to-amber-50/30">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-2 flex items-center justify-center gap-2">
|
||||
<Calendar size={24} className="text-warm-gold" />
|
||||
Zum Zeitstrahl beitragen
|
||||
</h3>
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs">
|
||||
Teile ein Ereignis aus Omas Leben
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{submitSuccess ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-green-50 border border-green-200 rounded-xl p-6 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-lora text-green-700/80 text-sm">
|
||||
Ereignis eingereicht!
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/60 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-5 space-y-4"
|
||||
>
|
||||
{/* Event Type Selection */}
|
||||
<div className="flex gap-3">
|
||||
<label className="flex-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="general"
|
||||
checked={eventType === 'general'}
|
||||
onChange={(e) => setEventType(e.target.value as 'general')}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="px-4 py-2 bg-white/80 border-2 border-warm-border rounded-lg text-center font-lora text-sm text-warm-brown peer-checked:border-warm-gold peer-checked:bg-warm-gold/10 transition-all">
|
||||
Generelles Ereignis
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="personal"
|
||||
checked={eventType === 'personal'}
|
||||
onChange={(e) => setEventType(e.target.value as 'personal')}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="px-4 py-2 bg-white/80 border-2 border-warm-border rounded-lg text-center font-lora text-sm text-warm-brown peer-checked:border-warm-gold peer-checked:bg-warm-gold/10 transition-all">
|
||||
Persönliches Ereignis
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Conditional Name Field */}
|
||||
{eventType === 'personal' && (
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Dein Name (optional)"
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Ereignis-Titel *"
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
|
||||
{/* Date */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.day}
|
||||
onChange={(e) => setFormData({ ...formData, day: e.target.value })}
|
||||
placeholder="Tag"
|
||||
className="px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.month}
|
||||
onChange={(e) => setFormData({ ...formData, month: e.target.value })}
|
||||
placeholder="Monat"
|
||||
className="px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.year}
|
||||
onChange={(e) => setFormData({ ...formData, year: e.target.value })}
|
||||
placeholder="Jahr *"
|
||||
required
|
||||
className="px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
placeholder="Ort (optional)"
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Beschreibung (optional)"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
|
||||
{/* Photos */}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-xs text-warm-brown file:mr-3 file:py-1 file:px-3 file:rounded-full file:border-0 file:text-xs file:bg-warm-gold/10 file:text-warm-gold hover:file:bg-warm-gold/20 file:cursor-pointer focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{files.map((file, i) => (
|
||||
<div key={i} className="aspect-square rounded-lg overflow-hidden bg-warm-brown/5">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-warm-gold hover:bg-warm-gold-light text-white font-lora text-sm py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sendet...
|
||||
</>
|
||||
) : (
|
||||
'Ereignis einreichen'
|
||||
)}
|
||||
</button>
|
||||
<p className="font-lora text-xs text-warm-brown-light/40 text-center">
|
||||
* Pflichtfelder
|
||||
</p>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -124,9 +124,6 @@ export default function TributeSection() {
|
||||
<p className="font-cormorant italic text-warm-brown/50 text-lg">
|
||||
Maria Malejka
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/40 text-sm mt-1">
|
||||
29. November 1944 · 10. Februar 2026
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/30 text-xs mt-3 tracking-wide">
|
||||
Beerdigung am 20. Februar 2026
|
||||
</p>
|
||||
|
||||
+81
-1
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
export type { Memory, MediaItem } from './types'
|
||||
export type { Memory, MediaItem, Candle, TimelineEntry, Recipe, TimelineContribution } from './types'
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
@@ -43,6 +43,52 @@ function initDb(db: DatabaseSync) {
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS timeline (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
year TEXT NOT NULL,
|
||||
month TEXT,
|
||||
day TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contributions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('memory', 'timeline', 'media', 'recipe')),
|
||||
year TEXT,
|
||||
month TEXT,
|
||||
day TEXT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
location TEXT,
|
||||
media_filenames TEXT,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'rejected', 'flagged')),
|
||||
moderation_reason TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
ingredients TEXT,
|
||||
instructions TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
|
||||
// Migration: add author column if missing
|
||||
@@ -51,4 +97,38 @@ function initDb(db: DatabaseSync) {
|
||||
} catch {
|
||||
// Column already exists – ignore
|
||||
}
|
||||
|
||||
// Migration: add status column to media
|
||||
try {
|
||||
db.exec(`ALTER TABLE media ADD COLUMN status TEXT DEFAULT 'approved'`)
|
||||
} catch {
|
||||
// Column already exists – ignore
|
||||
}
|
||||
|
||||
// Migration: add media_filenames to timeline
|
||||
try {
|
||||
db.exec(`ALTER TABLE timeline ADD COLUMN media_filenames TEXT`)
|
||||
} catch {}
|
||||
|
||||
// Migration: add month, day, location to timeline
|
||||
try {
|
||||
db.exec(`ALTER TABLE timeline ADD COLUMN month TEXT`)
|
||||
} catch {}
|
||||
try {
|
||||
db.exec(`ALTER TABLE timeline ADD COLUMN day TEXT`)
|
||||
} catch {}
|
||||
try {
|
||||
db.exec(`ALTER TABLE timeline ADD COLUMN location TEXT`)
|
||||
} catch {}
|
||||
|
||||
// Add initial timeline entries if empty
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM timeline').get() as { count: number }
|
||||
if (count.count === 0) {
|
||||
db.prepare(`
|
||||
INSERT INTO timeline (year, month, day, title, description, location, sort_order)
|
||||
VALUES
|
||||
('1944', '11', '29', 'Geburt', 'Maria Malejka wurde geboren', 'Polen', 1),
|
||||
('2026', '2', '10', 'Tod', 'Maria Malejka ist friedlich verstorben', 'Deutschland', 999)
|
||||
`).run()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,57 @@ export type MediaItem = {
|
||||
type: 'photo' | 'video' | 'music'
|
||||
caption: string | null
|
||||
sort_order: number
|
||||
status: 'approved' | 'pending'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Candle = {
|
||||
id: number
|
||||
name: string
|
||||
message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type TimelineEntry = {
|
||||
id: number
|
||||
year: string
|
||||
month: string | null
|
||||
day: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
location: string | null
|
||||
media_filenames: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Recipe = {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
ingredients: string | null
|
||||
instructions: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Contribution = {
|
||||
id: number
|
||||
name: string
|
||||
email: string | null
|
||||
type: 'memory' | 'timeline' | 'media' | 'recipe'
|
||||
year: string | null
|
||||
month: string | null
|
||||
day: string | null
|
||||
title: string | null
|
||||
content: string | null
|
||||
location: string | null
|
||||
media_filenames: string | null
|
||||
status: 'pending' | 'approved' | 'rejected' | 'flagged'
|
||||
moderation_reason?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Legacy alias for backwards compatibility
|
||||
export type TimelineContribution = Contribution
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
async function sha256(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(input)
|
||||
const hash = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export default async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Allow: /zugang page, site-auth API, static assets, favicon
|
||||
if (
|
||||
pathname === '/zugang' ||
|
||||
pathname.startsWith('/api/site-auth') ||
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/icon.svg'
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Public API routes (no auth required)
|
||||
if (
|
||||
pathname.startsWith('/api/contributions') ||
|
||||
pathname.startsWith('/api/upload') ||
|
||||
pathname.startsWith('/api/candles') ||
|
||||
pathname.startsWith('/api/family-upload') ||
|
||||
pathname.startsWith('/api/timeline') ||
|
||||
pathname.startsWith('/api/recipes') ||
|
||||
pathname.startsWith('/api/memories') ||
|
||||
pathname.startsWith('/api/media') ||
|
||||
pathname.startsWith('/api/files') ||
|
||||
pathname.startsWith('/api/auth')
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const sitePassword = process.env.SITE_PASSWORD || 'familie'
|
||||
const expectedToken = await sha256(sitePassword)
|
||||
const token = request.cookies.get('site_auth')?.value
|
||||
|
||||
if (token === expectedToken) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = '/zugang'
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization)
|
||||
* - favicon.ico
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user