249 lines
9.4 KiB
TypeScript
249 lines
9.4 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Button } from '@/components/ui/button';
|
|
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';
|
|
import { getApiErrorMessage, isApiError } from '../lib/apiError';
|
|
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
|
|
|
export default function EventPhotosPage() {
|
|
const params = useParams<{ slug?: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation('management');
|
|
const { t: tCommon } = useTranslation('common');
|
|
const translateLimits = React.useCallback(
|
|
(key: string, options?: Record<string, unknown>) => tCommon(`limits.${key}`, options),
|
|
[tCommon]
|
|
);
|
|
|
|
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [error, setError] = React.useState<string | undefined>(undefined);
|
|
const [busyId, setBusyId] = React.useState<number | null>(null);
|
|
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
|
|
|
const load = React.useCallback(async () => {
|
|
if (!slug) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(undefined);
|
|
try {
|
|
const result = await getEventPhotos(slug);
|
|
setPhotos(result.photos);
|
|
setLimits(result.limits ?? null);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [slug]);
|
|
|
|
React.useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
async function handleToggleFeature(photo: TenantPhoto) {
|
|
if (!slug) return;
|
|
setBusyId(photo.id);
|
|
try {
|
|
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(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.'));
|
|
}
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(photo: TenantPhoto) {
|
|
if (!slug) return;
|
|
setBusyId(photo.id);
|
|
try {
|
|
await deletePhoto(slug, photo.id);
|
|
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.'));
|
|
}
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
}
|
|
|
|
if (!slug) {
|
|
return (
|
|
<AdminLayout title="Fotos moderieren" subtitle="Bitte wähle ein Event aus der Übersicht." 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 zurück und wähle dort ein Event aus.
|
|
<Button className="mt-4" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
|
|
Zurück zur Liste
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
const actions = (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => slug && navigate(ADMIN_EVENT_VIEW_PATH(slug))}
|
|
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
|
>
|
|
Zurück zum Event
|
|
</Button>
|
|
);
|
|
|
|
return (
|
|
<AdminLayout
|
|
title={t('photos.moderation.title', 'Fotos moderieren')}
|
|
subtitle={t('photos.moderation.subtitle', 'Setze Highlights oder entferne unpassende Uploads.')}
|
|
actions={actions}
|
|
>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('photos.alerts.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<LimitWarningsBanner limits={limits} translate={translateLimits} />
|
|
|
|
<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" /> {t('photos.gallery.title', 'Galerie')}
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-slate-600">
|
|
{t('photos.gallery.description', 'Klick auf ein Foto, um es hervorzuheben oder zu löschen.')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<GallerySkeleton />
|
|
) : photos.length === 0 ? (
|
|
<EmptyGallery
|
|
title={t('photos.gallery.emptyTitle', 'Noch keine Fotos vorhanden')}
|
|
description={t('photos.gallery.emptyDescription', 'Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.')}
|
|
/>
|
|
) : (
|
|
<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 ?? undefined} 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" />}
|
|
Löschen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function LimitWarningsBanner({
|
|
limits,
|
|
translate,
|
|
}: {
|
|
limits: EventLimitSummary | null;
|
|
translate: (key: string, options?: Record<string, unknown>) => string;
|
|
}) {
|
|
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
|
|
|
if (!warnings.length) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="mb-6 space-y-2">
|
|
{warnings.map((warning) => (
|
|
<Alert
|
|
key={warning.id}
|
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
|
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
|
>
|
|
<AlertDescription className="flex items-center gap-2 text-sm">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
{warning.message}
|
|
</AlertDescription>
|
|
</Alert>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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({ title, description }: { title: string; description: string }) {
|
|
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">{title}</h3>
|
|
<p className="text-sm text-slate-600">{description}</p>
|
|
</div>
|
|
);
|
|
}
|