diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 2c20d61..5176941 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -60,19 +60,44 @@ class PhotoController extends Controller : null; $query = Photo::where('event_id', $event->id) - ->with('event')->withCount('likes') - ->orderBy('created_at', 'desc'); + ->with('event')->withCount('likes'); // Filters if ($request->has('status')) { $query->where('status', $request->status); } + if ($request->boolean('featured')) { + $query->where('is_featured', true); + } + if ($request->has('user_id')) { $query->where('uploader_id', $request->user_id); } - $perPage = $request->get('per_page', 20); + if ($request->filled('ingest_source')) { + $query->where('ingest_source', $request->string('ingest_source')); + } + + $visibility = strtolower((string) $request->input('visibility', '')); + if ($visibility === 'visible') { + $query->where('status', '!=', 'hidden'); + } elseif ($visibility === 'hidden') { + $query->where('status', 'hidden'); + } + + if ($request->filled('search')) { + $term = strtolower(trim((string) $request->get('search'))); + $query->where(function ($inner) use ($term) { + $inner->whereRaw('LOWER(original_name) LIKE ?', ['%'.$term.'%']) + ->orWhereRaw('LOWER(filename) LIKE ?', ['%'.$term.'%']); + }); + } + + $direction = strtolower($request->get('sort', 'desc')) === 'asc' ? 'asc' : 'desc'; + $query->orderBy('created_at', $direction); + + $perPage = max(1, min((int) $request->get('per_page', 40), 100)); $photos = $query->paginate($perPage); return PhotoResource::collection($photos)->additional([ @@ -80,6 +105,37 @@ class PhotoController extends Controller ]); } + public function visibility(Request $request, string $eventSlug, Photo $photo): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + $event = Event::where('slug', $eventSlug) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + if ($photo->event_id !== $event->id) { + return ApiError::response( + 'photo_not_found', + 'Foto nicht gefunden', + 'Das Foto gehört nicht zu diesem Event.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $photo->id] + ); + } + + $validated = $request->validate([ + 'visible' => ['required', 'boolean'], + ]); + + $photo->status = $validated['visible'] ? 'approved' : 'hidden'; + $photo->save(); + $photo->load('event')->loadCount('likes'); + + return response()->json([ + 'message' => 'Photo visibility updated', + 'data' => new PhotoResource($photo), + ]); + } + /** * Store a newly uploaded photo. */ @@ -397,6 +453,9 @@ class PhotoController extends Controller $assets = EventMediaAsset::where('photo_id', $photo->id)->get(); foreach ($assets as $asset) { + if (! is_string($asset->path) || $asset->path === '') { + continue; + } try { Storage::disk($asset->disk)->delete($asset->path); } catch (\Throwable $e) { @@ -412,7 +471,13 @@ class PhotoController extends Controller // Ensure legacy paths are removed if assets missing if ($assets->isEmpty()) { $fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($event); - Storage::disk($fallbackDisk)->delete([$photo->path, $photo->thumbnail_path]); + $paths = array_values(array_filter([ + is_string($photo->path) ? $photo->path : null, + is_string($photo->thumbnail_path) ? $photo->thumbnail_path : null, + ])); + if (! empty($paths)) { + Storage::disk($fallbackDisk)->delete($paths); + } } $eventPackage = $event->eventPackage; diff --git a/playwright-report/data/2b972cf6fb2da3f16cadd7202702687cdeaa7901.webm b/playwright-report/data/2b972cf6fb2da3f16cadd7202702687cdeaa7901.webm deleted file mode 100644 index 95cc501..0000000 Binary files a/playwright-report/data/2b972cf6fb2da3f16cadd7202702687cdeaa7901.webm and /dev/null differ diff --git a/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png b/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png deleted file mode 100644 index 6d360f6..0000000 Binary files a/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png and /dev/null differ diff --git a/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm b/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm deleted file mode 100644 index a0347bd..0000000 Binary files a/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm and /dev/null differ diff --git a/playwright-report/index.html b/playwright-report/index.html deleted file mode 100644 index 8b32ed0..0000000 --- a/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index c5f1c00..1d6e91a 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -1300,16 +1300,51 @@ export async function getEventTypes(): Promise { .filter((row): row is TenantEventType => Boolean(row)); } -export async function getEventPhotos(slug: string): Promise<{ photos: TenantPhoto[]; limits: EventLimitSummary | null }> { - const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`); - const data = await jsonOrThrow<{ data?: TenantPhoto[]; limits?: EventLimitSummary | null }>( +export type GetEventPhotosOptions = { + page?: number; + perPage?: number; + sort?: 'asc' | 'desc'; + search?: string; + status?: string; + featured?: boolean; + ingestSource?: string; + visibility?: 'visible' | 'hidden' | 'all'; +}; + +export async function getEventPhotos( + slug: string, + options: GetEventPhotosOptions = {} +): Promise<{ photos: TenantPhoto[]; limits: EventLimitSummary | null; meta: PaginationMeta }> { + const params = new URLSearchParams(); + if (options.page) params.set('page', String(options.page)); + if (options.perPage) params.set('per_page', String(options.perPage)); + if (options.sort) params.set('sort', options.sort); + if (options.search) params.set('search', options.search); + if (options.status) params.set('status', options.status); + if (options.featured) params.set('featured', '1'); + if (options.ingestSource) params.set('ingest_source', options.ingestSource); + if (options.visibility) params.set('visibility', options.visibility); + + const response = await authorizedFetch(`${eventEndpoint(slug)}/photos${params.toString() ? `?${params.toString()}` : ''}`); + const data = await jsonOrThrow<{ + data?: TenantPhoto[]; + limits?: EventLimitSummary | null; + meta?: Partial; + current_page?: number; + last_page?: number; + per_page?: number; + total?: number; + }>( response, 'Failed to load photos' ); + const meta = buildPagination(data as unknown as JsonValue, options.perPage ?? 40); + return { photos: (data.data ?? []).map(normalizePhoto), limits: (data.limits ?? null) as EventLimitSummary | null, + meta, }; } @@ -1328,8 +1363,12 @@ export async function unfeaturePhoto(slug: string, id: number): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`, { method: 'DELETE' }); if (!response.ok) { - await safeJson(response); - throw new Error('Failed to delete photo'); + const payload = await safeJson(response); + if (response.status === 404) { + // Treat missing files as idempotent deletes to keep the UI in sync. + return; + } + throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to delete photo'); } } diff --git a/resources/js/admin/lib/eventTabs.ts b/resources/js/admin/lib/eventTabs.ts index 59a086f..e719026 100644 --- a/resources/js/admin/lib/eventTabs.ts +++ b/resources/js/admin/lib/eventTabs.ts @@ -12,7 +12,6 @@ import { export type EventTabCounts = Partial<{ photos: number; tasks: number; - invites: number; }>; type Translator = (key: string, fallback: string) => string; @@ -26,7 +25,7 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false; const formatBadge = (value?: number | null): number | undefined => { - if (typeof value === 'number' && Number.isFinite(value)) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { return value; } return undefined; @@ -42,19 +41,18 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts key: 'photos', label: translate('eventMenu.photos', 'Uploads'), href: ADMIN_EVENT_PHOTOS_PATH(event.slug), - badge: formatBadge(counts.photos ?? event.photo_count ?? event.pending_photo_count ?? null), + badge: formatBadge(counts.photos), }, { key: 'tasks', label: translate('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(event.slug), - badge: formatBadge(counts.tasks ?? event.tasks_count ?? null), + badge: formatBadge(counts.tasks), }, { key: 'invites', label: translate('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(event.slug), - badge: formatBadge(counts.invites ?? event.active_invites_count ?? event.total_invites_count ?? null), }, { key: 'branding', diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx index 140d42e..879e1ce 100644 --- a/resources/js/admin/main.tsx +++ b/resources/js/admin/main.tsx @@ -1,6 +1,7 @@ import React, { Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from './auth/context'; import { router } from './router'; @@ -55,6 +56,7 @@ createRoot(rootEl).render( + {enableDevSwitcher ? ( diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index 179b632..70f2fbd 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -217,7 +217,6 @@ export default function EventDetailPage() { const counts = { photos: stats?.uploads_total ?? event.photo_count ?? undefined, tasks: toolkitData?.tasks?.summary?.total ?? event.tasks_count ?? undefined, - invites: event.active_invites_count ?? event.total_invites_count ?? undefined, }; return buildEventTabs(event, translateMenu, counts); }, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]); diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index 9c6e0c2..4bf73b2 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -284,7 +284,6 @@ export default function EventInvitesPage(): React.ReactElement { } const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return buildEventTabs(event, translateMenu, { - invites: state.invites.length, photos: event.photo_count ?? event.pending_photo_count ?? undefined, tasks: event.tasks_count ?? undefined, }); diff --git a/resources/js/admin/pages/EventPhotoboothPage.tsx b/resources/js/admin/pages/EventPhotoboothPage.tsx index 48bdcda..215d7b6 100644 --- a/resources/js/admin/pages/EventPhotoboothPage.tsx +++ b/resources/js/admin/pages/EventPhotoboothPage.tsx @@ -186,7 +186,6 @@ export default function EventPhotoboothPage() { } const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return buildEventTabs(event, translateMenu, { - invites: event.active_invites_count ?? event.total_invites_count ?? undefined, photos: event.photo_count ?? event.pending_photo_count ?? undefined, tasks: event.tasks_count ?? undefined, }); diff --git a/resources/js/admin/pages/EventPhotosPage.tsx b/resources/js/admin/pages/EventPhotosPage.tsx index 039752f..b154935 100644 --- a/resources/js/admin/pages/EventPhotosPage.tsx +++ b/resources/js/admin/pages/EventPhotosPage.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { AlertTriangle, Camera, + Check, Copy, Eye, EyeOff, @@ -21,13 +22,16 @@ 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 } from '../api'; +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'; @@ -36,6 +40,7 @@ import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH } 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; @@ -59,6 +64,25 @@ export default function EventPhotosPage() { 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], @@ -69,12 +93,12 @@ export default function EventPhotosPage() { 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: photos.length, - tasks: event.tasks_count ?? 0, - invites: event.active_invites_count ?? event.total_invites_count ?? 0, + photos: photoBadge, + tasks: event.tasks_count ?? undefined, }); - }, [event, photos.length, slug, t]); + }, [event, slug, t, loading, pagination?.total]); const load = React.useCallback(async () => { if (!slug) { @@ -84,15 +108,29 @@ export default function EventPhotosPage() { setLoading(true); setError(undefined); try { + const visibility = statusFilter === 'hidden' ? 'hidden' : 'visible'; const [photoResult, eventData, catalog] = await Promise.all([ - getEventPhotos(slug), + 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); + setEvent({ + ...eventData, + photo_count: photoResult.meta?.total ?? eventData.photo_count, + }); setAddons(catalog); } catch (err) { if (!isAuthError(err)) { @@ -101,7 +139,7 @@ export default function EventPhotosPage() { } finally { setLoading(false); } - }, [slug]); + }, [slug, page, sortOrder, search, statusFilter]); React.useEffect(() => { load(); @@ -142,55 +180,83 @@ export default function EventPhotosPage() { 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) { - // No dedicated visibility endpoint available; emulate by filtering locally. - setPhotos((prev) => - prev.map((entry) => - entry.id === photo.id ? { ...entry, status: visible ? 'visible' : 'hidden' } : entry, - ), - ); - setSelectedIds((prev) => prev.filter((id) => id !== photo.id)); + 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); + } } - const filteredPhotos = React.useMemo(() => { - const term = search.trim().toLowerCase(); - return photos.filter((photo) => { - const matchesSearch = - term.length === 0 || - (photo.original_name ?? '').toLowerCase().includes(term) || - (photo.filename ?? '').toLowerCase().includes(term); - if (!matchesSearch) { - return false; - } - if (statusFilter === 'featured') { - return Boolean(photo.is_featured); - } - if (statusFilter === 'hidden') { - return photo.status === 'hidden'; - } - if (statusFilter === 'photobooth') { - return photo.ingest_source === 'photobooth'; - } - return true; - }); - }, [photos, search, statusFilter]); + 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(filteredPhotos.map((photo) => photo.id)); - }, [filteredPhotos]); + setSelectedIds(paginatedPhotos.map((photo) => photo.id)); + }, [paginatedPhotos]); const clearSelection = React.useCallback(() => { setSelectedIds([]); @@ -203,35 +269,62 @@ export default function EventPhotosPage() { const handleBulkVisibility = React.useCallback( async (visible: boolean) => { - if (!selectedPhotos.length) return; + if (!slug || !selectedPhotos.length) return; setBulkBusy(true); - await Promise.all(selectedPhotos.map((photo) => handleToggleVisibility(photo, visible))); - setBulkBusy(false); + 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], + [selectedPhotos, slug], ); const handleBulkFeature = React.useCallback( async (featured: boolean) => { if (!slug || !selectedPhotos.length) return; setBulkBusy(true); - 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.')); + 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); } - } finally { - setBusyId(null); } + setSelectedIds([]); + } finally { + setBulkBusy(false); } - setSelectedIds([]); - setBulkBusy(false); }, [selectedPhotos, slug], ); @@ -309,7 +402,10 @@ export default function EventPhotosPage() { onSearch={setSearch} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} - totalCount={filteredPhotos.length} + totalCount={totalCount} + page={currentPage} + pageCount={pageCount} + onPageChange={setPage} selectionCount={selectedIds.length} onSelectAll={selectAllVisible} onClearSelection={clearSelection} @@ -318,22 +414,26 @@ export default function EventPhotosPage() { onBulkFeature={() => { void handleBulkFeature(true); }} onBulkUnfeature={() => { void handleBulkFeature(false); }} busy={bulkBusy} + sortOrder={sortOrder} + onSortOrderChange={setSortOrder} + showSearch={showSearch} + onToggleSearch={() => setShowSearch((prev) => !prev)} /> {loading ? ( - ) : filteredPhotos.length === 0 ? ( + ) : totalCount === 0 ? ( ) : ( { void handleToggleFeature(photo); }} onToggleVisibility={(photo, visible) => { void handleToggleVisibility(photo, visible); }} - onDelete={(photo) => { void handleDelete(photo); }} + onRequestDelete={(photo) => { requestDelete(photo); }} busyId={busyId} /> )} @@ -355,11 +455,21 @@ export default function EventPhotosPage() { )} + + ); } 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') { @@ -536,9 +646,14 @@ function EmptyGallery({ title, description }: { title: string; description: stri function GalleryToolbar({ search, onSearch, + showSearch, + onToggleSearch, statusFilter, onStatusFilterChange, totalCount, + page, + pageCount, + onPageChange, selectionCount, onSelectAll, onClearSelection, @@ -546,13 +661,20 @@ function GalleryToolbar({ 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; @@ -560,6 +682,8 @@ function GalleryToolbar({ onBulkShow: () => void; onBulkFeature: () => void; onBulkUnfeature: () => void; + sortOrder: 'desc' | 'asc'; + onSortOrderChange: (value: 'desc' | 'asc') => void; busy: boolean; }) { const { t } = useTranslation('management'); @@ -573,27 +697,53 @@ function GalleryToolbar({ return (
-
- - 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) => ( - +
+ ) : ( + - ))} + )} +
+ {filters.map((filter) => ( + + ))} +
+
+
+ {t('photos.filters.sort', 'Sortierung')} +
@@ -631,6 +781,27 @@ function GalleryToolbar({ {t('photos.filters.selectAll', 'Alle auswählen')} )} +
+ + + {page} / {pageCount} + + +
); @@ -642,7 +813,7 @@ function PhotoGrid({ onToggleSelect, onToggleFeature, onToggleVisibility, - onDelete, + onRequestDelete, busyId, }: { photos: TenantPhoto[]; @@ -650,7 +821,7 @@ function PhotoGrid({ onToggleSelect: (id: number) => void; onToggleFeature: (photo: TenantPhoto) => void; onToggleVisibility: (photo: TenantPhoto, visible: boolean) => void; - onDelete: (photo: TenantPhoto) => void; + onRequestDelete: (photo: TenantPhoto) => void; busyId: number | null; }) { return ( @@ -663,7 +834,7 @@ function PhotoGrid({ onToggleSelect={() => onToggleSelect(photo.id)} onToggleFeature={() => onToggleFeature(photo)} onToggleVisibility={(visible) => onToggleVisibility(photo, visible)} - onDelete={() => onDelete(photo)} + onRequestDelete={() => onRequestDelete(photo)} busy={busyId === photo.id} /> ))} @@ -677,7 +848,7 @@ function PhotoCard({ onToggleSelect, onToggleFeature, onToggleVisibility, - onDelete, + onRequestDelete, busy, }: { photo: TenantPhoto; @@ -685,11 +856,20 @@ function PhotoCard({ onToggleSelect: () => void; onToggleFeature: () => void; onToggleVisibility: (visible: boolean) => void; - onDelete: () => 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 (
@@ -720,7 +900,7 @@ function PhotoCard({ {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))} + /> + +
+ + + + +
+
+ ); +} diff --git a/resources/js/admin/pages/EventRecapPage.tsx b/resources/js/admin/pages/EventRecapPage.tsx index 23513e0..695dd17 100644 --- a/resources/js/admin/pages/EventRecapPage.tsx +++ b/resources/js/admin/pages/EventRecapPage.tsx @@ -211,8 +211,7 @@ export default function EventRecapPage() { const eventTabs = event ? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), { photos: stats?.uploads_total ?? event.photo_count ?? undefined, - tasks: stats?.uploads_total ?? event.tasks_count ?? undefined, - invites: event.active_invites_count ?? event.total_invites_count ?? undefined, + tasks: event.tasks_count ?? undefined, }) : []; diff --git a/resources/js/admin/pages/EventTasksPage.tsx b/resources/js/admin/pages/EventTasksPage.tsx index 952a416..daf831a 100644 --- a/resources/js/admin/pages/EventTasksPage.tsx +++ b/resources/js/admin/pages/EventTasksPage.tsx @@ -171,12 +171,12 @@ export default function EventTasksPage() { return []; } const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const taskBadge = loading ? undefined : assignedTasks.length; return buildEventTabs(event, translateMenu, { - photos: event.photo_count ?? 0, - tasks: assignedTasks.length, - invites: event.active_invites_count ?? event.total_invites_count ?? 0, + photos: event.photo_count ?? undefined, + tasks: taskBadge, }); - }, [event, assignedTasks.length, t]); + }, [event, assignedTasks.length, t, loading]); React.useEffect(() => { let cancelled = false; diff --git a/routes/api.php b/routes/api.php index faca7c2..beffd12 100644 --- a/routes/api.php +++ b/routes/api.php @@ -182,6 +182,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::delete('{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy'); Route::post('{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature'); Route::post('{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature'); + Route::post('{photo}/visibility', [PhotoController::class, 'visibility'])->name('tenant.events.photos.visibility'); Route::post('bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve'); Route::post('bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject'); Route::get('moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation');