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
+1119 -48
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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 })
}
+31
View File
@@ -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 })
}
+63
View File
@@ -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 })
}
}
+208
View File
@@ -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 }
)
}
}
+54
View File
@@ -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 })
}
+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 })
}
+54
View File
@@ -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)
}
+15 -6
View File
@@ -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)
}
+85
View File
@@ -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
})
}
}
+49
View File
@@ -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 })
}
+54
View File
@@ -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 })
}
+28
View File
@@ -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 })
}
+59
View File
@@ -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 })
}
+55
View File
@@ -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
View File
@@ -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 })
}
+3
View File
@@ -0,0 +1,3 @@
export const revalidate = 60 // Revalidate every 60 seconds
export const dynamic = 'force-static'
export const fetchCache = 'force-cache'
+78
View File
@@ -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;
}
}
+4
View File
@@ -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

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