feat: complete memorial website features

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
denshooter
2026-02-18 12:20:33 +01:00
parent 43e9d49620
commit a34d406375
54 changed files with 5989 additions and 248 deletions
+95
View File
@@ -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 })
}