-
-
-
-
-
-
{t('galleryPage.title')}
-
- {newPhotosBadgeText}
+
+
+
+
+
+
+
+
+
+ {t('galleryPage.hero.label')}
+
+
+
+ {event?.name ?? t('galleryPage.hero.eventFallback')}
+
+
+ {t('galleryPage.subtitle')}
+
+
+
+
+ {heroStatsLine}
+
+ {newCount > 0 && (
+
+ {newPhotosBadgeText}
+
+ )}
+
+
+
+ {newCount > 0 && (
+
+ )}
-
{t('galleryPage.subtitle')}
- {newCount > 0 && (
-
-
+
+
+
{loading && (
@@ -363,7 +467,7 @@ export default function GalleryPage() {
)}
- {list.map((p: GalleryPhoto) => {
+ {list.map((p: GalleryPhoto, idx: number) => {
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
const createdLabel = p.created_at
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -399,11 +503,13 @@ export default function GalleryPage() {
{
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}}
- loading="lazy"
/>
diff --git a/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx b/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx
new file mode 100644
index 00000000..0af3957d
--- /dev/null
+++ b/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import GalleryPage from '../GalleryPage';
+import { LocaleProvider } from '../../i18n/LocaleContext';
+
+vi.mock('../../polling/usePollGalleryDelta', () => ({
+ usePollGalleryDelta: () => ({
+ photos: [],
+ loading: false,
+ newCount: 0,
+ acknowledgeNew: vi.fn(),
+ refreshNow: vi.fn(),
+ }),
+}));
+
+vi.mock('../../context/EventBrandingContext', () => ({
+ useEventBranding: () => ({
+ branding: {
+ primaryColor: '#FF5A5F',
+ secondaryColor: '#FFF8F5',
+ fontFamily: 'Space Grotesk, sans-serif',
+ buttons: { radius: 12, style: 'filled', linkColor: '#FF5A5F' },
+ typography: { heading: 'Space Grotesk, sans-serif', body: 'Space Grotesk, sans-serif' },
+ },
+ }),
+}));
+
+vi.mock('../../context/EventStatsContext', () => ({
+ useEventStats: () => ({
+ likesCount: 12,
+ guestCount: 5,
+ onlineGuests: 2,
+ tasksSolved: 0,
+ latestPhotoAt: null,
+ loading: false,
+ eventKey: 'demo',
+ slug: 'demo',
+ }),
+}));
+
+vi.mock('../../services/eventApi', () => ({
+ fetchEvent: vi.fn().mockResolvedValue({ name: 'Demo Event' }),
+}));
+
+vi.mock('../../components/ToastHost', () => ({
+ useToast: () => ({ push: vi.fn() }),
+}));
+
+vi.mock('../../components/ShareSheet', () => ({
+ default: () => null,
+}));
+
+vi.mock('../PhotoLightbox', () => ({
+ default: () => null,
+}));
+
+vi.mock('../../components/PullToRefresh', () => ({
+ default: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}));
+
+vi.mock('../../components/FiltersBar', () => ({
+ default: () =>
,
+}));
+
+describe('GalleryPage hero CTA', () => {
+ it('links to the upload page', async () => {
+ render(
+
+
+
+ } />
+
+
+
+ );
+
+ const link = await screen.findByRole('link', { name: /neues foto hochladen/i });
+ expect(link).toHaveAttribute('href', '/e/demo/upload');
+ });
+});
diff --git a/resources/js/guest/polling/usePollGalleryDelta.ts b/resources/js/guest/polling/usePollGalleryDelta.ts
index 3d407832..13c615e1 100644
--- a/resources/js/guest/polling/usePollGalleryDelta.ts
+++ b/resources/js/guest/polling/usePollGalleryDelta.ts
@@ -139,7 +139,6 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
setLoading(true);
latestAt.current = null;
etagRef.current = null;
- setPhotos([]);
void fetchDelta();
if (timer.current) window.clearInterval(timer.current);
// Poll less aggressively when hidden
@@ -158,7 +157,6 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
latestAt.current = null;
etagRef.current = null;
setNewCount(0);
- setPhotos([]);
await fetchDelta();
}, [fetchDelta, token]);
diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx
index 75f53474..378e311d 100644
--- a/resources/js/guest/router.tsx
+++ b/resources/js/guest/router.tsx
@@ -26,7 +26,6 @@ const TaskPickerPage = React.lazy(() => import('./pages/TaskPickerPage'));
const TaskDetailPage = React.lazy(() => import('./pages/TaskDetailPage'));
const UploadPage = React.lazy(() => import('./pages/UploadPage'));
const UploadQueuePage = React.lazy(() => import('./pages/UploadQueuePage'));
-const GalleryPage = React.lazy(() => import('./pages/GalleryPage'));
const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox'));
const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
@@ -88,7 +87,6 @@ export const router = createBrowserRouter([
{ path: 'tasks/:taskId', element:
},
{ path: 'upload', element:
},
{ path: 'queue', element:
},
- { path: 'gallery', element:
},
{ path: 'photo/:photoId', element:
},
{ path: 'achievements', element:
},
{ path: 'slideshow', element:
},
diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts
index d4520da8..c3b0904f 100644
--- a/resources/js/guest/services/photosApi.ts
+++ b/resources/js/guest/services/photosApi.ts
@@ -52,6 +52,50 @@ export async function likePhoto(id: number): Promise
{
return json.likes_count ?? json.data?.likes_count ?? 0;
}
+export async function unlikePhoto(id: number): Promise {
+ const headers = buildCsrfHeaders();
+
+ const res = await fetch(`/api/v1/photos/${id}/like`, {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ ...headers,
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!res.ok) {
+ let payload: unknown = null;
+ try {
+ payload = await res.clone().json();
+ } catch (error) {
+ console.warn('Unlike photo: failed to parse error payload', error);
+ }
+
+ if (res.status === 419) {
+ const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
+ error.code = 'csrf_mismatch';
+ error.status = res.status;
+ throw error;
+ }
+
+ const error: UploadError = new Error(
+ (payload as { error?: { message?: string } } | null)?.error?.message ?? `Unlike failed: ${res.status}`
+ );
+ error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'unlike_failed';
+ error.status = res.status;
+ const meta = (payload as { error?: { meta?: Record } } | null)?.error?.meta;
+ if (meta) {
+ error.meta = meta;
+ }
+
+ throw error;
+ }
+
+ const json = await res.json();
+ return json.likes_count ?? json.data?.likes_count ?? 0;
+}
+
type UploadOptions = {
guestName?: string;
onProgress?: (percent: number) => void;
diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php
index 62bac7f9..fc6d8411 100644
--- a/resources/lang/de/admin.php
+++ b/resources/lang/de/admin.php
@@ -417,6 +417,14 @@ return [
'extend_expiry_missing' => 'Einladung nicht gefunden.',
'extend_expiry_missing_date' => 'Bitte ein neues Ablaufdatum wählen.',
'extend_expiry_success' => 'Ablauf der Einladung aktualisiert.',
+ 'demo_read_only_action' => 'Demo-Modus',
+ 'demo_read_only_label' => 'Nur-Lesen-Demo',
+ 'demo_read_only_help' => 'Uploads und Schreibaktionen für diesen Link deaktivieren.',
+ 'demo_read_only_heading' => 'Demo-Modus für :label',
+ 'demo_read_only_heading_fallback' => 'Demo-Modus für Einladung',
+ 'demo_read_only_missing' => 'Einladung nicht gefunden.',
+ 'demo_read_only_success' => 'Demo-Modus aktualisiert.',
+ 'demo_read_only_badge' => 'Demo (nur lesen)',
],
'analytics' => [
'success_total' => 'Erfolgreiche Zugriffe',
diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php
index 817c5a6b..1b43bbe0 100644
--- a/resources/lang/en/admin.php
+++ b/resources/lang/en/admin.php
@@ -413,6 +413,14 @@ return [
'extend_expiry_missing' => 'Invitation not found.',
'extend_expiry_missing_date' => 'Please select a new expiry.',
'extend_expiry_success' => 'Invitation expiry updated.',
+ 'demo_read_only_action' => 'Demo mode',
+ 'demo_read_only_label' => 'Read-only demo',
+ 'demo_read_only_help' => 'Disable uploads and write actions for this invitation link.',
+ 'demo_read_only_heading' => 'Demo mode for :label',
+ 'demo_read_only_heading_fallback' => 'Demo mode for invitation',
+ 'demo_read_only_missing' => 'Invitation not found.',
+ 'demo_read_only_success' => 'Demo mode updated.',
+ 'demo_read_only_badge' => 'Demo (read-only)',
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the invitations below or manage QR layouts in the admin app.',
'open_admin' => 'Open admin app',
],
diff --git a/resources/views/filament/events/join-link.blade.php b/resources/views/filament/events/join-link.blade.php
index 256839f8..ba7313db 100644
--- a/resources/views/filament/events/join-link.blade.php
+++ b/resources/views/filament/events/join-link.blade.php
@@ -60,6 +60,11 @@
{{ __('admin.events.join_link.token_inactive') }}
@endif
+ @if (!empty($token['demo_read_only']))
+
+ {{ __('admin.events.join_link.demo_read_only_badge') }}
+
+ @endif
@@ -78,6 +83,7 @@
@if (isset($action))
{{ $action->getModalAction('extend_join_token_expiry')(['token_id' => $token['id']]) }}
+ {{ $action->getModalAction('set_demo_read_only')(['token_id' => $token['id']]) }}
@endif
diff --git a/routes/api.php b/routes/api.php
index d28f5af4..701d19b8 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -193,6 +193,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('/events/{token}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
+ Route::delete('/photos/{id}/like', [EventPublicController::class, 'unlike'])->name('photos.unlike');
Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink'])
->whereNumber('photo')
->name('photos.share');
diff --git a/tests/Feature/ContentSecurityPolicyTest.php b/tests/Feature/ContentSecurityPolicyTest.php
new file mode 100644
index 00000000..933f8753
--- /dev/null
+++ b/tests/Feature/ContentSecurityPolicyTest.php
@@ -0,0 +1,20 @@
+ false]);
+
+ $response = $this->get('/e/test/upload');
+
+ $csp = $response->headers->get('Content-Security-Policy');
+
+ $this->assertNotNull($csp);
+ $this->assertStringContainsString("worker-src 'self' blob:", $csp);
+ }
+}
diff --git a/tests/Feature/EventJoinTokenExpiryActionTest.php b/tests/Feature/EventJoinTokenExpiryActionTest.php
index 18314ff9..e2d547dc 100644
--- a/tests/Feature/EventJoinTokenExpiryActionTest.php
+++ b/tests/Feature/EventJoinTokenExpiryActionTest.php
@@ -51,6 +51,52 @@ class EventJoinTokenExpiryActionTest extends TestCase
);
}
+ public function test_superadmin_can_toggle_demo_read_only_on_join_token(): void
+ {
+ $user = User::factory()->create(['role' => 'super_admin']);
+ $event = Event::factory()->create([
+ 'date' => now()->addDays(10),
+ ]);
+
+ $token = $event->joinTokens()->latest('id')->first();
+
+ $this->bootSuperAdminPanel($user);
+
+ Livewire::test(ListEvents::class)
+ ->callAction(
+ [
+ TestAction::make('join_tokens')->table($event),
+ TestAction::make('set_demo_read_only')
+ ->arguments(['token_id' => $token->id]),
+ ],
+ [
+ 'demo_read_only' => true,
+ ]
+ )
+ ->assertHasNoErrors();
+
+ $token->refresh();
+
+ $this->assertTrue((bool) data_get($token->metadata, 'demo_read_only', false));
+
+ Livewire::test(ListEvents::class)
+ ->callAction(
+ [
+ TestAction::make('join_tokens')->table($event),
+ TestAction::make('set_demo_read_only')
+ ->arguments(['token_id' => $token->id]),
+ ],
+ [
+ 'demo_read_only' => false,
+ ]
+ )
+ ->assertHasNoErrors();
+
+ $token->refresh();
+
+ $this->assertFalse((bool) data_get($token->metadata, 'demo_read_only', false));
+ }
+
private function bootSuperAdminPanel(User $user): void
{
$panel = Filament::getPanel('superadmin');
diff --git a/tests/Feature/GuestJoinTokenFlowTest.php b/tests/Feature/GuestJoinTokenFlowTest.php
index e7acd99b..ca38b472 100644
--- a/tests/Feature/GuestJoinTokenFlowTest.php
+++ b/tests/Feature/GuestJoinTokenFlowTest.php
@@ -368,6 +368,38 @@ class GuestJoinTokenFlowTest extends TestCase
$this->assertEquals(1, $photo->fresh()->likes_count);
}
+ public function test_guest_can_unlike_photo_after_liking(): void
+ {
+ $event = $this->createPublishedEvent();
+ $token = $this->tokenService->createToken($event);
+
+ $photo = Photo::factory()->create([
+ 'event_id' => $event->id,
+ 'likes_count' => 0,
+ ]);
+
+ $this->getJson("/api/v1/events/{$token->token}");
+
+ $this->withHeader('X-Device-Id', 'device-like')
+ ->postJson("/api/v1/photos/{$photo->id}/like")
+ ->assertOk();
+
+ $response = $this->withHeader('X-Device-Id', 'device-like')
+ ->deleteJson("/api/v1/photos/{$photo->id}/like");
+
+ $response->assertOk()
+ ->assertJson([
+ 'liked' => false,
+ ]);
+
+ $this->assertDatabaseMissing('photo_likes', [
+ 'photo_id' => $photo->id,
+ 'guest_name' => 'device-like',
+ ]);
+
+ $this->assertEquals(0, $photo->fresh()->likes_count);
+ }
+
public function test_guest_cannot_access_event_with_expired_token(): void
{
$event = $this->createPublishedEvent();