Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).

Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
Codex Agent
2025-11-01 19:50:17 +01:00
parent 2c14493604
commit 79b209de9a
55 changed files with 3348 additions and 462 deletions

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Camera, Loader2, Sparkles, Trash2 } from 'lucide-react';
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@@ -9,6 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
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() {
@@ -16,11 +19,18 @@ export default function EventPhotosPage() {
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 | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const load = React.useCallback(async () => {
if (!slug) {
@@ -30,11 +40,12 @@ export default function EventPhotosPage() {
setLoading(true);
setError(null);
try {
const data = await getEventPhotos(slug);
setPhotos(data);
const result = await getEventPhotos(slug);
setPhotos(result.photos);
setLimits(result.limits ?? null);
} catch (err) {
if (!isAuthError(err)) {
setError('Fotos konnten nicht geladen werden.');
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
}
} finally {
setLoading(false);
@@ -55,7 +66,7 @@ export default function EventPhotosPage() {
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
} catch (err) {
if (!isAuthError(err)) {
setError('Feature-Aktion fehlgeschlagen.');
setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.'));
}
} finally {
setBusyId(null);
@@ -70,7 +81,7 @@ export default function EventPhotosPage() {
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
} catch (err) {
if (!isAuthError(err)) {
setError('Foto konnte nicht entfernt werden.');
setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.'));
}
} finally {
setBusyId(null);
@@ -104,31 +115,36 @@ export default function EventPhotosPage() {
return (
<AdminLayout
title="Fotos moderieren"
subtitle="Setze Highlights oder entferne unpassende Uploads."
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>Aktion fehlgeschlagen</AlertTitle>
<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" /> Galerie
<Camera className="h-5 w-5 text-sky-500" /> {t('photos.gallery.title', 'Galerie')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Klick auf ein Foto, um es hervorzuheben oder zu löschen.
{t('photos.gallery.description', 'Klick auf ein Foto, um es hervorzuheben oder zu löschen.')}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<GallerySkeleton />
) : photos.length === 0 ? (
<EmptyGallery />
<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) => (
@@ -178,6 +194,37 @@ export default function EventPhotosPage() {
);
}
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">
@@ -188,15 +235,14 @@ function GallerySkeleton() {
);
}
function EmptyGallery() {
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">Noch keine Fotos vorhanden</h3>
<p className="text-sm text-slate-600">Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.</p>
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
<p className="text-sm text-slate-600">{description}</p>
</div>
);
}