die eventphotospage funktioniert nun zuverlässig

This commit is contained in:
Codex Agent
2025-11-26 17:49:55 +01:00
parent 8b395ab552
commit bfa15cc48e
15 changed files with 478 additions and 202 deletions

View File

@@ -60,19 +60,44 @@ class PhotoController extends Controller
: null; : null;
$query = Photo::where('event_id', $event->id) $query = Photo::where('event_id', $event->id)
->with('event')->withCount('likes') ->with('event')->withCount('likes');
->orderBy('created_at', 'desc');
// Filters // Filters
if ($request->has('status')) { if ($request->has('status')) {
$query->where('status', $request->status); $query->where('status', $request->status);
} }
if ($request->boolean('featured')) {
$query->where('is_featured', true);
}
if ($request->has('user_id')) { if ($request->has('user_id')) {
$query->where('uploader_id', $request->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); $photos = $query->paginate($perPage);
return PhotoResource::collection($photos)->additional([ 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. * Store a newly uploaded photo.
*/ */
@@ -397,6 +453,9 @@ class PhotoController extends Controller
$assets = EventMediaAsset::where('photo_id', $photo->id)->get(); $assets = EventMediaAsset::where('photo_id', $photo->id)->get();
foreach ($assets as $asset) { foreach ($assets as $asset) {
if (! is_string($asset->path) || $asset->path === '') {
continue;
}
try { try {
Storage::disk($asset->disk)->delete($asset->path); Storage::disk($asset->disk)->delete($asset->path);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -412,7 +471,13 @@ class PhotoController extends Controller
// Ensure legacy paths are removed if assets missing // Ensure legacy paths are removed if assets missing
if ($assets->isEmpty()) { if ($assets->isEmpty()) {
$fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($event); $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; $eventPackage = $event->eventPackage;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1300,16 +1300,51 @@ export async function getEventTypes(): Promise<TenantEventType[]> {
.filter((row): row is TenantEventType => Boolean(row)); .filter((row): row is TenantEventType => Boolean(row));
} }
export async function getEventPhotos(slug: string): Promise<{ photos: TenantPhoto[]; limits: EventLimitSummary | null }> { export type GetEventPhotosOptions = {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`); page?: number;
const data = await jsonOrThrow<{ data?: TenantPhoto[]; limits?: EventLimitSummary | null }>( 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<PaginationMeta>;
current_page?: number;
last_page?: number;
per_page?: number;
total?: number;
}>(
response, response,
'Failed to load photos' 'Failed to load photos'
); );
const meta = buildPagination(data as unknown as JsonValue, options.perPage ?? 40);
return { return {
photos: (data.data ?? []).map(normalizePhoto), photos: (data.data ?? []).map(normalizePhoto),
limits: (data.limits ?? null) as EventLimitSummary | null, limits: (data.limits ?? null) as EventLimitSummary | null,
meta,
}; };
} }
@@ -1328,8 +1363,12 @@ export async function unfeaturePhoto(slug: string, id: number): Promise<TenantPh
export async function deletePhoto(slug: string, id: number): Promise<void> { export async function deletePhoto(slug: string, id: number): Promise<void> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`, { method: 'DELETE' }); const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`, { method: 'DELETE' });
if (!response.ok) { if (!response.ok) {
await safeJson(response); const payload = await safeJson(response);
throw new Error('Failed to delete photo'); 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');
} }
} }

View File

@@ -12,7 +12,6 @@ import {
export type EventTabCounts = Partial<{ export type EventTabCounts = Partial<{
photos: number; photos: number;
tasks: number; tasks: number;
invites: number;
}>; }>;
type Translator = (key: string, fallback: string) => string; 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 hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false;
const formatBadge = (value?: number | null): number | undefined => { 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 value;
} }
return undefined; return undefined;
@@ -42,19 +41,18 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
key: 'photos', key: 'photos',
label: translate('eventMenu.photos', 'Uploads'), label: translate('eventMenu.photos', 'Uploads'),
href: ADMIN_EVENT_PHOTOS_PATH(event.slug), 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', key: 'tasks',
label: translate('eventMenu.tasks', 'Aufgaben'), label: translate('eventMenu.tasks', 'Aufgaben'),
href: ADMIN_EVENT_TASKS_PATH(event.slug), href: ADMIN_EVENT_TASKS_PATH(event.slug),
badge: formatBadge(counts.tasks ?? event.tasks_count ?? null), badge: formatBadge(counts.tasks),
}, },
{ {
key: 'invites', key: 'invites',
label: translate('eventMenu.invites', 'Einladungen'), label: translate('eventMenu.invites', 'Einladungen'),
href: ADMIN_EVENT_INVITES_PATH(event.slug), href: ADMIN_EVENT_INVITES_PATH(event.slug),
badge: formatBadge(counts.invites ?? event.active_invites_count ?? event.total_invites_count ?? null),
}, },
{ {
key: 'branding', key: 'branding',

View File

@@ -1,6 +1,7 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './auth/context'; import { AuthProvider } from './auth/context';
import { router } from './router'; import { router } from './router';
@@ -55,6 +56,7 @@ createRoot(rootEl).render(
</OnboardingProgressProvider> </OnboardingProgressProvider>
</EventProvider> </EventProvider>
</AuthProvider> </AuthProvider>
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
{enableDevSwitcher ? ( {enableDevSwitcher ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<DevTenantSwitcher /> <DevTenantSwitcher />

View File

@@ -217,7 +217,6 @@ export default function EventDetailPage() {
const counts = { const counts = {
photos: stats?.uploads_total ?? event.photo_count ?? undefined, photos: stats?.uploads_total ?? event.photo_count ?? undefined,
tasks: toolkitData?.tasks?.summary?.total ?? event.tasks_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); return buildEventTabs(event, translateMenu, counts);
}, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]); }, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]);

View File

@@ -284,7 +284,6 @@ export default function EventInvitesPage(): React.ReactElement {
} }
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return buildEventTabs(event, translateMenu, { return buildEventTabs(event, translateMenu, {
invites: state.invites.length,
photos: event.photo_count ?? event.pending_photo_count ?? undefined, photos: event.photo_count ?? event.pending_photo_count ?? undefined,
tasks: event.tasks_count ?? undefined, tasks: event.tasks_count ?? undefined,
}); });

View File

@@ -186,7 +186,6 @@ export default function EventPhotoboothPage() {
} }
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return buildEventTabs(event, translateMenu, { return buildEventTabs(event, translateMenu, {
invites: event.active_invites_count ?? event.total_invites_count ?? undefined,
photos: event.photo_count ?? event.pending_photo_count ?? undefined, photos: event.photo_count ?? event.pending_photo_count ?? undefined,
tasks: event.tasks_count ?? undefined, tasks: event.tasks_count ?? undefined,
}); });

View File

@@ -4,6 +4,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { import {
AlertTriangle, AlertTriangle,
Camera, Camera,
Check,
Copy, Copy,
Eye, Eye,
EyeOff, EyeOff,
@@ -21,13 +22,16 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; 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 toast from 'react-hot-toast';
import { AddonsPicker } from '../components/Addons/AddonsPicker'; import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList'; import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { getAddonCatalog, type EventAddonCatalogItem, type EventAddonSummary } from '../api'; import { getAddonCatalog, type EventAddonCatalogItem, type EventAddonSummary } from '../api';
import { AdminLayout } from '../components/AdminLayout'; 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 { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; 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'; import { buildEventTabs } from '../lib/eventTabs';
export default function EventPhotosPage() { export default function EventPhotosPage() {
const PAGE_SIZE = 40;
const params = useParams<{ slug?: string }>(); const params = useParams<{ slug?: string }>();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const slug = params.slug ?? searchParams.get('slug') ?? null; 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 [statusFilter, setStatusFilter] = React.useState<'all' | 'featured' | 'hidden' | 'photobooth'>('all');
const [selectedIds, setSelectedIds] = React.useState<number[]>([]); const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const [bulkBusy, setBulkBusy] = React.useState(false); 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( const photoboothUploads = React.useMemo(
() => photos.filter((photo) => photo.ingest_source === 'photobooth').length, () => photos.filter((photo) => photo.ingest_source === 'photobooth').length,
[photos], [photos],
@@ -69,12 +93,12 @@ export default function EventPhotosPage() {
return []; return [];
} }
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const photoBadge = !loading ? event.photo_count ?? pagination?.total : undefined;
return buildEventTabs(event, translateMenu, { return buildEventTabs(event, translateMenu, {
photos: photos.length, photos: photoBadge,
tasks: event.tasks_count ?? 0, tasks: event.tasks_count ?? undefined,
invites: event.active_invites_count ?? event.total_invites_count ?? 0,
}); });
}, [event, photos.length, slug, t]); }, [event, slug, t, loading, pagination?.total]);
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!slug) { if (!slug) {
@@ -84,15 +108,29 @@ export default function EventPhotosPage() {
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
try { try {
const visibility = statusFilter === 'hidden' ? 'hidden' : 'visible';
const [photoResult, eventData, catalog] = await Promise.all([ 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), getEvent(slug),
getAddonCatalog(), getAddonCatalog(),
]); ]);
setPhotos(photoResult.photos); setPhotos(photoResult.photos);
setLimits(photoResult.limits ?? null); setLimits(photoResult.limits ?? null);
setPagination(photoResult.meta ?? null);
setEventAddons(eventData.addons ?? []); setEventAddons(eventData.addons ?? []);
setEvent(eventData); setEvent({
...eventData,
photo_count: photoResult.meta?.total ?? eventData.photo_count,
});
setAddons(catalog); setAddons(catalog);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -101,7 +139,7 @@ export default function EventPhotosPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [slug]); }, [slug, page, sortOrder, search, statusFilter]);
React.useEffect(() => { React.useEffect(() => {
load(); load();
@@ -142,55 +180,83 @@ export default function EventPhotosPage() {
try { try {
await deletePhoto(slug, photo.id); await deletePhoto(slug, photo.id);
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id)); setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
toast.success(t('photos.actions.deleteSuccess', 'Foto gelöscht'));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.')); setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.'));
toast.error(t('photos.actions.deleteFailed', 'Foto konnte nicht entfernt werden.'));
} }
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
} }
async function handleToggleVisibility(photo: TenantPhoto, visible: boolean) { const requestDelete = React.useCallback(
// No dedicated visibility endpoint available; emulate by filtering locally. (photo: TenantPhoto) => {
setPhotos((prev) => if (skipDeleteConfirm) {
prev.map((entry) => void handleDelete(photo);
entry.id === photo.id ? { ...entry, status: visible ? 'visible' : 'hidden' } : entry, 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)); 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(() => { React.useEffect(() => {
const term = search.trim().toLowerCase(); setPage(1);
return photos.filter((photo) => { }, [search, statusFilter, sortOrder]);
const matchesSearch =
term.length === 0 || const pageCount = pagination?.last_page ?? 1;
(photo.original_name ?? '').toLowerCase().includes(term) || const currentPage = Math.min(page, pageCount || 1);
(photo.filename ?? '').toLowerCase().includes(term); const paginatedPhotos = photos;
if (!matchesSearch) { const totalCount = pagination?.total ?? photos.length;
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]);
const toggleSelect = React.useCallback((photoId: number) => { const toggleSelect = React.useCallback((photoId: number) => {
setSelectedIds((prev) => (prev.includes(photoId) ? prev.filter((id) => id !== photoId) : [...prev, photoId])); setSelectedIds((prev) => (prev.includes(photoId) ? prev.filter((id) => id !== photoId) : [...prev, photoId]));
}, []); }, []);
const selectAllVisible = React.useCallback(() => { const selectAllVisible = React.useCallback(() => {
setSelectedIds(filteredPhotos.map((photo) => photo.id)); setSelectedIds(paginatedPhotos.map((photo) => photo.id));
}, [filteredPhotos]); }, [paginatedPhotos]);
const clearSelection = React.useCallback(() => { const clearSelection = React.useCallback(() => {
setSelectedIds([]); setSelectedIds([]);
@@ -203,18 +269,43 @@ export default function EventPhotosPage() {
const handleBulkVisibility = React.useCallback( const handleBulkVisibility = React.useCallback(
async (visible: boolean) => { async (visible: boolean) => {
if (!selectedPhotos.length) return; if (!slug || !selectedPhotos.length) return;
setBulkBusy(true); setBulkBusy(true);
await Promise.all(selectedPhotos.map((photo) => handleToggleVisibility(photo, visible))); 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); setBulkBusy(false);
}
}, },
[selectedPhotos], [selectedPhotos, slug],
); );
const handleBulkFeature = React.useCallback( const handleBulkFeature = React.useCallback(
async (featured: boolean) => { async (featured: boolean) => {
if (!slug || !selectedPhotos.length) return; if (!slug || !selectedPhotos.length) return;
setBulkBusy(true); setBulkBusy(true);
try {
for (const photo of selectedPhotos) { for (const photo of selectedPhotos) {
setBusyId(photo.id); setBusyId(photo.id);
try { try {
@@ -231,7 +322,9 @@ export default function EventPhotosPage() {
} }
} }
setSelectedIds([]); setSelectedIds([]);
} finally {
setBulkBusy(false); setBulkBusy(false);
}
}, },
[selectedPhotos, slug], [selectedPhotos, slug],
); );
@@ -309,7 +402,10 @@ export default function EventPhotosPage() {
onSearch={setSearch} onSearch={setSearch}
statusFilter={statusFilter} statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter} onStatusFilterChange={setStatusFilter}
totalCount={filteredPhotos.length} totalCount={totalCount}
page={currentPage}
pageCount={pageCount}
onPageChange={setPage}
selectionCount={selectedIds.length} selectionCount={selectedIds.length}
onSelectAll={selectAllVisible} onSelectAll={selectAllVisible}
onClearSelection={clearSelection} onClearSelection={clearSelection}
@@ -318,22 +414,26 @@ export default function EventPhotosPage() {
onBulkFeature={() => { void handleBulkFeature(true); }} onBulkFeature={() => { void handleBulkFeature(true); }}
onBulkUnfeature={() => { void handleBulkFeature(false); }} onBulkUnfeature={() => { void handleBulkFeature(false); }}
busy={bulkBusy} busy={bulkBusy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
showSearch={showSearch}
onToggleSearch={() => setShowSearch((prev) => !prev)}
/> />
{loading ? ( {loading ? (
<GallerySkeleton /> <GallerySkeleton />
) : filteredPhotos.length === 0 ? ( ) : totalCount === 0 ? (
<EmptyGallery <EmptyGallery
title={t('photos.gallery.emptyTitle', 'Noch keine Fotos vorhanden')} 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.')} description={t('photos.gallery.emptyDescription', 'Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.')}
/> />
) : ( ) : (
<PhotoGrid <PhotoGrid
photos={filteredPhotos} photos={paginatedPhotos}
selectedIds={selectedIds} selectedIds={selectedIds}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
onToggleFeature={(photo) => { void handleToggleFeature(photo); }} onToggleFeature={(photo) => { void handleToggleFeature(photo); }}
onToggleVisibility={(photo, visible) => { void handleToggleVisibility(photo, visible); }} onToggleVisibility={(photo, visible) => { void handleToggleVisibility(photo, visible); }}
onDelete={(photo) => { void handleDelete(photo); }} onRequestDelete={(photo) => { requestDelete(photo); }}
busyId={busyId} busyId={busyId}
/> />
)} )}
@@ -355,11 +455,21 @@ export default function EventPhotosPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
<DeletePhotoDialog
open={Boolean(pendingDelete)}
photo={pendingDelete}
onCancel={cancelDelete}
onConfirm={confirmDelete}
skipConfirm={skipDeleteConfirm}
onSkipChange={updateSkipDeleteConfirm}
/>
</AdminLayout> </AdminLayout>
); );
} }
const LIMIT_WARNING_DISMISS_KEY = 'tenant-admin:dismissed-limit-warnings'; 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> { function readDismissedLimitWarnings(): Set<string> {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -536,9 +646,14 @@ function EmptyGallery({ title, description }: { title: string; description: stri
function GalleryToolbar({ function GalleryToolbar({
search, search,
onSearch, onSearch,
showSearch,
onToggleSearch,
statusFilter, statusFilter,
onStatusFilterChange, onStatusFilterChange,
totalCount, totalCount,
page,
pageCount,
onPageChange,
selectionCount, selectionCount,
onSelectAll, onSelectAll,
onClearSelection, onClearSelection,
@@ -546,13 +661,20 @@ function GalleryToolbar({
onBulkShow, onBulkShow,
onBulkFeature, onBulkFeature,
onBulkUnfeature, onBulkUnfeature,
sortOrder,
onSortOrderChange,
busy, busy,
}: { }: {
search: string; search: string;
onSearch: (value: string) => void; onSearch: (value: string) => void;
showSearch: boolean;
onToggleSearch: () => void;
statusFilter: 'all' | 'featured' | 'hidden' | 'photobooth'; statusFilter: 'all' | 'featured' | 'hidden' | 'photobooth';
onStatusFilterChange: (value: 'all' | 'featured' | 'hidden' | 'photobooth') => void; onStatusFilterChange: (value: 'all' | 'featured' | 'hidden' | 'photobooth') => void;
totalCount: number; totalCount: number;
page: number;
pageCount: number;
onPageChange: (page: number) => void;
selectionCount: number; selectionCount: number;
onSelectAll: () => void; onSelectAll: () => void;
onClearSelection: () => void; onClearSelection: () => void;
@@ -560,6 +682,8 @@ function GalleryToolbar({
onBulkShow: () => void; onBulkShow: () => void;
onBulkFeature: () => void; onBulkFeature: () => void;
onBulkUnfeature: () => void; onBulkUnfeature: () => void;
sortOrder: 'desc' | 'asc';
onSortOrderChange: (value: 'desc' | 'asc') => void;
busy: boolean; busy: boolean;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -573,15 +697,27 @@ function GalleryToolbar({
return ( return (
<div className="mb-4 space-y-4"> <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-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-1 items-center gap-2 rounded-full border border-slate-200 px-3 py-1"> <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" /> <Search className="h-4 w-4 text-slate-500" />
<Input <Input
autoFocus
value={search} value={search}
onChange={(event) => onSearch(event.target.value)} onChange={(event) => onSearch(event.target.value)}
placeholder={t('photos.filters.search', 'Uploads durchsuchen …')} placeholder={t('photos.filters.search', 'Uploads durchsuchen …')}
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0" 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> </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"> <div className="flex flex-wrap gap-2">
{filters.map((filter) => ( {filters.map((filter) => (
<Button <Button
@@ -596,6 +732,20 @@ function GalleryToolbar({
))} ))}
</div> </div>
</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"> <div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
<span>{t('photos.filters.count', '{{count}} Uploads', { count: totalCount })}</span> <span>{t('photos.filters.count', '{{count}} Uploads', { count: totalCount })}</span>
@@ -631,6 +781,27 @@ function GalleryToolbar({
{t('photos.filters.selectAll', 'Alle auswählen')} {t('photos.filters.selectAll', 'Alle auswählen')}
</Button> </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>
</div> </div>
); );
@@ -642,7 +813,7 @@ function PhotoGrid({
onToggleSelect, onToggleSelect,
onToggleFeature, onToggleFeature,
onToggleVisibility, onToggleVisibility,
onDelete, onRequestDelete,
busyId, busyId,
}: { }: {
photos: TenantPhoto[]; photos: TenantPhoto[];
@@ -650,7 +821,7 @@ function PhotoGrid({
onToggleSelect: (id: number) => void; onToggleSelect: (id: number) => void;
onToggleFeature: (photo: TenantPhoto) => void; onToggleFeature: (photo: TenantPhoto) => void;
onToggleVisibility: (photo: TenantPhoto, visible: boolean) => void; onToggleVisibility: (photo: TenantPhoto, visible: boolean) => void;
onDelete: (photo: TenantPhoto) => void; onRequestDelete: (photo: TenantPhoto) => void;
busyId: number | null; busyId: number | null;
}) { }) {
return ( return (
@@ -663,7 +834,7 @@ function PhotoGrid({
onToggleSelect={() => onToggleSelect(photo.id)} onToggleSelect={() => onToggleSelect(photo.id)}
onToggleFeature={() => onToggleFeature(photo)} onToggleFeature={() => onToggleFeature(photo)}
onToggleVisibility={(visible) => onToggleVisibility(photo, visible)} onToggleVisibility={(visible) => onToggleVisibility(photo, visible)}
onDelete={() => onDelete(photo)} onRequestDelete={() => onRequestDelete(photo)}
busy={busyId === photo.id} busy={busyId === photo.id}
/> />
))} ))}
@@ -677,7 +848,7 @@ function PhotoCard({
onToggleSelect, onToggleSelect,
onToggleFeature, onToggleFeature,
onToggleVisibility, onToggleVisibility,
onDelete, onRequestDelete,
busy, busy,
}: { }: {
photo: TenantPhoto; photo: TenantPhoto;
@@ -685,11 +856,20 @@ function PhotoCard({
onToggleSelect: () => void; onToggleSelect: () => void;
onToggleFeature: () => void; onToggleFeature: () => void;
onToggleVisibility: (visible: boolean) => void; onToggleVisibility: (visible: boolean) => void;
onDelete: () => void; onRequestDelete: () => void;
busy: boolean; busy: boolean;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const hidden = photo.status === 'hidden'; 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 ( return (
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 shadow-sm"> <div className="rounded-2xl border border-slate-200 bg-white/90 p-4 shadow-sm">
@@ -720,7 +900,7 @@ function PhotoCard({
<span>{t('photos.gallery.uploader', 'Uploader: {{name}}', { name: photo.uploader_name ?? 'Unbekannt' })}</span> <span>{t('photos.gallery.uploader', 'Uploader: {{name}}', { name: photo.uploader_name ?? 'Unbekannt' })}</span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={busy} onClick={() => onToggleVisibility(!hidden)}> <Button variant="outline" size="sm" disabled={busy} onClick={() => onToggleVisibility(hidden ? true : false)}>
{busy ? ( {busy ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : hidden ? ( ) : hidden ? (
@@ -751,24 +931,104 @@ function PhotoCard({
</> </>
)} )}
</Button> </Button>
<Button variant="destructive" size="sm" onClick={onDelete} disabled={busy}> <Button variant="destructive" size="sm" onClick={onRequestDelete} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
{t('photos.actions.delete', 'Löschen')} {t('photos.actions.delete', 'Löschen')}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={async () => {
if (!photo.url) return; if (!photo.url) {
navigator.clipboard.writeText(photo.url).then(() => { toast.error(t('photos.actions.copyMissing', 'Kein Link verfügbar'));
toast.success(t('photos.actions.copySuccess', 'Link kopiert')); 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')} <Copy className="h-4 w-4" /> {t('photos.actions.copy', 'Link kopieren')}
</>
)}
</Button> </Button>
</div> </div>
</div> </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>
);
}

View File

@@ -211,8 +211,7 @@ export default function EventRecapPage() {
const eventTabs = event const eventTabs = event
? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), { ? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), {
photos: stats?.uploads_total ?? event.photo_count ?? undefined, photos: stats?.uploads_total ?? event.photo_count ?? undefined,
tasks: stats?.uploads_total ?? event.tasks_count ?? undefined, tasks: event.tasks_count ?? undefined,
invites: event.active_invites_count ?? event.total_invites_count ?? undefined,
}) })
: []; : [];

View File

@@ -171,12 +171,12 @@ export default function EventTasksPage() {
return []; return [];
} }
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const taskBadge = loading ? undefined : assignedTasks.length;
return buildEventTabs(event, translateMenu, { return buildEventTabs(event, translateMenu, {
photos: event.photo_count ?? 0, photos: event.photo_count ?? undefined,
tasks: assignedTasks.length, tasks: taskBadge,
invites: event.active_invites_count ?? event.total_invites_count ?? 0,
}); });
}, [event, assignedTasks.length, t]); }, [event, assignedTasks.length, t, loading]);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;

View File

@@ -182,6 +182,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::delete('{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy'); 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}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature');
Route::post('{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature'); 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-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve');
Route::post('bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject'); 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'); Route::get('moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation');