feat: admin categorized display, improved CI/CD pipeline
- Admin: User timeline contributions shown in Timeline section - Admin: User memory contributions shown in Erinnerungen section - Admin: User photo uploads shown in Familien-Uploads section - All contributions still appear in unified Beiträge section - Dockerfile: fix data dir path (/data -> /app/data) - CI/CD: use checkout@v4, retry health check, auto-create proxy network - CI/CD: support SITE_PASSWORD/ADMIN_PASSWORD secrets - CI/CD: use wget instead of curl (alpine compat) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+153
-4
@@ -17,6 +17,8 @@ import {
|
||||
Eye,
|
||||
Loader2,
|
||||
Flame,
|
||||
User,
|
||||
Heart,
|
||||
} from 'lucide-react'
|
||||
|
||||
type Memory = {
|
||||
@@ -749,9 +751,65 @@ export default function AdminPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Candles Section */}
|
||||
{/* 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">
|
||||
@@ -1118,9 +1176,50 @@ export default function AdminPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recipes Section */}
|
||||
{/* 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 => (
|
||||
<div key={`tc-${c.id}`} className={`rounded-lg p-3 border flex items-center justify-between ${
|
||||
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-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.content ? `· ${c.content}` : ''}</p>
|
||||
</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>
|
||||
)}
|
||||
</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">
|
||||
@@ -1343,6 +1442,56 @@ export default function AdminPage() {
|
||||
))
|
||||
)}
|
||||
</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) */}
|
||||
|
||||
Reference in New Issue
Block a user