Initial commit: Maria Malejka memorial website

Next.js 14 + node:sqlite memorial site with:
- Hero section, photo slideshow & gallery
- Memory/thoughts editor (admin)
- Music player with upload
- Video gallery
- Docker Compose deployment
- Responsive warm earth tone design
This commit is contained in:
denshooter
2026-02-16 01:26:37 +01:00
commit bdcfa8f3c5
29 changed files with 3779 additions and 0 deletions
+620
View File
@@ -0,0 +1,620 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Upload,
Plus,
Edit2,
Trash2,
Save,
X,
LogOut,
Image as ImageIcon,
Film,
Music,
FileText,
Eye,
Loader2,
} from 'lucide-react'
type Memory = {
id: number
title: string
content: string
created_at: string
updated_at: string
}
type MediaItem = {
id: number
filename: string
original_name: string | null
type: 'photo' | 'video' | 'music'
caption: string | null
sort_order: number
created_at: string
}
export default function AdminPage() {
const [authed, setAuthed] = useState<boolean | null>(null)
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [loginLoading, setLoginLoading] = useState(false)
const [memories, setMemories] = useState<Memory[]>([])
const [photos, setPhotos] = useState<MediaItem[]>([])
const [videos, setVideos] = useState<MediaItem[]>([])
const [music, setMusic] = useState<MediaItem[]>([])
const [uploading, setUploading] = useState(false)
const [uploadCaption, setUploadCaption] = useState('')
const [uploadStatus, setUploadStatus] = useState('')
const [dragOver, setDragOver] = useState(false)
const [editingMemory, setEditingMemory] = useState<Memory | null>(null)
const [newMemory, setNewMemory] = useState({ title: '', content: '' })
const [showNewMemory, setShowNewMemory] = useState(false)
const [savingMemory, setSavingMemory] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const loadData = useCallback(async () => {
const [memoriesRes, mediaRes] = await Promise.all([
fetch('/api/memories'),
fetch('/api/media'),
])
const [memoriesData, mediaData] = await Promise.all([
memoriesRes.json(),
mediaRes.json(),
])
setMemories(Array.isArray(memoriesData) ? memoriesData : [])
const items: MediaItem[] = Array.isArray(mediaData) ? mediaData : []
setPhotos(items.filter((m) => m.type === 'photo'))
setVideos(items.filter((m) => m.type === 'video'))
setMusic(items.filter((m) => m.type === 'music'))
}, [])
useEffect(() => {
fetch('/api/auth')
.then((r) => r.json())
.then((d) => {
setAuthed(d.authed)
if (d.authed) loadData()
})
.catch(() => setAuthed(false))
}, [loadData])
const login = async (e: React.FormEvent) => {
e.preventDefault()
setLoginLoading(true)
setLoginError('')
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (res.ok) {
setAuthed(true)
loadData()
} else {
setLoginError('Falsches Passwort')
}
} finally {
setLoginLoading(false)
}
}
const logout = async () => {
await fetch('/api/auth', { method: 'DELETE' })
setAuthed(false)
setPassword('')
}
const uploadFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
if (uploadCaption) formData.append('caption', uploadCaption)
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
setUploadStatus(`Fehler: ${err.error || 'Unbekannter Fehler'}`)
return false
}
return true
}
const handleFiles = async (files: File[]) => {
if (files.length === 0) return
setUploading(true)
setUploadStatus(`Lade ${files.length} Datei(en) hoch...`)
let success = 0
for (const file of files) {
const ok = await uploadFile(file)
if (ok) success++
}
setUploadStatus(`${success} von ${files.length} hochgeladen.`)
setUploadCaption('')
await loadData()
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
setTimeout(() => setUploadStatus(''), 3000)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(Array.from(e.target.files || []))
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
handleFiles(Array.from(e.dataTransfer.files))
}
const deleteMedia = async (id: number) => {
if (!confirm('Datei wirklich löschen?')) return
await fetch(`/api/media/${id}`, { method: 'DELETE' })
loadData()
}
const saveMemory = async (e: React.FormEvent) => {
e.preventDefault()
setSavingMemory(true)
try {
if (editingMemory) {
await fetch(`/api/memories/${editingMemory.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editingMemory.title,
content: editingMemory.content,
}),
})
setEditingMemory(null)
} else {
await fetch('/api/memories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newMemory),
})
setNewMemory({ title: '', content: '' })
setShowNewMemory(false)
}
loadData()
} finally {
setSavingMemory(false)
}
}
const deleteMemory = async (id: number) => {
if (!confirm('Erinnerung wirklich löschen?')) return
await fetch(`/api/memories/${id}`, { method: 'DELETE' })
loadData()
}
// ── Loading state ──────────────────────────────────────────────────────────
if (authed === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-cream">
<Loader2 className="animate-spin text-warm-gold" size={28} />
</div>
)
}
// ── Login ─────────────────────────────────────────────────────────────────
if (!authed) {
return (
<div className="min-h-screen flex items-center justify-center bg-cream px-4">
<div className="w-full max-w-sm">
<h1 className="font-cormorant italic text-4xl text-center text-warm-brown mb-1">
Verwaltung
</h1>
<p className="text-center text-warm-brown-light text-sm mb-8 font-lora">
Maria Malejka · Gedenkseite
</p>
<form onSubmit={login} className="space-y-4">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort"
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400 font-lora"
autoFocus
/>
{loginError && (
<p className="text-red-500 text-sm text-center">{loginError}</p>
)}
<button
type="submit"
disabled={loginLoading}
className="w-full py-3 bg-amber-800 hover:bg-amber-700 disabled:opacity-60 text-amber-100 rounded-xl font-lora transition-colors flex items-center justify-center gap-2"
>
{loginLoading && <Loader2 size={16} className="animate-spin" />}
Anmelden
</button>
</form>
<a
href="/"
className="block text-center mt-5 text-sm text-warm-brown-light hover:text-warm-gold transition-colors"
>
Zur Gedenkseite
</a>
</div>
</div>
)
}
// ── Admin UI ───────────────────────────────────────────────────────────────
return (
<div className="min-h-screen bg-cream">
{/* Header */}
<header className="bg-amber-950/95 text-amber-100 px-4 py-3 flex items-center justify-between sticky top-0 z-20 backdrop-blur-sm">
<a
href="/"
className="text-amber-400 hover:text-amber-200 transition-colors flex items-center gap-1.5 text-sm"
>
<Eye size={14} />
Seite ansehen
</a>
<h1 className="font-cormorant italic text-xl text-amber-200">
Verwaltung
</h1>
<button
onClick={logout}
className="text-amber-500 hover:text-amber-200 transition-colors flex items-center gap-1.5 text-sm"
>
<LogOut size={14} />
Abmelden
</button>
</header>
<div className="max-w-3xl mx-auto px-4 py-10 space-y-14">
{/* ── Upload Section ───────────────────────────────────────────────── */}
<section>
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
<Upload size={20} className="text-warm-gold" />
Dateien hochladen
</h2>
<div className="mb-3">
<input
type="text"
value={uploadCaption}
onChange={(e) => setUploadCaption(e.target.value)}
placeholder="Bildunterschrift / Beschreibung (optional)"
className="w-full px-4 py-2.5 rounded-xl border border-warm-border bg-white text-warm-brown text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 font-lora"
/>
</div>
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
className={`border-2 border-dashed rounded-2xl p-8 text-center transition-colors ${
dragOver
? 'border-amber-500 bg-amber-50/50'
: 'border-warm-border hover:border-amber-400'
}`}
>
<Upload className="mx-auto mb-3 text-warm-gold" size={28} />
<p className="text-warm-brown font-lora text-sm mb-1">
Fotos, Videos oder Musik hochladen
</p>
<p className="text-warm-brown-light text-xs mb-4">
JPG, PNG, HEIC (iPhone), MP4, MOV, MP3, M4A mehrere auf einmal möglich
</p>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*,audio/*"
onChange={handleFileChange}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className={`inline-block px-6 py-2.5 rounded-xl cursor-pointer transition-colors font-lora text-sm ${
uploading
? 'bg-amber-200 text-amber-700 cursor-not-allowed'
: 'bg-amber-800 hover:bg-amber-700 text-amber-100'
}`}
>
{uploading ? 'Lädt hoch…' : 'Dateien auswählen'}
</label>
{uploadStatus && (
<p className="mt-3 text-sm text-warm-brown-light font-lora">
{uploadStatus}
</p>
)}
</div>
</section>
{/* ── Photos ──────────────────────────────────────────────────────── */}
<section>
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
<ImageIcon size={20} className="text-warm-gold" />
Fotos
<span className="text-warm-brown-light text-xl">({photos.length})</span>
</h2>
{photos.length === 0 ? (
<p className="text-warm-brown-light text-sm italic font-lora">
Noch keine Fotos hochgeladen.
</p>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
{photos.map((photo) => (
<div key={photo.id} className="relative group aspect-square">
<img
src={`/api/files/${photo.filename}`}
alt={photo.caption || ''}
className="w-full h-full object-cover rounded-xl"
loading="lazy"
/>
<button
onClick={() => deleteMedia(photo.id)}
className="absolute top-1 right-1 bg-red-500/90 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow"
title="Löschen"
>
<Trash2 size={12} />
</button>
{photo.caption && (
<div className="absolute bottom-0 inset-x-0 bg-black/50 rounded-b-xl px-1.5 py-1">
<p className="text-white text-xs truncate">{photo.caption}</p>
</div>
)}
</div>
))}
</div>
)}
</section>
{/* ── Videos ──────────────────────────────────────────────────────── */}
<section>
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
<Film size={20} className="text-warm-gold" />
Videos
<span className="text-warm-brown-light text-xl">({videos.length})</span>
</h2>
{videos.length === 0 ? (
<p className="text-warm-brown-light text-sm italic font-lora">
Noch keine Videos hochgeladen.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{videos.map((video) => (
<div key={video.id} className="relative group">
<video
src={`/api/files/${video.filename}`}
className="w-full aspect-video object-cover rounded-xl bg-stone-900"
preload="metadata"
muted
/>
<p className="text-xs text-warm-brown-light mt-1 truncate font-lora">
{video.original_name || video.caption || video.filename}
</p>
<button
onClick={() => deleteMedia(video.id)}
className="absolute top-1 right-1 bg-red-500/90 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow"
title="Löschen"
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
</section>
{/* ── Music ───────────────────────────────────────────────────────── */}
<section>
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
<Music size={20} className="text-warm-gold" />
Musik
<span className="text-warm-brown-light text-xl">({music.length})</span>
</h2>
{music.length === 0 ? (
<p className="text-warm-brown-light text-sm italic font-lora">
Noch keine Musik hochgeladen.
</p>
) : (
<div className="space-y-2">
{music.map((track, i) => (
<div
key={track.id}
className="flex items-center gap-3 bg-white/60 rounded-xl px-4 py-3 border border-warm-border group"
>
<span className="text-warm-gold/60 text-sm font-cormorant">{i + 1}.</span>
<Music size={14} className="text-warm-gold flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-warm-brown truncate font-lora">
{track.original_name?.replace(/\.[^/.]+$/, '') ||
track.caption ||
track.filename}
</p>
</div>
<button
onClick={() => deleteMedia(track.id)}
className="text-red-400/60 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0"
title="Löschen"
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
)}
</section>
{/* ── Memories ────────────────────────────────────────────────────── */}
<section>
<div className="flex items-center justify-between mb-5">
<h2 className="font-cormorant italic text-3xl text-warm-brown flex items-center gap-2">
<FileText size={20} className="text-warm-gold" />
Erinnerungen
<span className="text-warm-brown-light text-xl">({memories.length})</span>
</h2>
<button
onClick={() => {
setShowNewMemory(true)
setEditingMemory(null)
}}
className="flex items-center gap-1.5 px-4 py-2 bg-amber-800 hover:bg-amber-700 text-amber-100 rounded-xl text-sm transition-colors font-lora"
>
<Plus size={15} />
Neue Erinnerung
</button>
</div>
{/* New / Edit form */}
<AnimatePresence>
{(showNewMemory || editingMemory) && (
<motion.form
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25 }}
onSubmit={saveMemory}
className="bg-white/80 rounded-2xl p-5 mb-6 space-y-3 border border-warm-border overflow-hidden"
>
<div className="flex items-center justify-between">
<span className="text-sm text-warm-brown-light font-lora">
{editingMemory ? 'Erinnerung bearbeiten' : 'Neue Erinnerung'}
</span>
<button
type="button"
onClick={() => {
setShowNewMemory(false)
setEditingMemory(null)
}}
className="text-warm-brown-light hover:text-warm-brown"
>
<X size={16} />
</button>
</div>
<input
type="text"
value={editingMemory ? editingMemory.title : newMemory.title}
onChange={(e) =>
editingMemory
? setEditingMemory({ ...editingMemory, title: e.target.value })
: setNewMemory({ ...newMemory, title: e.target.value })
}
placeholder="Titel"
required
className="w-full px-4 py-2.5 rounded-xl border border-warm-border bg-white font-cormorant italic text-xl text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<textarea
value={editingMemory ? editingMemory.content : newMemory.content}
onChange={(e) =>
editingMemory
? setEditingMemory({ ...editingMemory, content: e.target.value })
: setNewMemory({ ...newMemory, content: e.target.value })
}
placeholder="Deine Gedanken und Erinnerungen…"
required
rows={7}
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white font-lora text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400 resize-none text-sm leading-relaxed"
/>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => {
setShowNewMemory(false)
setEditingMemory(null)
}}
className="px-4 py-2 rounded-xl border border-warm-border text-warm-brown-light hover:text-warm-brown text-sm transition-colors font-lora"
>
Abbrechen
</button>
<button
type="submit"
disabled={savingMemory}
className="px-4 py-2 bg-amber-800 hover:bg-amber-700 disabled:opacity-60 text-amber-100 rounded-xl text-sm transition-colors flex items-center gap-1.5 font-lora"
>
{savingMemory ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
Speichern
</button>
</div>
</motion.form>
)}
</AnimatePresence>
{memories.length === 0 ? (
<p className="text-warm-brown-light text-sm italic font-lora">
Noch keine Erinnerungen geschrieben.
</p>
) : (
<div className="space-y-3">
{memories.map((memory) => (
<div
key={memory.id}
className={`bg-white/60 rounded-2xl p-5 border transition-colors ${
editingMemory?.id === memory.id
? 'border-amber-400'
: 'border-warm-border'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-cormorant italic text-xl text-warm-brown leading-snug">
{memory.title}
</h3>
<p className="text-warm-brown-light text-xs mt-0.5 font-lora">
{new Date(memory.created_at).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
<p className="text-warm-brown/70 text-sm mt-2 line-clamp-3 font-lora leading-relaxed">
{memory.content}
</p>
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => {
setEditingMemory(memory)
setShowNewMemory(false)
}}
className="p-2 text-amber-700 hover:text-amber-500 transition-colors rounded-lg hover:bg-amber-50"
title="Bearbeiten"
>
<Edit2 size={15} />
</button>
<button
onClick={() => deleteMemory(memory.id)}
className="p-2 text-red-400/70 hover:text-red-500 transition-colors rounded-lg hover:bg-red-50"
title="Löschen"
>
<Trash2 size={15} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { createHash } from 'crypto'
import { cookies } from 'next/headers'
function getExpectedToken() {
return createHash('sha256')
.update(process.env.ADMIN_PASSWORD || 'change-me')
.digest('hex')
}
export async function GET() {
const cookieStore = cookies()
const token = cookieStore.get('admin_auth')?.value
return NextResponse.json({ authed: token === getExpectedToken() })
}
export async function POST(req: NextRequest) {
const { password } = await req.json()
if (password !== (process.env.ADMIN_PASSWORD || 'change-me')) {
return NextResponse.json({ error: 'Falsches Passwort' }, { status: 401 })
}
const response = NextResponse.json({ success: true })
response.cookies.set('admin_auth', getExpectedToken(), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
})
return response
}
export async function DELETE() {
const response = NextResponse.json({ success: true })
response.cookies.delete('admin_auth')
return response
}
+76
View File
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
export const runtime = 'nodejs'
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
const MIME: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.aac': 'audio/aac',
'.wav': 'audio/wav',
}
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const filePath = path.join(DATA_DIR, 'uploads', ...params.path)
// Prevent path traversal
const uploadsBase = path.join(DATA_DIR, 'uploads')
if (!filePath.startsWith(uploadsBase)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const ext = path.extname(filePath).toLowerCase()
const contentType = MIME[ext] || 'application/octet-stream'
const stat = fs.statSync(filePath)
// Range requests for video/audio seeking
const range = request.headers.get('range')
if (range) {
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1
const chunksize = end - start + 1
const stream = fs.createReadStream(filePath, { start, end })
return new NextResponse(stream as unknown as ReadableStream, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(chunksize),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
},
})
}
const buffer = fs.readFileSync(filePath)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Content-Length': String(stat.size),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=86400',
},
})
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { cookies } from 'next/headers'
import { createHash } from 'crypto'
import { unlink } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
function isAdmin() {
const token = cookies().get('admin_auth')?.value
const expected = createHash('sha256')
.update(process.env.ADMIN_PASSWORD || 'change-me')
.digest('hex')
return token === expected
}
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
export async function DELETE(
_req: NextRequest,
{ params }: { params: { id: string } }
) {
if (!isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const db = getDb()
const media = db.prepare('SELECT * FROM media WHERE id = ?').get(params.id) as
| { filename: string }
| undefined
if (!media) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Delete file from disk
const filePath = path.join(DATA_DIR, 'uploads', media.filename)
if (existsSync(filePath)) {
await unlink(filePath)
}
db.prepare('DELETE FROM media WHERE id = ?').run(params.id)
return NextResponse.json({ success: true })
}
+17
View File
@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
export async function GET(req: NextRequest) {
const type = req.nextUrl.searchParams.get('type')
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'
const media = type
? db.prepare(query).all(type)
: db.prepare(query).all()
return NextResponse.json(media)
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { cookies } from 'next/headers'
import { createHash } from 'crypto'
function isAdmin() {
const token = cookies().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: { id: string } }
) {
if (!isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { title, content } = await req.json()
const db = getDb()
db.prepare(
"UPDATE memories SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?"
).run(title, content, params.id)
const memory = db.prepare('SELECT * FROM memories WHERE id = ?').get(params.id)
return NextResponse.json(memory)
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: { id: string } }
) {
if (!isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const db = getDb()
db.prepare('DELETE FROM memories WHERE id = ?').run(params.id)
return NextResponse.json({ success: true })
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { cookies } from 'next/headers'
import { createHash } from 'crypto'
function isAdmin() {
const token = cookies().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 memories = db
.prepare('SELECT * FROM memories ORDER BY created_at DESC')
.all()
return NextResponse.json(memories)
}
export async function POST(req: NextRequest) {
if (!isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { title, content } = await req.json()
if (!title?.trim() || !content?.trim()) {
return NextResponse.json(
{ error: 'Titel und Inhalt sind erforderlich' },
{ status: 400 }
)
}
const db = getDb()
const result = db
.prepare('INSERT INTO memories (title, content) VALUES (?, ?)')
.run(title.trim(), content.trim())
const memory = db
.prepare('SELECT * FROM memories WHERE id = ?')
.get(result.lastInsertRowid)
return NextResponse.json(memory, { status: 201 })
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { cookies } from 'next/headers'
import { createHash, randomUUID } from 'crypto'
import { getDb } from '@/lib/db'
export const runtime = 'nodejs'
export const maxDuration = 60
function isAdmin() {
const token = cookies().get('admin_auth')?.value
const expected = createHash('sha256')
.update(process.env.ADMIN_PASSWORD || 'change-me')
.digest('hex')
return token === expected
}
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
const MIME_TO_FOLDER: Record<string, string> = {
'image/jpeg': 'photos',
'image/jpg': 'photos',
'image/png': 'photos',
'image/webp': 'photos',
'image/gif': 'photos',
'image/heic': 'photos',
'image/heif': 'photos',
'video/mp4': 'videos',
'video/quicktime': 'videos',
'video/mov': 'videos',
'video/x-msvideo': 'videos',
'audio/mpeg': 'music',
'audio/mp3': 'music',
'audio/mp4': 'music',
'audio/m4a': 'music',
'audio/aac': 'music',
'audio/wav': 'music',
'audio/x-wav': 'music',
}
const FOLDER_TO_TYPE: Record<string, 'photo' | 'video' | 'music'> = {
photos: 'photo',
videos: 'video',
music: 'music',
}
export async function POST(req: NextRequest) {
if (!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 })
}
// Try to detect type from mime or extension
let mimeType = file.type?.toLowerCase() || ''
const ext = path.extname(file.name).toLowerCase()
// iOS HEIC fallback
if (!mimeType && (ext === '.heic' || ext === '.heif')) {
mimeType = 'image/heic'
}
const folder = MIME_TO_FOLDER[mimeType]
if (!folder) {
return NextResponse.json(
{ error: `Dateityp "${mimeType}" nicht unterstützt` },
{ 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 })
}
+36
View File
@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
background-color: #FAF7F0;
color: #3D2B1F;
}
::selection {
background-color: #E2C97E;
color: #3D2B1F;
}
}
@layer utilities {
/* Custom scrollbar */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: #FAF7F0;
}
::-webkit-scrollbar-thumb {
background: #C4A04A55;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #C4A04A;
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from 'next'
import { Cormorant_Garamond, Lora } from 'next/font/google'
import './globals.css'
const cormorant = Cormorant_Garamond({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700'],
style: ['normal', 'italic'],
variable: '--font-cormorant',
display: 'swap',
})
const lora = Lora({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
style: ['normal', 'italic'],
variable: '--font-lora',
display: 'swap',
})
export const metadata: Metadata = {
title: 'In Erinnerung an Maria Malejka',
description:
'Eine liebevolle Gedenkseite für Maria Malejka · 29. November 1944 10. Februar 2026',
openGraph: {
title: 'In Erinnerung an Maria Malejka',
description: '29. November 1944 10. Februar 2026',
type: 'website',
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="de" className={`${cormorant.variable} ${lora.variable}`}>
<body className="font-lora antialiased">{children}</body>
</html>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { getDb } from '@/lib/db'
import type { Memory, MediaItem } from '@/lib/types'
import HeroSection from '@/components/HeroSection'
import PhotoSlideshow from '@/components/PhotoSlideshow'
import PhotoGallery from '@/components/PhotoGallery'
import MemorySection from '@/components/MemorySection'
import MusicPlayer from '@/components/MusicPlayer'
import VideoGallery from '@/components/VideoGallery'
export const dynamic = 'force-dynamic'
export default async function HomePage() {
const db = getDb()
const photos = db
.prepare("SELECT * FROM media WHERE type = 'photo' ORDER BY sort_order, created_at")
.all() as MediaItem[]
const videos = db
.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at")
.all() as MediaItem[]
const music = db
.prepare("SELECT * FROM media WHERE type = 'music' ORDER BY sort_order, created_at")
.all() as MediaItem[]
const memories = db
.prepare('SELECT * FROM memories ORDER BY created_at DESC')
.all() as Memory[]
return (
<main className="min-h-screen bg-cream">
{/* Hero */}
<HeroSection heroPhoto={photos[0]?.filename ?? null} />
{/* Navigation anchors */}
<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">
{photos.length > 0 && (
<a
href="#bilder"
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
>
Bilder
</a>
)}
{memories.length > 0 && (
<a
href="#erinnerungen"
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
>
Erinnerungen
</a>
)}
{videos.length > 0 && (
<a
href="#videos"
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
>
Videos
</a>
)}
</div>
</nav>
{/* Photo section */}
{photos.length > 0 && (
<section id="bilder" className="py-16 sm:py-20">
<div className="max-w-6xl mx-auto px-4">
<div className="text-center mb-12">
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
Erinnerungen in Bildern
</h2>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-16 bg-warm-gold/40" />
<span className="text-warm-gold text-xl"></span>
<div className="h-px w-16 bg-warm-gold/40" />
</div>
</div>
{photos.length > 1 && <PhotoSlideshow photos={photos} />}
<PhotoGallery photos={photos} />
</div>
</section>
)}
{/* Memories */}
<MemorySection memories={memories} />
{/* Videos */}
<VideoGallery videos={videos} />
{/* Music player */}
<MusicPlayer tracks={music} />
{/* Footer */}
<footer className="py-16 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-3xl text-warm-gold mb-3">
In liebevoller Erinnerung
</p>
<p className="font-lora text-warm-brown-light text-sm tracking-wider">
Maria Malejka
</p>
<p className="font-lora text-warm-brown-light/60 text-xs mt-1 tracking-widest">
29. November 1944 10. Februar 2026
</p>
{/* Ornament */}
<div className="flex items-center justify-center gap-3 mt-6 mb-6">
<div className="h-px w-12 bg-warm-gold/20" />
<span className="text-warm-gold/40 text-sm"></span>
<div className="h-px w-12 bg-warm-gold/20" />
</div>
<p className="font-cormorant italic text-warm-brown-light/60 text-lg">
Du bist nicht fort, nur ein Schritt voraus."
</p>
</div>
{/* Hidden admin link */}
<a
href="/admin"
className="mt-10 inline-block text-warm-border/30 hover:text-warm-border/60 text-xs transition-colors"
title="Verwaltung"
>
·
</a>
</footer>
</main>
)
}