feat(tenant-admin): refresh event management experience

This commit is contained in:
Codex Agent
2025-09-26 12:17:25 +02:00
parent 215d19f07e
commit 6215ebbaa0
13 changed files with 1255 additions and 300 deletions

View File

@@ -1,97 +1,199 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Camera, Loader2, Sparkles, Trash2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventPhotosPage() {
const [sp] = useSearchParams();
const id = Number(sp.get('id'));
const [rows, setRows] = React.useState<any[]>([]);
const [searchParams] = useSearchParams();
const slug = searchParams.get('slug');
const navigate = useNavigate();
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
const load = React.useCallback(async () => {
if (!slug) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setRows(await getEventPhotos(id));
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
const data = await getEventPhotos(slug);
setPhotos(data);
} catch (err) {
if (!isAuthError(err)) {
setError('Fotos konnten nicht geladen werden.');
}
} finally {
setLoading(false);
}
}, [id]);
}, [slug]);
React.useEffect(() => {
load();
}, [load]);
async function onFeature(photo: any) {
async function handleToggleFeature(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
await featurePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
const updated = photo.is_featured
? await unfeaturePhoto(slug, photo.id)
: await featurePhoto(slug, photo.id);
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
} catch (err) {
if (!isAuthError(err)) {
setError('Feature-Aktion fehlgeschlagen.');
}
} finally {
setBusyId(null);
}
}
async function onUnfeature(photo: any) {
async function handleDelete(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
await unfeaturePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
await deletePhoto(slug, photo.id);
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
} catch (err) {
if (!isAuthError(err)) {
setError('Foto konnte nicht entfernt werden.');
}
} finally {
setBusyId(null);
}
}
async function onDelete(photo: any) {
try {
await deletePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
if (!slug) {
return (
<AdminLayout title="Fotos moderieren" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={null}>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
Kein Slug in der URL gefunden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
<Button className="mt-4" onClick={() => navigate('/admin/events')}>
Zurueck zur Liste
</Button>
</CardContent>
</Card>
</AdminLayout>
);
}
const actions = (
<Button
variant="outline"
onClick={() => navigate(`/admin/events/view-slug=${encodeURIComponent(slug)}`)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
Zurueck zum Event
</Button>
);
return (
<div className="mx-auto max-w-5xl p-4">
<h1 className="mb-3 text-lg font-semibold">Fotos moderieren</h1>
{loading && <div>Lade ...</div>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{rows.map((p) => (
<div key={p.id} className="rounded border p-2">
<img
src={p.thumbnail_path || p.file_path}
className="mb-2 aspect-square w-full rounded object-cover"
alt={p.caption ?? 'Foto'}
/>
<div className="flex items-center justify-between text-sm">
<span>?? {p.likes_count}</span>
<div className="flex gap-1">
{p.is_featured ? (
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>
Unfeature
</Button>
) : (
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>
Feature
</Button>
)}
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>
L<EFBFBD>schen
</Button>
</div>
<AdminLayout
title="Fotos moderieren"
subtitle="Setze Highlights oder entferne unpassende Uploads."
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Camera className="h-5 w-5 text-sky-500" /> Galerie
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Klick auf ein Foto, um es hervorzuheben oder zu loeschen.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<GallerySkeleton />
) : photos.length === 0 ? (
<EmptyGallery />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{photos.map((photo) => (
<div key={photo.id} className="rounded-2xl border border-white/80 bg-white/90 p-3 shadow-sm">
<div className="relative overflow-hidden rounded-xl">
<img src={photo.thumbnail_url || photo.url} alt={photo.original_name ?? 'Foto'} className="aspect-square w-full object-cover" />
{photo.is_featured && (
<span className="absolute left-3 top-3 rounded-full bg-pink-500/90 px-3 py-1 text-xs font-semibold text-white shadow">
Featured
</span>
)}
</div>
<div className="mt-3 flex flex-col gap-2 text-sm text-slate-700">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Likes: {photo.likes_count}</span>
<span>Uploader: {photo.uploader_name ?? 'Unbekannt'}</span>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
onClick={() => handleToggleFeature(photo)}
disabled={busyId === photo.id}
>
{busyId === photo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{photo.is_featured ? 'Featured entfernen' : 'Als Highlight setzen'}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(photo)}
disabled={busyId === photo.id}
>
{busyId === photo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Loeschen
</Button>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</AdminLayout>
);
}
function GallerySkeleton() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="aspect-square animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyGallery() {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-sky-200 bg-white/70 p-10 text-center">
<div className="rounded-full bg-sky-100 p-3 text-sky-600 shadow-inner shadow-sky-200/80">
<Camera className="h-5 w-5" />
</div>
<h3 className="text-lg font-semibold text-slate-900">Noch keine Fotos vorhanden</h3>
<p className="text-sm text-slate-600">Motiviere deine Gaeste zum Hochladen - hier erscheint anschliessend die Galerie.</p>
</div>
);
}