From bfa15cc48ea04e59467a589eb19f747081da5115 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 26 Nov 2025 17:49:55 +0100 Subject: [PATCH] =?UTF-8?q?die=20eventphotospage=20funktioniert=20nun=20zu?= =?UTF-8?q?verl=C3=A4ssig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Api/Tenant/PhotoController.php | 73 ++- ...72cf6fb2da3f16cadd7202702687cdeaa7901.webm | Bin 27311 -> 0 bytes ...33d5db6370b6de345e990751aa1f1da65ad675.png | Bin 4253 -> 0 bytes ...93c4cccd959a8ecdb174bd08799b82fa8c840.webm | Bin 18727 -> 0 bytes playwright-report/index.html | 85 ---- resources/js/admin/api.ts | 49 +- resources/js/admin/lib/eventTabs.ts | 8 +- resources/js/admin/main.tsx | 2 + resources/js/admin/pages/EventDetailPage.tsx | 1 - resources/js/admin/pages/EventInvitesPage.tsx | 1 - .../js/admin/pages/EventPhotoboothPage.tsx | 1 - resources/js/admin/pages/EventPhotosPage.tsx | 448 ++++++++++++++---- resources/js/admin/pages/EventRecapPage.tsx | 3 +- resources/js/admin/pages/EventTasksPage.tsx | 8 +- routes/api.php | 1 + 15 files changed, 478 insertions(+), 202 deletions(-) delete mode 100644 playwright-report/data/2b972cf6fb2da3f16cadd7202702687cdeaa7901.webm delete mode 100644 playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png delete mode 100644 playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm delete mode 100644 playwright-report/index.html 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 95cc501f49a98e07c918cf471f8b5d09c2f05eff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27311 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 diff --git a/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png b/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png deleted file mode 100644 index 6d360f6bba60307ddce12a4bda5ae0e2ff9278b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4253 zcmeAS@N?(olHy`uVBq!ia0y~yUeX7 q@D_FkhX4QX9*X@7G?5KtA~VB;)qHl1Z#nXSA`G6celF{r5}E*b2*WS{ diff --git a/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm b/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm deleted file mode 100644 index a0347bde4087f698e07748a361559d49e939e6ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18727 zcmeI)e~8<69mnxclI!)lc5T|L^Oyq<9rhffWQvlhe`ML6%fz;^*Ul({v8}tvv_{U= z=tREXwB3E@21X7f`{9}rl*qUi`Owniv(KG-+xmNU=&N$QwSh2?I=ApF8v52k$!Q|4Jl2 zdCyxOPCw?Pe)WxThG6(#zOXvm%&T@}{^!nR`{3>OjV&I1#y0Qz@iS*1K7Gou&7;pv6NMB+pK{jTJ}yWf|Pn|bHGFFt(q#bw*n)8Dm?u{uZ0_ak>q#;!!n zePid(J5Qc-qGdCB%gD7+`_zSpe*5%+jY z=FNne`0`(Oz5UJ6(Vf?0#$12RzjdvZ*C$Q=S=(gISE8HP(0NJDGpl7bowvgOUz79r z(7CrG71Vdb^Y|Zz&acL;`bFD#=91q(-dsO7wQs%d-?qk%P$d`kP_B9SzF=n zmx2)@DLMbl(D^?;X6hI0NI}k1L+97e6hlxEaOV(Md2AvCEdjR=gV#zS=mCh15l9fo z5wHj}2y_Sx0K_H$P;h~GzoM8 z+++w$5y%oK5vUPx33vc@P7p{DC=jR+Xc6cE*fmBVK_ExKBG4evAus@N^CW=`fg*t_ zfi{6YfZcHdNdkES4uK|tE`U9Tz!ZTjff9il0hfRWAU;7LMW8^SLZC&U2Vn0QfdqjZ z0gFI`K!?Bpz`jWW83IKDRRV1SeE{Qe0!adS0*(g4?CPh>zkYG+ zCkYxzFZv;Z70<54U)#2?#$WFU<1g#kUgyrmKR+^mYI)DbJ^LS@-7L2Lz4Z$G53fMh ztXw_)0aO2>ZAw7^wtxM7JrsbgNOogt!P|SoZ6sW4z$M@TkOWIfJLA|phI8)Kr$?4 z&9MwsC=#d=XcOoIkPJ)NVn>oHRfMi(87JCy^AxFR> z&>+wuFaRJKma^u)3{@x+s1j%s=mU@pOW9&PNfq)091VmS*3xqMKz=>LQl^m%O9NT0 zS<4PETxj_Ur&Jmawytg;p zPy{9cmIhK(p+KNQphchuKmsgf3kk3^kfRnBfd+vNfdK#su#_z%z|ugGT2u+N34|9I zWd;ebG?0WABY6Vh1x9PpqzYXC5@0DUB*4-@mRghu)Cjl)JOC14DO*T@rGWyqs1Rro z=mC%bOBqrEEDhwSg+-u2phI8)Kmsgf3kk3^P^1=B0&N0)01{v+LrQ?9fjqTv2s8*Jd;)934M$*w;LWdr@B*WO@B*WO9<-1E zOQ|3MmIlHLj0P;4p+O+Lz-TSP3ycCJz|uelW{8Cs7_CBhfzd#lTJ!-(fTgsMPhe>v zPc0k*O#)p25@0DqN`R$-EVU>Rs1a}pcmO28Qnru)O9KUJQ6bPG&;uX=mNKLSSQ^Mt z3yVO5K!?BpfCN~|77}1-phzvM1lk1p03^UthLiwH19@uU5NHzU0+0Yp8Bzi)4P>cB zi9n5jOTYsl0hY3b1Xvm

TwI7J(iB39ytQCBV`^j#^j*8U#871^^_$Qnru)O9Mq} zQ6UXxBVY(bhR95cy?^5a=E;J?V0_j dJo}@;GgE8N9GLQKCwS%~YtP*MN6-GnzW{+#QI-Gz 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');