1035 lines
38 KiB
TypeScript
1035 lines
38 KiB
TypeScript
// @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<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 [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
|
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
|
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
|
const [search, setSearch] = React.useState('');
|
|
const [statusFilter, setStatusFilter] = React.useState<'all' | 'featured' | 'hidden' | 'photobooth'>('all');
|
|
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
|
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<PaginationMeta | null>(null);
|
|
const [pendingDelete, setPendingDelete] = React.useState<TenantPhoto | null>(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 (
|
|
<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}
|
|
tabs={eventTabs}
|
|
currentTabKey="photos"
|
|
>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('photos.alerts.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<LimitWarningsBanner limits={limits} translate={translateLimits} eventSlug={slug} addons={addons} />
|
|
|
|
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<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>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="outline" className="border-sky-200 text-sky-700">
|
|
{t('photos.gallery.photoboothCount', '{{count}} Photobooth-Uploads', { count: photoboothUploads })}
|
|
</Badge>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => slug && navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(slug))}
|
|
disabled={!slug}
|
|
className="text-rose-600 hover:bg-rose-50"
|
|
>
|
|
{t('photos.gallery.photoboothCta', 'Photobooth-Zugang öffnen')}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<GalleryToolbar
|
|
search={search}
|
|
onSearch={setSearch}
|
|
statusFilter={statusFilter}
|
|
onStatusFilterChange={setStatusFilter}
|
|
totalCount={totalCount}
|
|
page={currentPage}
|
|
pageCount={pageCount}
|
|
onPageChange={setPage}
|
|
selectionCount={selectedIds.length}
|
|
onSelectAll={selectAllVisible}
|
|
onClearSelection={clearSelection}
|
|
onBulkHide={() => { 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 ? (
|
|
<GallerySkeleton />
|
|
) : totalCount === 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.')}
|
|
/>
|
|
) : (
|
|
<PhotoGrid
|
|
photos={paginatedPhotos}
|
|
selectedIds={selectedIds}
|
|
onToggleSelect={toggleSelect}
|
|
onToggleFeature={(photo) => { void handleToggleFeature(photo); }}
|
|
onToggleVisibility={(photo, visible) => { void handleToggleVisibility(photo, visible); }}
|
|
onRequestDelete={(photo) => { requestDelete(photo); }}
|
|
busyId={busyId}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{eventAddons.length > 0 && (
|
|
<Card className="mt-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
|
|
<CardHeader>
|
|
<CardTitle className="text-base font-semibold text-slate-900">
|
|
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<AddonSummaryList addons={eventAddons} t={(key, fallback) => t(key, fallback)} />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<DeletePhotoDialog
|
|
open={Boolean(pendingDelete)}
|
|
photo={pendingDelete}
|
|
onCancel={cancelDelete}
|
|
onConfirm={confirmDelete}
|
|
skipConfirm={skipDeleteConfirm}
|
|
onSkipChange={updateSkipDeleteConfirm}
|
|
/>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
const LIMIT_WARNING_DISMISS_KEY = 'tenant-admin:dismissed-limit-warnings';
|
|
const DELETE_CONFIRM_SKIP_KEY = 'tenant-admin:skip-photo-delete-confirm';
|
|
|
|
function readDismissedLimitWarnings(): Set<string> {
|
|
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<string>) {
|
|
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, unknown>) => string;
|
|
eventSlug: string | null;
|
|
addons: EventAddonCatalogItem[];
|
|
}) {
|
|
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
|
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
|
const [dismissedIds, setDismissedIds] = React.useState<Set<string>>(() => 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 (
|
|
<div className="mb-6 space-y-2">
|
|
{visibleWarnings.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}
|
|
>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex flex-1 items-center gap-2 text-sm">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertDescription className="flex-1 text-sm">
|
|
{warning.message}
|
|
</AlertDescription>
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
{warning.scope === 'photos' || warning.scope === 'gallery' ? (
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => { void handleCheckout(warning.scope as 'photos' | 'gallery'); }}
|
|
disabled={busyScope === warning.scope}
|
|
>
|
|
<ShoppingCart className="mr-2 h-4 w-4" />
|
|
{warning.scope === 'photos'
|
|
? translate('buyMorePhotos', { defaultValue: 'Mehr Fotos freischalten' })
|
|
: translate('extendGallery', { defaultValue: 'Galerie verlängern' })}
|
|
</Button>
|
|
{warning.scope !== 'guests' ? (
|
|
<AddonsPicker
|
|
addons={addons}
|
|
scope={warning.scope as 'photos' | 'gallery'}
|
|
onCheckout={(key) => { void handleCheckout(key); }}
|
|
busy={busyScope === warning.scope}
|
|
t={(key, fallback) => translate(key, { defaultValue: fallback })}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={dismissLabel}
|
|
className="text-slate-500 hover:text-slate-800"
|
|
onClick={() => handleDismiss(warning.id)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="mb-4 space-y-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
|
{showSearch ? (
|
|
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
|
|
<Search className="h-4 w-4 text-slate-500" />
|
|
<Input
|
|
autoFocus
|
|
value={search}
|
|
onChange={(event) => onSearch(event.target.value)}
|
|
placeholder={t('photos.filters.search', 'Uploads durchsuchen …')}
|
|
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
|
/>
|
|
<Button variant="ghost" size="icon" className="text-slate-500" onClick={onToggleSearch}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button variant="outline" size="sm" className="rounded-full" onClick={onToggleSearch}>
|
|
<Search className="mr-2 h-4 w-4" />
|
|
{t('photos.filters.searchOpen', 'Suche öffnen')}
|
|
</Button>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
{filters.map((filter) => (
|
|
<Button
|
|
key={filter.key}
|
|
variant={statusFilter === filter.key ? 'secondary' : 'outline'}
|
|
className="rounded-full"
|
|
onClick={() => onStatusFilterChange(filter.key)}
|
|
size="sm"
|
|
>
|
|
{filter.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
|
<span className="hidden lg:inline">{t('photos.filters.sort', 'Sortierung')}</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="rounded-full"
|
|
onClick={() => onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc')}
|
|
>
|
|
{sortOrder === 'desc'
|
|
? t('photos.filters.sortDesc', 'Neueste zuerst')
|
|
: t('photos.filters.sortAsc', 'Älteste zuerst')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
|
<Filter className="h-4 w-4" />
|
|
<span>{t('photos.filters.count', '{{count}} Uploads', { count: totalCount })}</span>
|
|
{selectionCount > 0 ? (
|
|
<>
|
|
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
|
{t('photos.filters.selected', '{{count}} ausgewählt', { count: selectionCount })}
|
|
</Badge>
|
|
<Button variant="outline" size="sm" onClick={onClearSelection}>
|
|
{t('photos.filters.clearSelection', 'Auswahl aufheben')}
|
|
</Button>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" onClick={onBulkHide} disabled={busy}>
|
|
<EyeOff className="mr-2 h-4 w-4" />
|
|
{t('photos.actions.hide', 'Verstecken')}
|
|
</Button>
|
|
<Button size="sm" onClick={onBulkShow} disabled={busy}>
|
|
<Eye className="mr-2 h-4 w-4" />
|
|
{t('photos.actions.show', 'Einblenden')}
|
|
</Button>
|
|
<Button size="sm" onClick={onBulkFeature} disabled={busy}>
|
|
<Star className="mr-2 h-4 w-4" />
|
|
{t('photos.actions.feature', 'Highlight')}
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={onBulkUnfeature} disabled={busy}>
|
|
<Star className="mr-2 h-4 w-4" />
|
|
{t('photos.actions.unfeature', 'Highlight entfernen')}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<Button variant="ghost" size="sm" onClick={onSelectAll}>
|
|
{t('photos.filters.selectAll', 'Alle auswählen')}
|
|
</Button>
|
|
)}
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page <= 1}
|
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
>
|
|
{t('photos.filters.prev', 'Zurück')}
|
|
</Button>
|
|
<span className="text-slate-600">
|
|
{page} / {pageCount}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= pageCount}
|
|
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
|
|
>
|
|
{t('photos.filters.next', 'Weiter')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{photos.map((photo) => (
|
|
<PhotoCard
|
|
key={photo.id}
|
|
photo={photo}
|
|
selected={selectedIds.includes(photo.id)}
|
|
onToggleSelect={() => onToggleSelect(photo.id)}
|
|
onToggleFeature={() => onToggleFeature(photo)}
|
|
onToggleVisibility={(visible) => onToggleVisibility(photo, visible)}
|
|
onRequestDelete={() => onRequestDelete(photo)}
|
|
busy={busyId === photo.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 shadow-sm">
|
|
<div className="relative mb-3">
|
|
<button
|
|
type="button"
|
|
className={`absolute left-3 top-3 z-10 rounded-full border border-white bg-white/90 px-3 py-1 text-xs font-semibold transition ${
|
|
selected ? 'text-sky-600' : 'text-slate-500'
|
|
}`}
|
|
onClick={onToggleSelect}
|
|
>
|
|
{selected ? t('photos.gallery.selected', 'Ausgewählt') : t('photos.gallery.select', 'Markieren')}
|
|
</button>
|
|
<img
|
|
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
|
alt={photo.original_name ?? 'Foto'}
|
|
className={`h-56 w-full rounded-2xl object-cover ${hidden ? 'opacity-60' : ''}`}
|
|
/>
|
|
{photo.is_featured && (
|
|
<span className="absolute right-3 top-3 rounded-full bg-pink-500/90 px-3 py-1 text-xs font-semibold text-white shadow">
|
|
Highlight
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2 text-sm text-slate-700">
|
|
<div className="flex items-center justify-between text-xs text-slate-500">
|
|
<span>{t('photos.gallery.likes', 'Likes: {{count}}', { count: photo.likes_count })}</span>
|
|
<span>{t('photos.gallery.uploader', 'Uploader: {{name}}', { name: photo.uploader_name ?? 'Unbekannt' })}</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => onToggleVisibility(hidden ? true : false)}>
|
|
{busy ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : hidden ? (
|
|
<>
|
|
<Eye className="h-4 w-4" /> {t('photos.actions.show', 'Einblenden')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<EyeOff className="h-4 w-4" /> {t('photos.actions.hide', 'Verstecken')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant={photo.is_featured ? 'secondary' : 'outline'}
|
|
size="sm"
|
|
disabled={busy}
|
|
onClick={onToggleFeature}
|
|
>
|
|
{busy ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : photo.is_featured ? (
|
|
<>
|
|
<Star className="h-4 w-4" /> {t('photos.actions.unfeature', 'Highlight entfernen')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Star className="h-4 w-4" /> {t('photos.actions.feature', 'Als Highlight setzen')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button variant="destructive" size="sm" onClick={onRequestDelete} disabled={busy}>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
|
{t('photos.actions.delete', 'Löschen')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={async () => {
|
|
if (!photo.url) {
|
|
toast.error(t('photos.actions.copyMissing', 'Kein Link verfügbar'));
|
|
return;
|
|
}
|
|
const successMessage = t('photos.actions.copySuccess', 'Link kopiert');
|
|
try {
|
|
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
|
throw new Error('clipboard_unavailable');
|
|
}
|
|
await navigator.clipboard.writeText(photo.url);
|
|
toast.success(successMessage);
|
|
setCopied(true);
|
|
} catch (err) {
|
|
// Fallback for unsichere Kontexte: zeige den Link zum manuellen Kopieren.
|
|
const promptValue = window.prompt(t('photos.actions.copyPrompt', 'Link zum Kopieren'), photo.url);
|
|
if (promptValue !== null) {
|
|
toast.success(successMessage);
|
|
setCopied(true);
|
|
} else {
|
|
toast.error(t('photos.actions.copyFailed', 'Kopieren nicht möglich. Bitte manuell kopieren.'));
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{copied ? (
|
|
<span className="flex items-center gap-1 text-emerald-600">
|
|
<Check className="h-4 w-4" /> {t('photos.actions.copyDone', 'Kopiert!')}
|
|
</span>
|
|
) : (
|
|
<>
|
|
<Copy className="h-4 w-4" /> {t('photos.actions.copy', 'Link kopieren')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onCancel(); }}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('photos.deleteDialog.title', 'Foto löschen?')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('photos.deleteDialog.description', 'Dieses Foto wird dauerhaft entfernt. Diese Aktion kann nicht rückgängig gemacht werden.')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{photo ? (
|
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
|
{photo.original_name ?? t('photos.deleteDialog.fallbackName', 'Unbenanntes Foto')}
|
|
</div>
|
|
) : null}
|
|
<div className="flex items-center gap-2 rounded-lg bg-slate-50/70 p-3 text-sm">
|
|
<Checkbox
|
|
id="skip-delete-confirm"
|
|
checked={skipConfirm}
|
|
onCheckedChange={(checked) => onSkipChange(Boolean(checked))}
|
|
/>
|
|
<Label htmlFor="skip-delete-confirm" className="text-slate-700">
|
|
{t('photos.deleteDialog.skip', 'Nicht erneut in dieser Sitzung nachfragen')}
|
|
</Label>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onCancel}>
|
|
{t('photos.actions.cancel', 'Abbrechen')}
|
|
</Button>
|
|
<Button variant="destructive" onClick={onConfirm}>
|
|
{t('photos.actions.delete', 'Löschen')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|