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:
denshooter
2026-02-18 13:04:20 +01:00
parent 9223a2bfbb
commit 034ba854b5
3 changed files with 189 additions and 24 deletions
+35 -19
View File
@@ -8,23 +8,25 @@ on:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: actions/checkout@v4
- name: Create proxy network if needed
run: docker network create proxy || true
- name: Build Docker image
run: |
docker build -t oma-memorial:latest .
run: docker build -t oma-memorial:latest .
- name: Stop and remove old container
run: |
docker stop oma-memorial || true
docker rm oma-memorial || true
docker stop oma-memorial 2>/dev/null || true
docker rm oma-memorial 2>/dev/null || true
- name: Ensure data directory exists
run: mkdir -p ${{ gitea.workspace }}/data
- name: Run container in proxy network
run: |
docker run -d \
@@ -32,14 +34,28 @@ jobs:
--network proxy \
--restart unless-stopped \
-e NODE_ENV=production \
-v $(pwd)/data:/app/data \
-e SITE_PASSWORD="${{ secrets.SITE_PASSWORD }}" \
-e ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}" \
-v ${{ gitea.workspace }}/data:/app/data \
oma-memorial:latest
- name: Health check
run: |
sleep 10
docker exec oma-memorial curl -f http://localhost:3000 || exit 1
- name: Show container logs
echo "Waiting for container to start..."
for i in $(seq 1 15); do
if docker exec oma-memorial wget -q --spider http://localhost:3000 2>/dev/null; then
echo "Container is healthy!"
exit 0
fi
echo "Attempt $i/15..."
sleep 2
done
echo "Health check failed"
docker logs oma-memorial --tail 30
exit 1
- name: Show container status
if: always()
run: docker logs oma-memorial --tail 50
run: |
docker ps --filter name=oma-memorial
docker logs oma-memorial --tail 20
+1 -1
View File
@@ -25,7 +25,7 @@ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
RUN mkdir -p /data && chown nextjs:nodejs /data
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs
+153 -4
View File
@@ -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) */}