bdcfa8f3c5
Next.js 14 + node:sqlite memorial site with: - Hero section, photo slideshow & gallery - Memory/thoughts editor (admin) - Music player with upload - Video gallery - Docker Compose deployment - Responsive warm earth tone design
621 lines
24 KiB
TypeScript
621 lines
24 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,
|
|
} from 'lucide-react'
|
|
|
|
type Memory = {
|
|
id: number
|
|
title: string
|
|
content: string
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type MediaItem = {
|
|
id: number
|
|
filename: string
|
|
original_name: string | null
|
|
type: 'photo' | 'video' | 'music'
|
|
caption: string | null
|
|
sort_order: number
|
|
created_at: string
|
|
}
|
|
|
|
export default function AdminPage() {
|
|
const [authed, setAuthed] = useState<boolean | null>(null)
|
|
const [password, setPassword] = useState('')
|
|
const [loginError, setLoginError] = useState('')
|
|
const [loginLoading, setLoginLoading] = useState(false)
|
|
|
|
const [memories, setMemories] = useState<Memory[]>([])
|
|
const [photos, setPhotos] = useState<MediaItem[]>([])
|
|
const [videos, setVideos] = useState<MediaItem[]>([])
|
|
const [music, setMusic] = useState<MediaItem[]>([])
|
|
|
|
const [uploading, setUploading] = useState(false)
|
|
const [uploadCaption, setUploadCaption] = useState('')
|
|
const [uploadStatus, setUploadStatus] = useState('')
|
|
const [dragOver, setDragOver] = useState(false)
|
|
|
|
const [editingMemory, setEditingMemory] = useState<Memory | null>(null)
|
|
const [newMemory, setNewMemory] = useState({ title: '', content: '' })
|
|
const [showNewMemory, setShowNewMemory] = useState(false)
|
|
const [savingMemory, setSavingMemory] = useState(false)
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const loadData = useCallback(async () => {
|
|
const [memoriesRes, mediaRes] = await Promise.all([
|
|
fetch('/api/memories'),
|
|
fetch('/api/media'),
|
|
])
|
|
const [memoriesData, mediaData] = await Promise.all([
|
|
memoriesRes.json(),
|
|
mediaRes.json(),
|
|
])
|
|
setMemories(Array.isArray(memoriesData) ? memoriesData : [])
|
|
const items: MediaItem[] = Array.isArray(mediaData) ? mediaData : []
|
|
setPhotos(items.filter((m) => m.type === 'photo'))
|
|
setVideos(items.filter((m) => m.type === 'video'))
|
|
setMusic(items.filter((m) => m.type === 'music'))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetch('/api/auth')
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
setAuthed(d.authed)
|
|
if (d.authed) loadData()
|
|
})
|
|
.catch(() => setAuthed(false))
|
|
}, [loadData])
|
|
|
|
const login = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setLoginLoading(true)
|
|
setLoginError('')
|
|
try {
|
|
const res = await fetch('/api/auth', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password }),
|
|
})
|
|
if (res.ok) {
|
|
setAuthed(true)
|
|
loadData()
|
|
} else {
|
|
setLoginError('Falsches Passwort')
|
|
}
|
|
} finally {
|
|
setLoginLoading(false)
|
|
}
|
|
}
|
|
|
|
const logout = async () => {
|
|
await fetch('/api/auth', { method: 'DELETE' })
|
|
setAuthed(false)
|
|
setPassword('')
|
|
}
|
|
|
|
const uploadFile = async (file: File) => {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
if (uploadCaption) formData.append('caption', uploadCaption)
|
|
|
|
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}))
|
|
setUploadStatus(`Fehler: ${err.error || 'Unbekannter Fehler'}`)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const handleFiles = async (files: File[]) => {
|
|
if (files.length === 0) return
|
|
setUploading(true)
|
|
setUploadStatus(`Lade ${files.length} Datei(en) hoch...`)
|
|
|
|
let success = 0
|
|
for (const file of files) {
|
|
const ok = await uploadFile(file)
|
|
if (ok) success++
|
|
}
|
|
|
|
setUploadStatus(`${success} von ${files.length} hochgeladen.`)
|
|
setUploadCaption('')
|
|
await loadData()
|
|
setUploading(false)
|
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
setTimeout(() => setUploadStatus(''), 3000)
|
|
}
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
handleFiles(Array.from(e.target.files || []))
|
|
}
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setDragOver(false)
|
|
handleFiles(Array.from(e.dataTransfer.files))
|
|
}
|
|
|
|
const deleteMedia = async (id: number) => {
|
|
if (!confirm('Datei wirklich löschen?')) return
|
|
await fetch(`/api/media/${id}`, { method: 'DELETE' })
|
|
loadData()
|
|
}
|
|
|
|
const saveMemory = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setSavingMemory(true)
|
|
try {
|
|
if (editingMemory) {
|
|
await fetch(`/api/memories/${editingMemory.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: editingMemory.title,
|
|
content: editingMemory.content,
|
|
}),
|
|
})
|
|
setEditingMemory(null)
|
|
} else {
|
|
await fetch('/api/memories', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newMemory),
|
|
})
|
|
setNewMemory({ title: '', content: '' })
|
|
setShowNewMemory(false)
|
|
}
|
|
loadData()
|
|
} finally {
|
|
setSavingMemory(false)
|
|
}
|
|
}
|
|
|
|
const deleteMemory = async (id: number) => {
|
|
if (!confirm('Erinnerung wirklich löschen?')) return
|
|
await fetch(`/api/memories/${id}`, { method: 'DELETE' })
|
|
loadData()
|
|
}
|
|
|
|
// ── Loading state ──────────────────────────────────────────────────────────
|
|
if (authed === null) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-cream">
|
|
<Loader2 className="animate-spin text-warm-gold" size={28} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Login ─────────────────────────────────────────────────────────────────
|
|
if (!authed) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-cream px-4">
|
|
<div className="w-full max-w-sm">
|
|
<h1 className="font-cormorant italic text-4xl text-center text-warm-brown mb-1">
|
|
Verwaltung
|
|
</h1>
|
|
<p className="text-center text-warm-brown-light text-sm mb-8 font-lora">
|
|
Maria Malejka · Gedenkseite
|
|
</p>
|
|
|
|
<form onSubmit={login} className="space-y-4">
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="Passwort"
|
|
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400 font-lora"
|
|
autoFocus
|
|
/>
|
|
{loginError && (
|
|
<p className="text-red-500 text-sm text-center">{loginError}</p>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
disabled={loginLoading}
|
|
className="w-full py-3 bg-amber-800 hover:bg-amber-700 disabled:opacity-60 text-amber-100 rounded-xl font-lora transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
{loginLoading && <Loader2 size={16} className="animate-spin" />}
|
|
Anmelden
|
|
</button>
|
|
</form>
|
|
|
|
<a
|
|
href="/"
|
|
className="block text-center mt-5 text-sm text-warm-brown-light hover:text-warm-gold transition-colors"
|
|
>
|
|
← Zur Gedenkseite
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Admin UI ───────────────────────────────────────────────────────────────
|
|
return (
|
|
<div className="min-h-screen bg-cream">
|
|
{/* Header */}
|
|
<header className="bg-amber-950/95 text-amber-100 px-4 py-3 flex items-center justify-between sticky top-0 z-20 backdrop-blur-sm">
|
|
<a
|
|
href="/"
|
|
className="text-amber-400 hover:text-amber-200 transition-colors flex items-center gap-1.5 text-sm"
|
|
>
|
|
<Eye size={14} />
|
|
Seite ansehen
|
|
</a>
|
|
<h1 className="font-cormorant italic text-xl text-amber-200">
|
|
Verwaltung
|
|
</h1>
|
|
<button
|
|
onClick={logout}
|
|
className="text-amber-500 hover:text-amber-200 transition-colors flex items-center gap-1.5 text-sm"
|
|
>
|
|
<LogOut size={14} />
|
|
Abmelden
|
|
</button>
|
|
</header>
|
|
|
|
<div className="max-w-3xl mx-auto px-4 py-10 space-y-14">
|
|
{/* ── Upload Section ───────────────────────────────────────────────── */}
|
|
<section>
|
|
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
|
|
<Upload size={20} className="text-warm-gold" />
|
|
Dateien hochladen
|
|
</h2>
|
|
|
|
<div className="mb-3">
|
|
<input
|
|
type="text"
|
|
value={uploadCaption}
|
|
onChange={(e) => setUploadCaption(e.target.value)}
|
|
placeholder="Bildunterschrift / Beschreibung (optional)"
|
|
className="w-full px-4 py-2.5 rounded-xl border border-warm-border bg-white text-warm-brown text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 font-lora"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
onDrop={handleDrop}
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
className={`border-2 border-dashed rounded-2xl p-8 text-center transition-colors ${
|
|
dragOver
|
|
? 'border-amber-500 bg-amber-50/50'
|
|
: 'border-warm-border hover:border-amber-400'
|
|
}`}
|
|
>
|
|
<Upload className="mx-auto mb-3 text-warm-gold" size={28} />
|
|
<p className="text-warm-brown font-lora text-sm mb-1">
|
|
Fotos, Videos oder Musik hochladen
|
|
</p>
|
|
<p className="text-warm-brown-light text-xs mb-4">
|
|
JPG, PNG, HEIC (iPhone), MP4, MOV, MP3, M4A — mehrere auf einmal möglich
|
|
</p>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/*,video/*,audio/*"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
id="file-upload"
|
|
/>
|
|
<label
|
|
htmlFor="file-upload"
|
|
className={`inline-block px-6 py-2.5 rounded-xl cursor-pointer transition-colors font-lora text-sm ${
|
|
uploading
|
|
? 'bg-amber-200 text-amber-700 cursor-not-allowed'
|
|
: 'bg-amber-800 hover:bg-amber-700 text-amber-100'
|
|
}`}
|
|
>
|
|
{uploading ? 'Lädt hoch…' : 'Dateien auswählen'}
|
|
</label>
|
|
|
|
{uploadStatus && (
|
|
<p className="mt-3 text-sm text-warm-brown-light font-lora">
|
|
{uploadStatus}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Photos ──────────────────────────────────────────────────────── */}
|
|
<section>
|
|
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
|
|
<ImageIcon size={20} className="text-warm-gold" />
|
|
Fotos
|
|
<span className="text-warm-brown-light text-xl">({photos.length})</span>
|
|
</h2>
|
|
|
|
{photos.length === 0 ? (
|
|
<p className="text-warm-brown-light text-sm italic font-lora">
|
|
Noch keine Fotos hochgeladen.
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
|
{photos.map((photo) => (
|
|
<div key={photo.id} className="relative group aspect-square">
|
|
<img
|
|
src={`/api/files/${photo.filename}`}
|
|
alt={photo.caption || ''}
|
|
className="w-full h-full object-cover rounded-xl"
|
|
loading="lazy"
|
|
/>
|
|
<button
|
|
onClick={() => deleteMedia(photo.id)}
|
|
className="absolute top-1 right-1 bg-red-500/90 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow"
|
|
title="Löschen"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
{photo.caption && (
|
|
<div className="absolute bottom-0 inset-x-0 bg-black/50 rounded-b-xl px-1.5 py-1">
|
|
<p className="text-white text-xs truncate">{photo.caption}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── Videos ──────────────────────────────────────────────────────── */}
|
|
<section>
|
|
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
|
|
<Film size={20} className="text-warm-gold" />
|
|
Videos
|
|
<span className="text-warm-brown-light text-xl">({videos.length})</span>
|
|
</h2>
|
|
|
|
{videos.length === 0 ? (
|
|
<p className="text-warm-brown-light text-sm italic font-lora">
|
|
Noch keine Videos hochgeladen.
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
|
{videos.map((video) => (
|
|
<div key={video.id} className="relative group">
|
|
<video
|
|
src={`/api/files/${video.filename}`}
|
|
className="w-full aspect-video object-cover rounded-xl bg-stone-900"
|
|
preload="metadata"
|
|
muted
|
|
/>
|
|
<p className="text-xs text-warm-brown-light mt-1 truncate font-lora">
|
|
{video.original_name || video.caption || video.filename}
|
|
</p>
|
|
<button
|
|
onClick={() => deleteMedia(video.id)}
|
|
className="absolute top-1 right-1 bg-red-500/90 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow"
|
|
title="Löschen"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── Music ───────────────────────────────────────────────────────── */}
|
|
<section>
|
|
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
|
|
<Music size={20} className="text-warm-gold" />
|
|
Musik
|
|
<span className="text-warm-brown-light text-xl">({music.length})</span>
|
|
</h2>
|
|
|
|
{music.length === 0 ? (
|
|
<p className="text-warm-brown-light text-sm italic font-lora">
|
|
Noch keine Musik hochgeladen.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{music.map((track, i) => (
|
|
<div
|
|
key={track.id}
|
|
className="flex items-center gap-3 bg-white/60 rounded-xl px-4 py-3 border border-warm-border group"
|
|
>
|
|
<span className="text-warm-gold/60 text-sm font-cormorant">{i + 1}.</span>
|
|
<Music size={14} className="text-warm-gold flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-warm-brown truncate font-lora">
|
|
{track.original_name?.replace(/\.[^/.]+$/, '') ||
|
|
track.caption ||
|
|
track.filename}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => deleteMedia(track.id)}
|
|
className="text-red-400/60 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0"
|
|
title="Löschen"
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── Memories ────────────────────────────────────────────────────── */}
|
|
<section>
|
|
<div className="flex items-center justify-between mb-5">
|
|
<h2 className="font-cormorant italic text-3xl text-warm-brown flex items-center gap-2">
|
|
<FileText size={20} className="text-warm-gold" />
|
|
Erinnerungen
|
|
<span className="text-warm-brown-light text-xl">({memories.length})</span>
|
|
</h2>
|
|
<button
|
|
onClick={() => {
|
|
setShowNewMemory(true)
|
|
setEditingMemory(null)
|
|
}}
|
|
className="flex items-center gap-1.5 px-4 py-2 bg-amber-800 hover:bg-amber-700 text-amber-100 rounded-xl text-sm transition-colors font-lora"
|
|
>
|
|
<Plus size={15} />
|
|
Neue Erinnerung
|
|
</button>
|
|
</div>
|
|
|
|
{/* New / Edit form */}
|
|
<AnimatePresence>
|
|
{(showNewMemory || editingMemory) && (
|
|
<motion.form
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
onSubmit={saveMemory}
|
|
className="bg-white/80 rounded-2xl p-5 mb-6 space-y-3 border border-warm-border overflow-hidden"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-warm-brown-light font-lora">
|
|
{editingMemory ? 'Erinnerung bearbeiten' : 'Neue Erinnerung'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowNewMemory(false)
|
|
setEditingMemory(null)
|
|
}}
|
|
className="text-warm-brown-light hover:text-warm-brown"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
value={editingMemory ? editingMemory.title : newMemory.title}
|
|
onChange={(e) =>
|
|
editingMemory
|
|
? setEditingMemory({ ...editingMemory, title: e.target.value })
|
|
: setNewMemory({ ...newMemory, title: e.target.value })
|
|
}
|
|
placeholder="Titel"
|
|
required
|
|
className="w-full px-4 py-2.5 rounded-xl border border-warm-border bg-white font-cormorant italic text-xl text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400"
|
|
/>
|
|
|
|
<textarea
|
|
value={editingMemory ? editingMemory.content : newMemory.content}
|
|
onChange={(e) =>
|
|
editingMemory
|
|
? setEditingMemory({ ...editingMemory, content: e.target.value })
|
|
: setNewMemory({ ...newMemory, content: e.target.value })
|
|
}
|
|
placeholder="Deine Gedanken und Erinnerungen…"
|
|
required
|
|
rows={7}
|
|
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white font-lora text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400 resize-none text-sm leading-relaxed"
|
|
/>
|
|
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowNewMemory(false)
|
|
setEditingMemory(null)
|
|
}}
|
|
className="px-4 py-2 rounded-xl border border-warm-border text-warm-brown-light hover:text-warm-brown text-sm transition-colors font-lora"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={savingMemory}
|
|
className="px-4 py-2 bg-amber-800 hover:bg-amber-700 disabled:opacity-60 text-amber-100 rounded-xl text-sm transition-colors flex items-center gap-1.5 font-lora"
|
|
>
|
|
{savingMemory ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Save size={14} />
|
|
)}
|
|
Speichern
|
|
</button>
|
|
</div>
|
|
</motion.form>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{memories.length === 0 ? (
|
|
<p className="text-warm-brown-light text-sm italic font-lora">
|
|
Noch keine Erinnerungen geschrieben.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{memories.map((memory) => (
|
|
<div
|
|
key={memory.id}
|
|
className={`bg-white/60 rounded-2xl p-5 border transition-colors ${
|
|
editingMemory?.id === memory.id
|
|
? 'border-amber-400'
|
|
: 'border-warm-border'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-cormorant italic text-xl text-warm-brown leading-snug">
|
|
{memory.title}
|
|
</h3>
|
|
<p className="text-warm-brown-light text-xs mt-0.5 font-lora">
|
|
{new Date(memory.created_at).toLocaleDateString('de-DE', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
})}
|
|
</p>
|
|
<p className="text-warm-brown/70 text-sm mt-2 line-clamp-3 font-lora leading-relaxed">
|
|
{memory.content}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-1 flex-shrink-0">
|
|
<button
|
|
onClick={() => {
|
|
setEditingMemory(memory)
|
|
setShowNewMemory(false)
|
|
}}
|
|
className="p-2 text-amber-700 hover:text-amber-500 transition-colors rounded-lg hover:bg-amber-50"
|
|
title="Bearbeiten"
|
|
>
|
|
<Edit2 size={15} />
|
|
</button>
|
|
<button
|
|
onClick={() => deleteMemory(memory.id)}
|
|
className="p-2 text-red-400/70 hover:text-red-500 transition-colors rounded-lg hover:bg-red-50"
|
|
title="Löschen"
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|