e3d4f7c96e
Build and Deploy / build-and-deploy (push) Has been cancelled
- 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>
1961 lines
98 KiB
TypeScript
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>
|
|
)
|
|
}
|