Files
oma-memorial/src/app/admin/page.tsx
T
denshooter e3d4f7c96e
Build and Deploy / build-and-deploy (push) Has been cancelled
Fix bugs and improve code quality
- Fix memory leak: revoke object URLs in TimelineUploadSection
- Fix broken timeline photo URLs in admin panel (/data/... → /api/files/...)
- Remove duplicate bad-word list in AI moderation function
- Add input validation for type/status params in media and contributions API
- Add bulk-approve button in admin for pending contributions
- Add PATCH endpoint for bulk-approving all pending contributions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 01:02:20 +01:00

1961 lines
98 KiB
TypeScript

'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,
Flame,
User,
Heart,
} from 'lucide-react'
type Memory = {
id: number
title: string
content: string
created_at: string
updated_at: string
}
type Candle = {
id: number
name: string
message: string | null
created_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
}
type TimelineEntry = {
id: number
year: string
month: string | null
day: string | null
title: string
description: string | null
location: string | null
media_filenames: string | null
sort_order: number
}
type Recipe = {
id: number
title: string
description: string | null
ingredients: string | null
instructions: string | null
sort_order: number
}
type TimelineContribution = {
id: number
name: string
email: string | null
type: 'memory' | 'timeline' | 'media' | 'recipe'
year: string | null
month: string | null
day: string | null
title: string | null
content: string | null
location: string | null
media_filenames: string | null
status: 'pending' | 'approved' | 'rejected' | 'flagged'
moderation_reason: string | null
created_at: string
}
type FamilyUpload = {
id: number
filename: string
original_name: string | null
type: 'photo' | 'video'
caption: string | null
status: string
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 [candles, setCandles] = useState<Candle[]>([])
const [photos, setPhotos] = useState<MediaItem[]>([])
const [videos, setVideos] = useState<MediaItem[]>([])
const [music, setMusic] = useState<MediaItem[]>([])
const [timeline, setTimeline] = useState<TimelineEntry[]>([])
const [recipes, setRecipes] = useState<Recipe[]>([])
const [familyUploads, setFamilyUploads] = useState<FamilyUpload[]>([])
const [timelineContributions, setTimelineContributions] = useState<TimelineContribution[]>([])
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 [editingCandle, setEditingCandle] = useState<Candle | null>(null)
const [contributionFilter, setContributionFilter] = useState<'all' | 'review' | 'approved' | 'rejected'>('review')
const [editingContribution, setEditingContribution] = useState<TimelineContribution | null>(null)
const [editingTimelineEntry, setEditingTimelineEntry] = useState<TimelineEntry | null>(null)
const [editingRecipe, setEditingRecipe] = useState<Recipe | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const loadData = useCallback(async () => {
const [memoriesRes, candlesRes, mediaRes, timelineRes, recipesRes, uploadsRes, contributionsRes] = await Promise.all([
fetch('/api/memories'),
fetch('/api/candles'),
fetch('/api/media'),
fetch('/api/timeline'),
fetch('/api/recipes'),
fetch('/api/family-upload'),
fetch('/api/contributions'), // New unified endpoint
])
const memoriesData = memoriesRes.ok ? await memoriesRes.json() : []
const candlesData = candlesRes.ok ? await candlesRes.json() : []
const mediaData = mediaRes.ok ? await mediaRes.json() : []
const timelineData = timelineRes.ok ? await timelineRes.json() : []
const recipesData = recipesRes.ok ? await recipesRes.json() : []
const uploadsData = uploadsRes.ok ? await uploadsRes.json() : []
const contributionsData = contributionsRes.ok ? await contributionsRes.json() : []
setMemories(Array.isArray(memoriesData) ? memoriesData : [])
setCandles(Array.isArray(candlesData) ? candlesData : [])
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'))
setTimeline(Array.isArray(timelineData) ? timelineData : [])
setRecipes(Array.isArray(recipesData) ? recipesData : [])
setFamilyUploads(Array.isArray(uploadsData) ? uploadsData : [])
setTimelineContributions(Array.isArray(contributionsData) ? contributionsData : [])
}, [])
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-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{photos.map((photo) => (
<div key={photo.id} className="relative group">
<img
src={`/api/files/${photo.filename}`}
alt={photo.caption || ''}
className="w-full aspect-square 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>
<input
type="text"
value={photo.caption || ''}
onChange={async (e) => {
const newCaption = e.target.value
await fetch(`/api/media/${photo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caption: newCaption }),
})
setPhotos(photos.map(p => p.id === photo.id ? { ...p, caption: newCaption } : p))
}}
placeholder="Bildunterschrift..."
className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs px-2 py-1 rounded-b-xl opacity-0 group-hover:opacity-100 transition-opacity focus:opacity-100 outline-none"
/>
</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 border-warm-border"
>
{editingMemory?.id === memory.id ? (
// Edit mode
<div className="space-y-3">
<input
type="text"
value={editingMemory.title}
onChange={(e) => setEditingMemory({ ...editingMemory, title: e.target.value })}
placeholder="Titel"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm font-cormorant italic text-lg"
/>
<textarea
value={editingMemory.content}
onChange={(e) => setEditingMemory({ ...editingMemory, content: e.target.value })}
placeholder="Inhalt"
rows={6}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm resize-none font-lora"
/>
<div className="flex gap-2">
<button
onClick={async () => {
await fetch(`/api/memories/${editingMemory.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editingMemory.title,
content: editingMemory.content,
}),
})
setEditingMemory(null)
loadData()
}}
className="px-4 py-2 bg-warm-gold text-white rounded-lg text-sm hover:bg-warm-gold/90 transition-colors"
>
Speichern
</button>
<button
onClick={() => setEditingMemory(null)}
className="px-4 py-2 bg-warm-brown-light/20 text-warm-brown rounded-lg text-sm hover:bg-warm-brown-light/30 transition-colors"
>
Abbrechen
</button>
</div>
</div>
) : (
// View mode
<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>
)}
{/* User Memory Contributions */}
{timelineContributions.filter(c => c.type === 'memory').length > 0 && (
<div className="mt-6 border-t border-warm-border pt-5">
<h3 className="font-lora text-sm text-warm-brown mb-3 flex items-center gap-2">
<Heart size={14} className="text-warm-gold" />
Nutzer-Erinnerungen ({timelineContributions.filter(c => c.type === 'memory').length})
</h3>
<div className="space-y-2">
{timelineContributions
.filter(c => c.type === 'memory')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map(c => {
const photos = c.media_filenames ? c.media_filenames.split(',').filter(Boolean) : []
return (
<div key={`mem-${c.id}`} className={`rounded-lg p-3 border ${
c.status === 'flagged' ? 'bg-red-50 border-red-200' :
c.status === 'approved' ? 'bg-green-50/50 border-green-200' :
c.status === 'rejected' ? 'bg-red-50/30 border-red-100' :
'bg-amber-50 border-amber-200'
}`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="font-lora text-sm text-warm-brown font-medium">{c.title || 'Erinnerung'}</span>
<span className="text-xs text-warm-brown-light/50">von {c.name}</span>
{c.status === 'flagged' && <span className="text-xs px-1.5 py-0.5 bg-red-200 text-red-800 rounded-full">🚩</span>}
{c.status === 'approved' && <span className="text-xs px-1.5 py-0.5 bg-green-200 text-green-800 rounded-full"></span>}
{c.status === 'rejected' && <span className="text-xs px-1.5 py-0.5 bg-red-100 text-red-600 rounded-full"></span>}
</div>
{c.moderation_reason && (
<p className="text-xs text-red-700 bg-red-100 rounded px-2 py-1 mb-1">🚩 {c.moderation_reason}</p>
)}
<p className="text-warm-brown-light/70 text-xs line-clamp-2">{c.content}</p>
{photos.length > 0 && (
<div className="flex gap-1.5 mt-2">
{photos.slice(0, 4).map((f, i) => (
<img key={i} src={`/api/files/${f.trim()}`} alt="" className="w-12 h-12 object-cover rounded-lg" />
))}
</div>
)}
</div>
<div className="flex gap-1 ml-2">
{(c.status === 'pending' || c.status === 'flagged') && (
<>
<button onClick={async () => { await fetch(`/api/contributions/${c.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'approved' }) }); loadData() }} className="p-1.5 text-green-600 hover:text-green-700" title="Freigeben"><Eye size={13} /></button>
<button onClick={async () => { await fetch(`/api/contributions/${c.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'rejected' }) }); loadData() }} className="p-1.5 text-red-400 hover:text-red-500" title="Ablehnen"><X size={13} /></button>
</>
)}
<button onClick={async () => { if (confirm('Löschen?')) { await fetch(`/api/contributions/${c.id}`, { method: 'DELETE' }); loadData() } }} className="p-1.5 text-red-400/60 hover:text-red-500" title="Löschen"><Trash2 size={13} /></button>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</section>
<section className="bg-white/40 backdrop-blur-sm rounded-3xl p-6 sm:p-8 shadow-sm border border-warm-border">
<div className="flex items-center justify-between mb-6">
<h2 className="font-cormorant italic text-3xl text-warm-brown flex items-center gap-3">
<Flame size={28} className="text-warm-gold" />
Kerzen ({candles.length})
</h2>
</div>
<div className="space-y-3">
{candles.length === 0 ? (
<p className="text-warm-brown-light text-sm italic font-lora">
Noch keine Kerzen angezündet.
</p>
) : (
candles.map((candle) => (
<div key={candle.id} className="bg-white/60 rounded-xl p-4 border border-warm-border">
{editingCandle?.id === candle.id ? (
// Edit mode
<div className="space-y-3">
<input
type="text"
value={editingCandle.name}
onChange={(e) => setEditingCandle({ ...editingCandle, name: e.target.value })}
placeholder="Name"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm font-lora font-semibold"
/>
<textarea
value={editingCandle.message || ''}
onChange={(e) => setEditingCandle({ ...editingCandle, message: e.target.value })}
placeholder="Nachricht (optional)"
rows={3}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm resize-none font-lora"
/>
<div className="flex gap-2">
<button
onClick={async () => {
await fetch(`/api/candles/${editingCandle.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editingCandle.name,
message: editingCandle.message || null,
}),
})
setEditingCandle(null)
loadData()
}}
className="px-4 py-2 bg-warm-gold text-white rounded-lg text-sm hover:bg-warm-gold/90 transition-colors"
>
Speichern
</button>
<button
onClick={() => setEditingCandle(null)}
className="px-4 py-2 bg-warm-brown-light/20 text-warm-brown rounded-lg text-sm hover:bg-warm-brown-light/30 transition-colors"
>
Abbrechen
</button>
</div>
</div>
) : (
// View mode
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-lora font-semibold text-warm-brown">{candle.name}</span>
<span className="text-warm-brown-light/40 text-xs">·</span>
<span className="text-warm-brown-light/50 text-xs">
{new Date(candle.created_at + 'Z').toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
{candle.message && (
<p className="text-warm-brown-light/70 text-sm mt-1 font-lora line-clamp-2">
{candle.message}
</p>
)}
</div>
<div className="flex gap-1">
<button
onClick={() => setEditingCandle(candle)}
className="p-2 text-amber-700 hover:text-amber-500 transition-colors"
title="Bearbeiten"
>
<Edit2 size={15} />
</button>
<button
onClick={async () => {
if (confirm(`Kerze von "${candle.name}" löschen?`)) {
await fetch(`/api/candles/${candle.id}`, { method: 'DELETE' })
loadData()
}
}}
className="p-2 text-red-400/70 hover:text-red-500 transition-colors"
title="Löschen"
>
<Trash2 size={15} />
</button>
</div>
</div>
)}
</div>
))
)}
</div>
</section>
{/* Timeline Section */}
<section className="bg-white/40 backdrop-blur-sm rounded-3xl p-6 sm:p-8 shadow-sm border border-warm-border">
<div className="mb-6">
<h2 className="font-cormorant italic text-3xl text-warm-brown flex items-center gap-3 mb-4">
<FileText size={28} className="text-warm-gold" />
Zeitstrahl
</h2>
{/* Add new timeline entry form */}
<div className="bg-white/60 rounded-xl p-5 border border-warm-border mb-4">
<h3 className="font-lora text-sm text-warm-brown mb-3">Neuer Eintrag</h3>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2">
<input
type="text"
placeholder="Jahr *"
id="newTimelineYear"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="text"
placeholder="Monat"
id="newTimelineMonth"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="text"
placeholder="Tag"
id="newTimelineDay"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
</div>
<input
type="text"
placeholder="Titel *"
id="newTimelineTitle"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="text"
placeholder="Ort"
id="newTimelineLocation"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<textarea
placeholder="Beschreibung"
id="newTimelineDescription"
rows={3}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm resize-none"
/>
<input
type="file"
accept="image/*"
multiple
id="newTimelinePhotos"
className="block w-full text-xs text-warm-brown file:mr-2 file:py-1 file:px-3 file:rounded file:border-0 file:text-xs file:bg-warm-gold/20 file:text-warm-brown hover:file:bg-warm-gold/30 file:cursor-pointer"
/>
<button
onClick={async () => {
const year = (document.getElementById('newTimelineYear') as HTMLInputElement).value
const title = (document.getElementById('newTimelineTitle') as HTMLInputElement).value
if (!year || !title) {
alert('Jahr und Titel sind Pflichtfelder')
return
}
const month = (document.getElementById('newTimelineMonth') as HTMLInputElement).value
const day = (document.getElementById('newTimelineDay') as HTMLInputElement).value
const location = (document.getElementById('newTimelineLocation') as HTMLInputElement).value
const description = (document.getElementById('newTimelineDescription') as HTMLTextAreaElement).value
const fileInput = document.getElementById('newTimelinePhotos') as HTMLInputElement
let media_filenames = null
if (fileInput.files && fileInput.files.length > 0) {
const formData = new FormData()
Array.from(fileInput.files).forEach(file => formData.append('files', file))
const uploadRes = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const uploadData = await uploadRes.json()
media_filenames = uploadData.filenames.join(',')
}
await fetch('/api/timeline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
year,
month: month || null,
day: day || null,
title,
location: location || null,
description: description || null,
media_filenames,
}),
})
// Reset form
;(document.getElementById('newTimelineYear') as HTMLInputElement).value = ''
;(document.getElementById('newTimelineMonth') as HTMLInputElement).value = ''
;(document.getElementById('newTimelineDay') as HTMLInputElement).value = ''
;(document.getElementById('newTimelineTitle') as HTMLInputElement).value = ''
;(document.getElementById('newTimelineLocation') as HTMLInputElement).value = ''
;(document.getElementById('newTimelineDescription') as HTMLTextAreaElement).value = ''
;(document.getElementById('newTimelinePhotos') as HTMLInputElement).value = ''
loadData()
}}
className="w-full px-4 py-2 bg-warm-gold hover:bg-amber-600 text-white rounded-lg transition-colors text-sm font-lora"
>
Hinzufügen
</button>
</div>
</div>
</div>
<div className="space-y-3">
{timeline.map((entry) => (
<div key={entry.id} className="bg-white/60 rounded-xl p-4 border border-warm-border">
{editingTimelineEntry?.id === entry.id ? (
// Edit mode
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2">
<input
type="text"
value={editingTimelineEntry.year}
onChange={(e) => setEditingTimelineEntry({ ...editingTimelineEntry, year: e.target.value })}
placeholder="Jahr"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="text"
value={editingTimelineEntry.month || ''}
onChange={(e) => setEditingTimelineEntry({ ...editingTimelineEntry, month: e.target.value })}
placeholder="Monat"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="text"
value={editingTimelineEntry.day || ''}
onChange={(e) => setEditingTimelineEntry({ ...editingTimelineEntry, day: e.target.value })}
placeholder="Tag"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
</div>
<input
type="text"
value={editingTimelineEntry.title}
onChange={(e) => setEditingTimelineEntry({ ...editingTimelineEntry, title: e.target.value })}
placeholder="Titel"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="text"
value={editingTimelineEntry.location || ''}
onChange={(e) => setEditingTimelineEntry({ ...editingTimelineEntry, location: e.target.value })}
placeholder="Ort"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<textarea
value={editingTimelineEntry.description || ''}
onChange={(e) => setEditingTimelineEntry({ ...editingTimelineEntry, description: e.target.value })}
placeholder="Beschreibung"
rows={3}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm resize-none"
/>
<div className="flex gap-2">
<button
onClick={async () => {
await fetch(`/api/timeline/${editingTimelineEntry.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
year: editingTimelineEntry.year,
month: editingTimelineEntry.month || null,
day: editingTimelineEntry.day || null,
title: editingTimelineEntry.title,
location: editingTimelineEntry.location || null,
description: editingTimelineEntry.description || null,
media_filenames: editingTimelineEntry.media_filenames || null,
}),
})
setEditingTimelineEntry(null)
loadData()
}}
className="px-4 py-2 bg-warm-gold text-white rounded-lg text-sm hover:bg-warm-gold/90 transition-colors"
>
Speichern
</button>
<button
onClick={() => setEditingTimelineEntry(null)}
className="px-4 py-2 bg-warm-brown-light/20 text-warm-brown rounded-lg text-sm hover:bg-warm-brown-light/30 transition-colors"
>
Abbrechen
</button>
</div>
</div>
) : (
// View mode
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<div className="font-cormorant text-xl text-warm-gold">
{entry.day && entry.month ? `${entry.day}.${entry.month}.${entry.year}` : entry.month ? `${entry.month}.${entry.year}` : entry.year}
</div>
{entry.location && (
<>
<span className="text-warm-brown-light/40 text-sm">·</span>
<span className="text-warm-brown-light/60 text-sm">{entry.location}</span>
</>
)}
</div>
<h4 className="font-cormorant italic text-lg text-warm-brown">{entry.title}</h4>
{entry.description && (
<p className="text-warm-brown-light/70 text-sm mt-1 font-lora line-clamp-2">{entry.description}</p>
)}
{entry.media_filenames && (
<div className="flex gap-1 mt-2">
{entry.media_filenames.split(',').slice(0, 3).map((filename, idx) => (
<img
key={idx}
src={`/api/files/${filename.trim()}`}
alt=""
className="w-10 h-10 object-cover rounded border border-warm-border"
/>
))}
{entry.media_filenames.split(',').length > 3 && (
<div className="w-10 h-10 rounded border border-warm-border bg-warm-brown-light/10 flex items-center justify-center text-xs text-warm-brown-light">
+{entry.media_filenames.split(',').length - 3}
</div>
)}
</div>
)}
</div>
<div className="flex gap-1">
<button
onClick={() => setEditingTimelineEntry(entry)}
className="p-2 text-amber-700 hover:text-amber-500 transition-colors"
>
<Edit2 size={15} />
</button>
<button
onClick={async () => {
if (confirm('Wirklich löschen?')) {
await fetch(`/api/timeline/${entry.id}`, { method: 'DELETE' })
loadData()
}
}}
className="p-2 text-red-400/70 hover:text-red-500 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
</div>
)}
</div>
))}
</div>
{/* User Timeline Contributions */}
{timelineContributions.filter(c => c.type === 'timeline').length > 0 && (
<div className="mt-6 border-t border-warm-border pt-5">
<h3 className="font-lora text-sm text-warm-brown mb-3 flex items-center gap-2">
<User size={14} className="text-warm-gold" />
Nutzer-Beiträge ({timelineContributions.filter(c => c.type === 'timeline').length})
</h3>
<div className="space-y-2">
{timelineContributions
.filter(c => c.type === 'timeline')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map(c => {
const isEditing = editingContribution?.id === c.id
return (
<div key={`tc-${c.id}`} className={`rounded-lg p-3 border ${
c.status === 'flagged' ? 'bg-red-50 border-red-200' :
c.status === 'approved' ? 'bg-green-50/50 border-green-200' :
c.status === 'rejected' ? 'bg-red-50/30 border-red-100' :
'bg-amber-50 border-amber-200'
}`}>
{isEditing ? (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
<input
type="text"
value={editingContribution.day || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, day: e.target.value })}
placeholder="Tag"
className="px-2 py-1.5 rounded border border-warm-border bg-white text-warm-brown text-xs"
/>
<input
type="text"
value={editingContribution.month || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, month: e.target.value })}
placeholder="Monat"
className="px-2 py-1.5 rounded border border-warm-border bg-white text-warm-brown text-xs"
/>
<input
type="text"
value={editingContribution.year || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, year: e.target.value })}
placeholder="Jahr"
className="px-2 py-1.5 rounded border border-warm-border bg-white text-warm-brown text-xs"
/>
</div>
<input
type="text"
value={editingContribution.title || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, title: e.target.value })}
placeholder="Titel"
className="w-full px-2 py-1.5 rounded border border-warm-border bg-white text-warm-brown text-xs"
/>
<input
type="text"
value={editingContribution.location || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, location: e.target.value })}
placeholder="Ort (optional)"
className="w-full px-2 py-1.5 rounded border border-warm-border bg-white text-warm-brown text-xs"
/>
<textarea
value={editingContribution.content || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, content: e.target.value })}
placeholder="Beschreibung (optional)"
rows={2}
className="w-full px-2 py-1.5 rounded border border-warm-border bg-white text-warm-brown text-xs resize-none"
/>
<div className="flex items-center gap-2">
<input
type="text"
value={editingContribution.name || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, name: e.target.value })}
placeholder="Name des Einreichers"
className="flex-1 px-2 py-1.5 rounded border border-warm-border bg-white text-warm-brown text-xs"
/>
<button
onClick={() => setEditingContribution({ ...editingContribution, name: '' })}
title="Namen entfernen (wird nicht angezeigt)"
className="px-2 py-1.5 rounded border border-warm-border bg-white text-warm-brown-light hover:text-red-500 text-xs transition-colors"
>
Name verbergen
</button>
</div>
<div className="flex gap-2">
<button
onClick={async () => {
await fetch(`/api/contributions/${editingContribution.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editingContribution.name,
type: editingContribution.type,
year: editingContribution.year,
month: editingContribution.month,
day: editingContribution.day,
title: editingContribution.title,
content: editingContribution.content,
location: editingContribution.location,
media_filenames: editingContribution.media_filenames,
status: editingContribution.status,
}),
})
setEditingContribution(null)
loadData()
}}
className="px-3 py-1.5 bg-warm-gold text-white rounded text-xs hover:bg-amber-600 transition-colors"
>
Speichern
</button>
<button
onClick={() => setEditingContribution(null)}
className="px-3 py-1.5 bg-warm-brown-light/20 text-warm-brown rounded text-xs hover:bg-warm-brown-light/30 transition-colors"
>
Abbrechen
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-lora text-sm text-warm-brown font-medium">{c.title || 'Ohne Titel'}</span>
{c.year && <span className="text-warm-gold text-xs">{c.day ? `${c.day}.` : ''}{c.month ? `${c.month}.` : ''}{c.year}</span>}
{c.status === 'flagged' && <span className="text-xs px-1.5 py-0.5 bg-red-200 text-red-800 rounded-full">🚩</span>}
{c.status === 'approved' && <span className="text-xs px-1.5 py-0.5 bg-green-200 text-green-800 rounded-full"></span>}
{c.status === 'rejected' && <span className="text-xs px-1.5 py-0.5 bg-red-100 text-red-600 rounded-full"></span>}
</div>
<p className="text-warm-brown-light/60 text-xs truncate">
{c.name ? c.name : <span className="italic text-warm-brown-light/40">Kein Name</span>}
{c.content ? ` · ${c.content}` : ''}
</p>
</div>
<div className="flex gap-1 ml-2">
<button onClick={() => setEditingContribution(c)} className="p-1.5 text-amber-700 hover:text-amber-500" title="Bearbeiten"><Edit2 size={13} /></button>
{(c.status === 'pending' || c.status === 'flagged') && (
<>
<button onClick={async () => { await fetch(`/api/contributions/${c.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'approved' }) }); loadData() }} className="p-1.5 text-green-600 hover:text-green-700" title="Freigeben"><Eye size={13} /></button>
<button onClick={async () => { await fetch(`/api/contributions/${c.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'rejected' }) }); loadData() }} className="p-1.5 text-red-400 hover:text-red-500" title="Ablehnen"><X size={13} /></button>
</>
)}
<button onClick={async () => { if (confirm('Löschen?')) { await fetch(`/api/contributions/${c.id}`, { method: 'DELETE' }); loadData() } }} className="p-1.5 text-red-400/60 hover:text-red-500" title="Löschen"><Trash2 size={13} /></button>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)}
</section>
<section className="bg-white/40 backdrop-blur-sm rounded-3xl p-6 sm:p-8 shadow-sm border border-warm-border">
<div className="flex items-center justify-between mb-6">
<h2 className="font-cormorant italic text-3xl text-warm-brown flex items-center gap-3">
<FileText size={28} className="text-warm-gold" />
Rezepte
</h2>
<button
onClick={async () => {
const title = prompt('Rezept-Titel:')
const description = prompt('Beschreibung (optional):')
const ingredients = prompt('Zutaten (eine pro Zeile):')
const instructions = prompt('Zubereitung (eine pro Zeile):')
if (!title) return
await fetch('/api/recipes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: description || null,
ingredients: ingredients || null,
instructions: instructions || null,
}),
})
loadData()
}}
className="flex items-center gap-2 px-4 py-2 bg-warm-gold hover:bg-amber-600 text-white rounded-xl transition-colors text-sm font-lora"
>
<Plus size={16} />
Neu
</button>
</div>
<div className="space-y-3">
{recipes.map((recipe) => (
<div key={recipe.id} className="bg-white/60 rounded-xl p-4 border border-warm-border">
{editingRecipe?.id === recipe.id ? (
// Edit mode
<div className="space-y-3">
<input
type="text"
value={editingRecipe.title}
onChange={(e) => setEditingRecipe({ ...editingRecipe, title: e.target.value })}
placeholder="Titel"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<textarea
value={editingRecipe.description || ''}
onChange={(e) => setEditingRecipe({ ...editingRecipe, description: e.target.value })}
placeholder="Beschreibung"
rows={2}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm resize-none"
/>
<textarea
value={editingRecipe.ingredients || ''}
onChange={(e) => setEditingRecipe({ ...editingRecipe, ingredients: e.target.value })}
placeholder="Zutaten (eine pro Zeile)"
rows={4}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm resize-none"
/>
<textarea
value={editingRecipe.instructions || ''}
onChange={(e) => setEditingRecipe({ ...editingRecipe, instructions: e.target.value })}
placeholder="Zubereitung (eine pro Zeile)"
rows={4}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm resize-none"
/>
<div className="flex gap-2">
<button
onClick={async () => {
await fetch(`/api/recipes/${editingRecipe.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editingRecipe.title,
description: editingRecipe.description || null,
ingredients: editingRecipe.ingredients || null,
instructions: editingRecipe.instructions || null,
}),
})
setEditingRecipe(null)
loadData()
}}
className="px-4 py-2 bg-warm-gold text-white rounded-lg text-sm hover:bg-warm-gold/90 transition-colors"
>
Speichern
</button>
<button
onClick={() => setEditingRecipe(null)}
className="px-4 py-2 bg-warm-brown-light/20 text-warm-brown rounded-lg text-sm hover:bg-warm-brown-light/30 transition-colors"
>
Abbrechen
</button>
</div>
</div>
) : (
// View mode
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-cormorant italic text-lg text-warm-brown">{recipe.title}</h4>
{recipe.description && (
<p className="text-warm-brown-light/70 text-sm mt-1 font-lora line-clamp-2">{recipe.description}</p>
)}
</div>
<div className="flex gap-1">
<button
onClick={() => setEditingRecipe(recipe)}
className="p-2 text-amber-700 hover:text-amber-500 transition-colors"
>
<Edit2 size={15} />
</button>
<button
onClick={async () => {
if (confirm('Wirklich löschen?')) {
await fetch(`/api/recipes/${recipe.id}`, { method: 'DELETE' })
loadData()
}
}}
className="p-2 text-red-400/70 hover:text-red-500 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
</div>
)}
</div>
))}
</div>
</section>
{/* Family Uploads Section */}
<section className="bg-white/40 backdrop-blur-sm rounded-3xl p-6 sm:p-8 shadow-sm border border-warm-border">
<h2 className="font-cormorant italic text-3xl text-warm-brown flex items-center gap-3 mb-6">
<Upload size={28} className="text-warm-gold" />
Familien-Uploads ({familyUploads.filter(u => u.status === 'pending').length} ausstehend)
</h2>
<div className="space-y-3">
{familyUploads.length === 0 ? (
<p className="text-warm-brown-light text-sm italic font-lora">Keine Uploads.</p>
) : (
familyUploads.map((upload) => (
<div key={upload.id} className={`rounded-xl p-4 border flex items-start gap-4 ${
upload.status === 'pending' ? 'bg-amber-50 border-amber-200' : 'bg-white/60 border-warm-border'
}`}>
{/* Preview */}
<div className="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden bg-warm-brown/10">
{upload.type === 'photo' ? (
<img
src={`/api/files/${upload.filename}`}
alt={upload.original_name || 'Upload'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Film size={24} className="text-warm-brown/50" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-lora font-semibold text-warm-brown text-sm">
{upload.caption || 'Anonym'}
</span>
{upload.status === 'pending' && (
<span className="text-xs px-2 py-0.5 bg-amber-200 text-amber-800 rounded-full">Ausstehend</span>
)}
{upload.status === 'approved' && (
<span className="text-xs px-2 py-0.5 bg-green-200 text-green-800 rounded-full">Freigegeben</span>
)}
</div>
<p className="text-warm-brown-light text-xs font-lora truncate">{upload.original_name}</p>
<p className="text-warm-brown/50 text-xs mt-1">
{upload.created_at ? new Date(upload.created_at).toLocaleString('de-DE') : ''}
</p>
</div>
<div className="flex gap-1 flex-shrink-0">
{upload.status === 'pending' && (
<>
<button
onClick={async () => {
await fetch(`/api/family-upload/${upload.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved' }),
})
loadData()
}}
className="p-2 text-green-600 hover:text-green-700 transition-colors"
title="Freigeben"
>
<Eye size={15} />
</button>
<button
onClick={async () => {
if (confirm('Wirklich ablehnen und löschen?')) {
await fetch(`/api/family-upload/${upload.id}`, { method: 'DELETE' })
loadData()
}
}}
className="p-2 text-red-400/70 hover:text-red-500 transition-colors"
title="Ablehnen"
>
<Trash2 size={15} />
</button>
</>
)}
{upload.status === 'approved' && (
<button
onClick={async () => {
if (confirm('Upload entfernen?')) {
await fetch(`/api/family-upload/${upload.id}`, { method: 'DELETE' })
loadData()
}
}}
className="p-2 text-red-400/70 hover:text-red-500 transition-colors"
>
<Trash2 size={15} />
</button>
)}
</div>
</div>
))
)}
</div>
{/* User Photo Contributions */}
{timelineContributions.filter(c => c.type === 'media' && c.media_filenames).length > 0 && (
<div className="mt-6 border-t border-warm-border pt-5">
<h3 className="font-lora text-sm text-warm-brown mb-3 flex items-center gap-2">
<User size={14} className="text-warm-gold" />
Nutzer Foto-Uploads ({timelineContributions.filter(c => c.type === 'media' && c.media_filenames).length})
</h3>
<div className="space-y-2">
{timelineContributions
.filter(c => c.type === 'media' && c.media_filenames)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map(c => {
const photos = c.media_filenames ? c.media_filenames.split(',').filter(Boolean) : []
return (
<div key={`mc-${c.id}`} className={`rounded-lg p-3 border flex items-start gap-3 ${
c.status === 'flagged' ? 'bg-red-50 border-red-200' :
c.status === 'approved' ? 'bg-green-50/50 border-green-200' :
'bg-amber-50 border-amber-200'
}`}>
<div className="flex gap-1.5 flex-shrink-0">
{photos.slice(0, 3).map((f, i) => (
<img key={i} src={`/api/files/${f.trim()}`} alt="" className="w-14 h-14 object-cover rounded-lg" />
))}
{photos.length > 3 && <div className="w-14 h-14 bg-warm-brown/10 rounded-lg flex items-center justify-center text-xs text-warm-brown-light">+{photos.length - 3}</div>}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-lora text-sm text-warm-brown">{c.name}</span>
<span className="text-xs text-warm-brown-light/50">{photos.length} Foto{photos.length > 1 ? 's' : ''}</span>
{c.status === 'approved' && <span className="text-xs px-1.5 py-0.5 bg-green-200 text-green-800 rounded-full"></span>}
{c.status === 'flagged' && <span className="text-xs px-1.5 py-0.5 bg-red-200 text-red-800 rounded-full">🚩</span>}
</div>
<p className="text-warm-brown/50 text-xs mt-0.5">{c.created_at ? new Date(c.created_at).toLocaleString('de-DE') : ''}</p>
</div>
<div className="flex gap-1 flex-shrink-0">
{(c.status === 'pending' || c.status === 'flagged') && (
<>
<button onClick={async () => { await fetch(`/api/contributions/${c.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'approved' }) }); loadData() }} className="p-1.5 text-green-600 hover:text-green-700" title="Freigeben"><Eye size={13} /></button>
<button onClick={async () => { await fetch(`/api/contributions/${c.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'rejected' }) }); loadData() }} className="p-1.5 text-red-400 hover:text-red-500" title="Ablehnen"><X size={13} /></button>
</>
)}
<button onClick={async () => { if (confirm('Löschen?')) { await fetch(`/api/contributions/${c.id}`, { method: 'DELETE' }); loadData() } }} className="p-1.5 text-red-400/60 hover:text-red-500" title="Löschen"><Trash2 size={13} /></button>
</div>
</div>
)
})}
</div>
</div>
)}
</section>
{/* Contributions Section (New Unified) */}
<section className="bg-white/40 backdrop-blur-sm rounded-3xl p-6 sm:p-8 shadow-sm border border-warm-border">
<h2 className="font-cormorant italic text-3xl text-warm-brown flex items-center gap-3 mb-6">
<FileText size={28} className="text-warm-gold" />
Beiträge ({timelineContributions.filter(c => c.status === 'pending' || c.status === 'flagged').length} zu prüfen)
</h2>
{/* Status Filter Tabs */}
<div className="flex flex-wrap gap-2 mb-6">
{timelineContributions.filter(c => c.status === 'pending').length > 0 && (
<button
onClick={async () => {
if (!confirm(`Alle ${timelineContributions.filter(c => c.status === 'pending').length} ausstehenden Beiträge freigeben?`)) return
await fetch('/api/contributions', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'approve-all-pending' }),
})
loadData()
}}
className="px-4 py-2 rounded-lg text-sm font-lora bg-green-600 hover:bg-green-700 text-white transition-colors"
>
Alle ausstehenden freigeben ({timelineContributions.filter(c => c.status === 'pending').length})
</button>
)}
<button
onClick={() => setContributionFilter('review')}
className={`px-4 py-2 rounded-lg text-sm font-lora transition-colors ${
contributionFilter === 'review'
? 'bg-amber-500 text-white'
: 'bg-white/60 text-warm-brown hover:bg-white/80'
}`}
>
🚩 Zu prüfen ({timelineContributions.filter(c => c.status === 'pending' || c.status === 'flagged').length})
</button>
<button
onClick={() => setContributionFilter('approved')}
className={`px-4 py-2 rounded-lg text-sm font-lora transition-colors ${
contributionFilter === 'approved'
? 'bg-green-500 text-white'
: 'bg-white/60 text-warm-brown hover:bg-white/80'
}`}
>
Freigegeben ({timelineContributions.filter(c => c.status === 'approved').length})
</button>
<button
onClick={() => setContributionFilter('rejected')}
className={`px-4 py-2 rounded-lg text-sm font-lora transition-colors ${
contributionFilter === 'rejected'
? 'bg-red-500 text-white'
: 'bg-white/60 text-warm-brown hover:bg-white/80'
}`}
>
Abgelehnt ({timelineContributions.filter(c => c.status === 'rejected').length})
</button>
<button
onClick={() => setContributionFilter('all')}
className={`px-4 py-2 rounded-lg text-sm font-lora transition-colors ${
contributionFilter === 'all'
? 'bg-warm-brown text-white'
: 'bg-white/60 text-warm-brown hover:bg-white/80'
}`}
>
Alle ({timelineContributions.length})
</button>
</div>
<div className="space-y-3">
{timelineContributions.length === 0 ? (
<p className="text-warm-brown-light text-sm italic font-lora">Keine Beiträge.</p>
) : (
(() => {
// Show all contribution types
let filtered = [...timelineContributions]
if (contributionFilter === 'review') {
filtered = filtered.filter(c => c.status === 'pending' || c.status === 'flagged')
} else if (contributionFilter !== 'all') {
filtered = filtered.filter(c => c.status === contributionFilter)
}
// Sort: flagged first, then pending, then by date
const sorted = [...filtered].sort((a, b) => {
if (a.status === 'flagged' && b.status !== 'flagged') return -1
if (a.status !== 'flagged' && b.status === 'flagged') return 1
if (a.status === 'pending' && b.status !== 'pending') return -1
if (a.status !== 'pending' && b.status === 'pending') return 1
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
if (sorted.length === 0) {
return <p className="text-warm-brown-light text-sm italic font-lora">Keine Beiträge in dieser Kategorie.</p>
}
return sorted.map((contribution) => {
const typeLabels = {
memory: 'Erinnerung',
timeline: 'Zeitstrahl',
media: 'Medien',
recipe: 'Rezept'
}
const photos = contribution.media_filenames ? contribution.media_filenames.split(',') : []
return (
<div key={contribution.id} className={`rounded-xl p-4 border ${
contribution.status === 'flagged'
? 'bg-red-50 border-red-300 ring-2 ring-red-200'
: contribution.status === 'pending'
? 'bg-amber-50 border-amber-200'
: 'bg-white/60 border-warm-border'
}`}>
{editingContribution?.id === contribution.id ? (
// Edit mode
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={editingContribution.name}
onChange={(e) => setEditingContribution({ ...editingContribution, name: e.target.value })}
placeholder="Name"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="email"
value={editingContribution.email || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, email: e.target.value })}
placeholder="Email (optional)"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
</div>
<select
value={editingContribution.type}
onChange={(e) => setEditingContribution({ ...editingContribution, type: e.target.value as any })}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
>
<option value="memory">Erinnerung</option>
<option value="timeline">Zeitstrahl-Ereignis</option>
<option value="media">Medien</option>
</select>
{editingContribution.type === 'timeline' && (
<>
<div className="grid grid-cols-3 gap-2">
<input
type="text"
value={editingContribution.day || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, day: e.target.value })}
placeholder="Tag"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="text"
value={editingContribution.month || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, month: e.target.value })}
placeholder="Monat"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<input
type="text"
value={editingContribution.year || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, year: e.target.value })}
placeholder="Jahr"
className="px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
</div>
<input
type="text"
value={editingContribution.location || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, location: e.target.value })}
placeholder="Ort (optional)"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
</>
)}
<input
type="text"
value={editingContribution.title || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, title: e.target.value })}
placeholder="Titel (optional)"
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm"
/>
<textarea
value={editingContribution.content || ''}
onChange={(e) => setEditingContribution({ ...editingContribution, content: e.target.value })}
placeholder="Inhalt / Beschreibung"
rows={4}
className="w-full px-3 py-2 rounded-lg border border-warm-border bg-white text-warm-brown text-sm resize-none"
/>
{photos.length > 0 && (
<div className="flex gap-2 flex-wrap">
{photos.map((filename, i) => (
<img
key={i}
src={`/api/files/${filename.trim()}`}
alt=""
className="w-20 h-20 object-cover rounded-lg"
/>
))}
</div>
)}
<div className="flex gap-2">
<button
onClick={async () => {
await fetch(`/api/contributions/${editingContribution.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editingContribution.name,
email: editingContribution.email,
type: editingContribution.type,
year: editingContribution.year,
month: editingContribution.month,
day: editingContribution.day,
title: editingContribution.title,
content: editingContribution.content,
location: editingContribution.location,
media_filenames: editingContribution.media_filenames,
status: editingContribution.status,
}),
})
setEditingContribution(null)
loadData()
}}
className="px-4 py-2 bg-warm-gold hover:bg-warm-gold-light text-white rounded-lg text-sm font-lora transition-colors flex items-center gap-2"
>
<Save size={14} />
Speichern
</button>
<button
onClick={() => setEditingContribution(null)}
className="px-4 py-2 bg-warm-brown-light/20 hover:bg-warm-brown-light/30 text-warm-brown rounded-lg text-sm font-lora transition-colors"
>
Abbrechen
</button>
</div>
</div>
) : (
// View mode
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-lora font-semibold text-warm-brown">{contribution.name}</span>
<span className="text-xs px-2 py-0.5 bg-warm-gold/20 text-warm-gold rounded-full">
{typeLabels[contribution.type]}
</span>
{contribution.status === 'pending' && (
<span className="text-xs px-2 py-0.5 bg-amber-200 text-amber-800 rounded-full">Ausstehend</span>
)}
{contribution.status === 'flagged' && (
<span className="text-xs px-2 py-0.5 bg-red-300 text-red-900 rounded-full font-semibold">🚩 Von KI geflaggt</span>
)}
{contribution.status === 'approved' && (
<span className="text-xs px-2 py-0.5 bg-green-200 text-green-800 rounded-full">Freigegeben</span>
)}
{contribution.status === 'rejected' && (
<span className="text-xs px-2 py-0.5 bg-red-200 text-red-800 rounded-full">Abgelehnt</span>
)}
</div>
{contribution.moderation_reason && (
<div className="mt-2 p-2 bg-red-100 border border-red-200 rounded text-xs text-red-800">
<strong>KI-Warnung:</strong> {contribution.moderation_reason}
</div>
)}
{contribution.type === 'timeline' && (contribution.year || contribution.month || contribution.day) && (
<p className="text-warm-gold text-sm font-lora mb-1">
{contribution.day && `${contribution.day}.`}{contribution.month && `${contribution.month}.`}{contribution.year}
{contribution.location && ` · ${contribution.location}`}
</p>
)}
{contribution.title && (
<p className="text-warm-brown font-lora font-medium text-sm mb-1">{contribution.title}</p>
)}
{contribution.content && (
<p className="text-warm-brown-light/80 text-sm mt-2 font-lora line-clamp-3">{contribution.content}</p>
)}
{photos.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{photos.slice(0, 3).map((filename, i) => (
<img
key={i}
src={`/api/files/${filename.trim()}`}
alt=""
className="w-16 h-16 object-cover rounded-lg"
/>
))}
{photos.length > 3 && (
<div className="w-16 h-16 bg-warm-brown/10 rounded-lg flex items-center justify-center text-warm-brown-light text-xs">
+{photos.length - 3}
</div>
)}
</div>
)}
</div>
<div className="flex gap-1 ml-4">
<button
onClick={() => setEditingContribution(contribution)}
className="p-2 text-warm-brown-light/60 hover:text-warm-brown transition-colors"
title="Bearbeiten"
>
<Edit2 size={15} />
</button>
{(contribution.status === 'pending' || contribution.status === 'flagged') && (
<>
<button
onClick={async () => {
await fetch(`/api/contributions/${contribution.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved' }),
})
loadData()
}}
className="p-2 text-green-600 hover:text-green-700 transition-colors"
title="Freigeben"
>
<Eye size={15} />
</button>
<button
onClick={async () => {
await fetch(`/api/contributions/${contribution.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'rejected' }),
})
loadData()
}}
className="p-2 text-red-400/70 hover:text-red-500 transition-colors"
title="Ablehnen"
>
<X size={15} />
</button>
</>
)}
<button
onClick={async () => {
if (confirm('Wirklich löschen?')) {
await fetch(`/api/contributions/${contribution.id}`, { method: 'DELETE' })
loadData()
}
}}
className="p-2 text-red-400/70 hover:text-red-500 transition-colors"
title="Löschen"
>
<Trash2 size={15} />
</button>
</div>
</div>
)}
</div>
)
})
})()
)}
</div>
</section>
</div>
</div>
)
}