// @ts-nocheck import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { AlertTriangle, Camera, Check, Copy, Eye, EyeOff, Filter, Loader2, Search, ShoppingCart, Star, Trash2, X, } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import toast from 'react-hot-toast'; import { AddonsPicker } from '../components/Addons/AddonsPicker'; import { AddonSummaryList } from '../components/Addons/AddonSummaryList'; import { getAddonCatalog, type EventAddonCatalogItem, type EventAddonSummary } from '../api'; import { AdminLayout } from '../components/AdminLayout'; import { createEventAddonCheckout, deletePhoto, featurePhoto, getEvent, getEventPhotos, TenantEvent, TenantPhoto, unfeaturePhoto, updatePhotoVisibility, type PaginationMeta } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; import { useTranslation } from 'react-i18next'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH } from '../constants'; import { buildEventTabs } from '../lib/eventTabs'; export default function EventPhotosPage() { const PAGE_SIZE = 40; const params = useParams<{ slug?: string }>(); const [searchParams, setSearchParams] = 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) => tCommon(`limits.${key}`, options), [tCommon] ); const [photos, setPhotos] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(undefined); const [busyId, setBusyId] = React.useState(null); const [limits, setLimits] = React.useState(null); const [addons, setAddons] = React.useState([]); const [eventAddons, setEventAddons] = React.useState([]); const [event, setEvent] = React.useState(null); const [search, setSearch] = React.useState(''); const [statusFilter, setStatusFilter] = React.useState<'all' | 'featured' | 'hidden' | 'photobooth'>('all'); const [selectedIds, setSelectedIds] = React.useState([]); const [bulkBusy, setBulkBusy] = React.useState(false); const [showSearch, setShowSearch] = React.useState(false); const [sortOrder, setSortOrder] = React.useState<'desc' | 'asc'>('desc'); const [page, setPage] = React.useState(1); const [pagination, setPagination] = React.useState(null); const [pendingDelete, setPendingDelete] = React.useState(null); const [skipDeleteConfirm, setSkipDeleteConfirm] = React.useState(() => typeof window !== 'undefined' ? window.sessionStorage.getItem(DELETE_CONFIRM_SKIP_KEY) === '1' : false, ); const updateSkipDeleteConfirm = React.useCallback((value: boolean) => { setSkipDeleteConfirm(value); if (typeof window === 'undefined') { return; } if (value) { window.sessionStorage.setItem(DELETE_CONFIRM_SKIP_KEY, '1'); } else { window.sessionStorage.removeItem(DELETE_CONFIRM_SKIP_KEY); } }, []); const photoboothUploads = React.useMemo( () => photos.filter((photo) => photo.ingest_source === 'photobooth').length, [photos], ); const eventTabs = React.useMemo(() => { if (!event || !slug) { return []; } const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const photoBadge = !loading ? event.photo_count ?? pagination?.total : undefined; return buildEventTabs(event, translateMenu, { photos: photoBadge, tasks: event.tasks_count ?? undefined, }); }, [event, slug, t, loading, pagination?.total]); const load = React.useCallback(async () => { if (!slug) { setLoading(false); return; } setLoading(true); setError(undefined); try { const visibility = statusFilter === 'hidden' ? 'hidden' : 'visible'; const [photoResult, eventData, catalog] = await Promise.all([ getEventPhotos(slug, { page, perPage: PAGE_SIZE, sort: sortOrder, search: search.trim() || undefined, status: statusFilter === 'hidden' ? 'hidden' : undefined, featured: statusFilter === 'featured', ingestSource: statusFilter === 'photobooth' ? 'photobooth' : undefined, visibility, }), getEvent(slug), getAddonCatalog(), ]); setPhotos(photoResult.photos); setLimits(photoResult.limits ?? null); setPagination(photoResult.meta ?? null); setEventAddons(eventData.addons ?? []); setEvent({ ...eventData, photo_count: photoResult.meta?.total ?? eventData.photo_count, }); setAddons(catalog); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.')); } } finally { setLoading(false); } }, [slug, page, sortOrder, search, statusFilter]); React.useEffect(() => { load(); }, [load]); React.useEffect(() => { const success = searchParams.get('addon_success'); if (success && slug) { toast(translateLimits('addonApplied', { defaultValue: 'Add-on angewendet. Limits aktualisieren sich in Kürze.' })); void load(); const params = new URLSearchParams(searchParams); params.delete('addon_success'); setSearchParams(params); navigate(window.location.pathname, { replace: true }); } }, [searchParams, slug, load, navigate, translateLimits, setSearchParams]); 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)); toast.success(t('photos.actions.deleteSuccess', 'Foto gelöscht')); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.')); toast.error(t('photos.actions.deleteFailed', 'Foto konnte nicht entfernt werden.')); } } finally { setBusyId(null); } } const requestDelete = React.useCallback( (photo: TenantPhoto) => { if (skipDeleteConfirm) { void handleDelete(photo); return; } setPendingDelete(photo); }, [skipDeleteConfirm, handleDelete], ); const confirmDelete = React.useCallback(() => { if (!pendingDelete) return; void handleDelete(pendingDelete); setPendingDelete(null); }, [pendingDelete, handleDelete]); const cancelDelete = React.useCallback(() => { setPendingDelete(null); }, []); async function handleToggleVisibility(photo: TenantPhoto, visible: boolean) { if (!slug) return; const shouldRemove = (!visible && statusFilter !== 'hidden') || (visible && statusFilter === 'hidden'); if (shouldRemove) { setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id)); } setBusyId(photo.id); try { const updated = await updatePhotoVisibility(slug, photo.id, visible); if (!shouldRemove) { setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry))); } setSelectedIds((prev) => prev.filter((id) => id !== photo.id)); toast.success( visible ? t('photos.actions.showSuccess', 'Foto eingeblendet') : t('photos.actions.hideSuccess', 'Foto versteckt') ); void load(); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Sichtbarkeit konnte nicht geändert werden.')); toast.error(t('photos.actions.hideFailed', 'Sichtbarkeit konnte nicht geändert werden.')); } } finally { setBusyId(null); } } React.useEffect(() => { setPage(1); }, [search, statusFilter, sortOrder]); const pageCount = pagination?.last_page ?? 1; const currentPage = Math.min(page, pageCount || 1); const paginatedPhotos = photos; const totalCount = pagination?.total ?? photos.length; const toggleSelect = React.useCallback((photoId: number) => { setSelectedIds((prev) => (prev.includes(photoId) ? prev.filter((id) => id !== photoId) : [...prev, photoId])); }, []); const selectAllVisible = React.useCallback(() => { setSelectedIds(paginatedPhotos.map((photo) => photo.id)); }, [paginatedPhotos]); const clearSelection = React.useCallback(() => { setSelectedIds([]); }, []); const selectedPhotos = React.useMemo( () => photos.filter((photo) => selectedIds.includes(photo.id)), [photos, selectedIds], ); const handleBulkVisibility = React.useCallback( async (visible: boolean) => { if (!slug || !selectedPhotos.length) return; setBulkBusy(true); const shouldRemove = (!visible && statusFilter !== 'hidden') || (visible && statusFilter === 'hidden'); if (shouldRemove) { setPhotos((prev) => prev.filter((entry) => !selectedPhotos.find((item) => item.id === entry.id))); } try { const updates = await Promise.all( selectedPhotos.map(async (photo) => updatePhotoVisibility(slug, photo.id, visible)), ); if (!shouldRemove) { setPhotos((prev) => prev.map((entry) => updates.find((item) => item.id === entry.id) ?? entry)); } setSelectedIds([]); toast.success( visible ? t('photos.actions.bulkShowSuccess', 'Ausgewählte Fotos eingeblendet') : t('photos.actions.bulkHideSuccess', 'Ausgewählte Fotos versteckt') ); void load(); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Sichtbarkeit konnte nicht geändert werden.')); toast.error(t('photos.actions.hideFailed', 'Sichtbarkeit konnte nicht geändert werden.')); } } finally { setBulkBusy(false); } }, [selectedPhotos, slug], ); const handleBulkFeature = React.useCallback( async (featured: boolean) => { if (!slug || !selectedPhotos.length) return; setBulkBusy(true); try { for (const photo of selectedPhotos) { setBusyId(photo.id); try { const updated = featured ? await featurePhoto(slug, photo.id) : await unfeaturePhoto(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); } } setSelectedIds([]); } finally { setBulkBusy(false); } }, [selectedPhotos, slug], ); if (!slug) { return ( Kein Slug in der URL gefunden. Kehre zur Event-Liste zurück und wähle dort ein Event aus. ); } const actions = ( ); return ( {error && ( {t('photos.alerts.errorTitle', 'Aktion fehlgeschlagen')} {error} )}
{t('photos.gallery.title', 'Galerie')} {t('photos.gallery.description', 'Klick auf ein Foto, um es hervorzuheben oder zu löschen.')}
{t('photos.gallery.photoboothCount', '{{count}} Photobooth-Uploads', { count: photoboothUploads })}
{ void handleBulkVisibility(false); }} onBulkShow={() => { void handleBulkVisibility(true); }} onBulkFeature={() => { void handleBulkFeature(true); }} onBulkUnfeature={() => { void handleBulkFeature(false); }} busy={bulkBusy} sortOrder={sortOrder} onSortOrderChange={setSortOrder} showSearch={showSearch} onToggleSearch={() => setShowSearch((prev) => !prev)} /> {loading ? ( ) : totalCount === 0 ? ( ) : ( { void handleToggleFeature(photo); }} onToggleVisibility={(photo, visible) => { void handleToggleVisibility(photo, visible); }} onRequestDelete={(photo) => { requestDelete(photo); }} busyId={busyId} /> )}
{eventAddons.length > 0 && ( {t('events.sections.addons.title', 'Add-ons & Upgrades')} {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} t(key, fallback)} /> )}
); } const LIMIT_WARNING_DISMISS_KEY = 'tenant-admin:dismissed-limit-warnings'; const DELETE_CONFIRM_SKIP_KEY = 'tenant-admin:skip-photo-delete-confirm'; function readDismissedLimitWarnings(): Set { if (typeof window === 'undefined') { return new Set(); } try { const raw = window.localStorage.getItem(LIMIT_WARNING_DISMISS_KEY); if (!raw) { return new Set(); } const parsed = JSON.parse(raw) as string[]; return new Set(parsed); } catch (error) { console.warn('[LimitWarnings] Failed to parse dismissed warnings', error); return new Set(); } } function persistDismissedLimitWarnings(ids: Set) { if (typeof window === 'undefined') { return; } try { window.localStorage.setItem(LIMIT_WARNING_DISMISS_KEY, JSON.stringify(Array.from(ids))); } catch (error) { console.warn('[LimitWarnings] Failed to persist dismissed warnings', error); } } function LimitWarningsBanner({ limits, translate, eventSlug, addons, }: { limits: EventLimitSummary | null; translate: (key: string, options?: Record) => string; eventSlug: string | null; addons: EventAddonCatalogItem[]; }) { const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]); const [busyScope, setBusyScope] = React.useState(null); const [dismissedIds, setDismissedIds] = React.useState>(() => readDismissedLimitWarnings()); const { t: tCommon } = useTranslation('common'); const dismissLabel = tCommon('actions.dismiss', { defaultValue: 'Hinweis ausblenden' }); const handleCheckout = React.useCallback( async (scopeOrKey: 'photos' | 'gallery' | string) => { if (!eventSlug) return; const scope = scopeOrKey === 'gallery' || scopeOrKey === 'photos' ? scopeOrKey : (scopeOrKey.includes('gallery') ? 'gallery' : 'photos'); setBusyScope(scope); const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? (() => { const fallbackKey = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d'; const candidates = addons.filter((addon) => addon.price_id && addon.key.includes(scope === 'photos' ? 'photos' : 'gallery')); return candidates[0]?.key ?? fallbackKey; })() : scopeOrKey; try { const currentUrl = window.location.origin + window.location.pathname; const successUrl = `${currentUrl}?addon_success=1`; const checkout = await createEventAddonCheckout(eventSlug, { addon_key: addonKey, quantity: 1, success_url: successUrl, cancel_url: currentUrl, }); if (checkout.checkout_url) { window.location.href = checkout.checkout_url; } } catch (err) { toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.')); } finally { setBusyScope(null); } }, [eventSlug, addons], ); const handleDismiss = React.useCallback((warningId: string) => { setDismissedIds((prev) => { const next = new Set(prev); next.add(warningId); persistDismissedLimitWarnings(next); return next; }); }, []); const visibleWarnings = warnings.filter((warning) => !dismissedIds.has(warning.id)); if (!visibleWarnings.length) { return null; } return (
{visibleWarnings.map((warning) => (
{warning.message}
{warning.scope === 'photos' || warning.scope === 'gallery' ? (
{warning.scope !== 'guests' ? ( { void handleCheckout(key); }} busy={busyScope === warning.scope} t={(key, fallback) => translate(key, { defaultValue: fallback })} /> ) : null}
) : null}
))}
); } function GallerySkeleton() { return (
{Array.from({ length: 6 }).map((_, index) => (
))}
); } function EmptyGallery({ title, description }: { title: string; description: string }) { return (

{title}

{description}

); } function GalleryToolbar({ search, onSearch, showSearch, onToggleSearch, statusFilter, onStatusFilterChange, totalCount, page, pageCount, onPageChange, selectionCount, onSelectAll, onClearSelection, onBulkHide, onBulkShow, onBulkFeature, onBulkUnfeature, sortOrder, onSortOrderChange, busy, }: { search: string; onSearch: (value: string) => void; showSearch: boolean; onToggleSearch: () => void; statusFilter: 'all' | 'featured' | 'hidden' | 'photobooth'; onStatusFilterChange: (value: 'all' | 'featured' | 'hidden' | 'photobooth') => void; totalCount: number; page: number; pageCount: number; onPageChange: (page: number) => void; selectionCount: number; onSelectAll: () => void; onClearSelection: () => void; onBulkHide: () => void; onBulkShow: () => void; onBulkFeature: () => void; onBulkUnfeature: () => void; sortOrder: 'desc' | 'asc'; onSortOrderChange: (value: 'desc' | 'asc') => void; busy: boolean; }) { const { t } = useTranslation('management'); const filters = [ { key: 'all', label: t('photos.filters.all', 'Alle') }, { key: 'featured', label: t('photos.filters.featured', 'Highlights') }, { key: 'hidden', label: t('photos.filters.hidden', 'Versteckt') }, { key: 'photobooth', label: t('photos.filters.photobooth', 'Photobooth') }, ] as const; return (
{showSearch ? (
onSearch(event.target.value)} placeholder={t('photos.filters.search', 'Uploads durchsuchen …')} className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0" />
) : ( )}
{filters.map((filter) => ( ))}
{t('photos.filters.sort', 'Sortierung')}
{t('photos.filters.count', '{{count}} Uploads', { count: totalCount })} {selectionCount > 0 ? ( <> {t('photos.filters.selected', '{{count}} ausgewählt', { count: selectionCount })}
) : ( )}
{page} / {pageCount}
); } function PhotoGrid({ photos, selectedIds, onToggleSelect, onToggleFeature, onToggleVisibility, onRequestDelete, busyId, }: { photos: TenantPhoto[]; selectedIds: number[]; onToggleSelect: (id: number) => void; onToggleFeature: (photo: TenantPhoto) => void; onToggleVisibility: (photo: TenantPhoto, visible: boolean) => void; onRequestDelete: (photo: TenantPhoto) => void; busyId: number | null; }) { return (
{photos.map((photo) => ( onToggleSelect(photo.id)} onToggleFeature={() => onToggleFeature(photo)} onToggleVisibility={(visible) => onToggleVisibility(photo, visible)} onRequestDelete={() => onRequestDelete(photo)} busy={busyId === photo.id} /> ))}
); } function PhotoCard({ photo, selected, onToggleSelect, onToggleFeature, onToggleVisibility, onRequestDelete, busy, }: { photo: TenantPhoto; selected: boolean; onToggleSelect: () => void; onToggleFeature: () => void; onToggleVisibility: (visible: boolean) => void; onRequestDelete: () => void; busy: boolean; }) { const { t } = useTranslation('management'); const hidden = photo.status === 'hidden'; const [copied, setCopied] = React.useState(false); React.useEffect(() => { if (!copied) { return; } const timeout = setTimeout(() => setCopied(false), 2000); return () => clearTimeout(timeout); }, [copied]); return (
{photo.original_name {photo.is_featured && ( Highlight )}
{t('photos.gallery.likes', 'Likes: {{count}}', { count: photo.likes_count })} {t('photos.gallery.uploader', 'Uploader: {{name}}', { name: photo.uploader_name ?? 'Unbekannt' })}
); } function DeletePhotoDialog({ open, photo, onCancel, onConfirm, skipConfirm, onSkipChange, }: { open: boolean; photo: TenantPhoto | null; onCancel: () => void; onConfirm: () => void; skipConfirm: boolean; onSkipChange: (value: boolean) => void; }) { const { t } = useTranslation('management'); return ( { if (!nextOpen) onCancel(); }}> {t('photos.deleteDialog.title', 'Foto löschen?')} {t('photos.deleteDialog.description', 'Dieses Foto wird dauerhaft entfernt. Diese Aktion kann nicht rückgängig gemacht werden.')} {photo ? (
{photo.original_name ?? t('photos.deleteDialog.fallbackName', 'Unbenanntes Foto')}
) : null}
onSkipChange(Boolean(checked))} />
); }