die eventphotospage funktioniert nun zuverlässig
This commit is contained in:
@@ -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.
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function handleToggleVisibility(photo: TenantPhoto, visible: boolean) {
|
||||||
// No dedicated visibility endpoint available; emulate by filtering locally.
|
if (!slug) return;
|
||||||
setPhotos((prev) =>
|
const shouldRemove = (!visible && statusFilter !== 'hidden') || (visible && statusFilter === 'hidden');
|
||||||
prev.map((entry) =>
|
if (shouldRemove) {
|
||||||
entry.id === photo.id ? { ...entry, status: visible ? 'visible' : 'hidden' } : entry,
|
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
|
||||||
),
|
}
|
||||||
);
|
setBusyId(photo.id);
|
||||||
setSelectedIds((prev) => prev.filter((id) => id !== 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(() => {
|
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,35 +269,62 @@ 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');
|
||||||
setBulkBusy(false);
|
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(
|
const handleBulkFeature = React.useCallback(
|
||||||
async (featured: boolean) => {
|
async (featured: boolean) => {
|
||||||
if (!slug || !selectedPhotos.length) return;
|
if (!slug || !selectedPhotos.length) return;
|
||||||
setBulkBusy(true);
|
setBulkBusy(true);
|
||||||
for (const photo of selectedPhotos) {
|
try {
|
||||||
setBusyId(photo.id);
|
for (const photo of selectedPhotos) {
|
||||||
try {
|
setBusyId(photo.id);
|
||||||
const updated = featured
|
try {
|
||||||
? await featurePhoto(slug, photo.id)
|
const updated = featured
|
||||||
: await unfeaturePhoto(slug, photo.id);
|
? await featurePhoto(slug, photo.id)
|
||||||
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
|
: await unfeaturePhoto(slug, photo.id);
|
||||||
} catch (err) {
|
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
|
||||||
if (!isAuthError(err)) {
|
} catch (err) {
|
||||||
setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.'));
|
if (!isAuthError(err)) {
|
||||||
|
setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
}
|
||||||
|
setSelectedIds([]);
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
}
|
}
|
||||||
setSelectedIds([]);
|
|
||||||
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,27 +697,53 @@ 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">
|
||||||
<Search className="h-4 w-4 text-slate-500" />
|
{showSearch ? (
|
||||||
<Input
|
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
|
||||||
value={search}
|
<Search className="h-4 w-4 text-slate-500" />
|
||||||
onChange={(event) => onSearch(event.target.value)}
|
<Input
|
||||||
placeholder={t('photos.filters.search', 'Uploads durchsuchen …')}
|
autoFocus
|
||||||
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
value={search}
|
||||||
/>
|
onChange={(event) => onSearch(event.target.value)}
|
||||||
</div>
|
placeholder={t('photos.filters.search', 'Uploads durchsuchen …')}
|
||||||
<div className="flex flex-wrap gap-2">
|
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
||||||
{filters.map((filter) => (
|
/>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="text-slate-500" onClick={onToggleSearch}>
|
||||||
key={filter.key}
|
<X className="h-4 w-4" />
|
||||||
variant={statusFilter === filter.key ? 'secondary' : 'outline'}
|
</Button>
|
||||||
className="rounded-full"
|
</div>
|
||||||
onClick={() => onStatusFilterChange(filter.key)}
|
) : (
|
||||||
size="sm"
|
<Button variant="outline" size="sm" className="rounded-full" onClick={onToggleSearch}>
|
||||||
>
|
<Search className="mr-2 h-4 w-4" />
|
||||||
{filter.label}
|
{t('photos.filters.searchOpen', 'Suche öffnen')}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<Button
|
||||||
|
key={filter.key}
|
||||||
|
variant={statusFilter === filter.key ? 'secondary' : 'outline'}
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => onStatusFilterChange(filter.key)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<span className="hidden lg:inline">{t('photos.filters.sort', 'Sortierung')}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||||||
|
>
|
||||||
|
{sortOrder === 'desc'
|
||||||
|
? t('photos.filters.sortDesc', 'Neueste zuerst')
|
||||||
|
: t('photos.filters.sortAsc', 'Älteste zuerst')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
||||||
@@ -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.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" /> {t('photos.actions.copy', 'Link kopieren')}
|
{copied ? (
|
||||||
|
<span className="flex items-center gap-1 text-emerald-600">
|
||||||
|
<Check className="h-4 w-4" /> {t('photos.actions.copyDone', 'Kopiert!')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4" /> {t('photos.actions.copy', 'Link kopieren')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user