feat(tenant-admin): refresh event management experience
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user