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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user