- Added public gallery API with token-expiry enforcement, branding payload, cursor pagination, and per-photo download stream (app/Http/Controllers/Api/EventPublicController.php:1, routes/api.php:16). 410 is returned when the package gallery duration has lapsed.

- Served the guest PWA at /g/{token} and introduced a mobile-friendly gallery page with lazy-loaded thumbnails, themed colors, lightbox, and download links plus new gallery data client (resources/js/guest/pages/PublicGalleryPage.tsx:1, resources/js/guest/services/galleryApi.ts:1, resources/js/guest/router.tsx:1). Added i18n strings for the public gallery experience (resources/js/guest/i18n/messages.ts:1).
- Ensured checkout step changes snap back to the progress bar on mobile via smooth scroll anchoring (resources/ js/pages/marketing/checkout/CheckoutWizard.tsx:1).
- Enabled tenant admins to export all approved event photos through a new download action that streams a ZIP archive, with translations and routing in place (app/Http/Controllers/Tenant/EventPhotoArchiveController.php:1, app/Filament/Resources/EventResource.php:1, routes/web.php:1, resources/lang/de/admin.php:1, resources/lang/en/admin.php:1).
This commit is contained in:
Codex Agent
2025-10-17 23:24:06 +02:00
parent 5817270c35
commit ae9b9160ac
20 changed files with 1410 additions and 3 deletions

View File

@@ -178,6 +178,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
title: 'Nicht gefunden',
description: 'Die Seite konnte nicht gefunden werden.',
},
galleryPublic: {
title: 'Galerie',
loading: 'Galerie wird geladen ...',
loadingMore: 'Weitere Fotos werden geladen',
loadError: 'Die Galerie konnte nicht geladen werden.',
loadMore: 'Mehr anzeigen',
download: 'Herunterladen',
expiredTitle: 'Galerie nicht verfügbar',
expiredDescription: 'Die Galerie für dieses Event ist abgelaufen.',
emptyTitle: 'Noch keine Fotos',
emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.',
lightboxGuestFallback: 'Gast',
},
uploadQueue: {
title: 'Uploads',
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
@@ -510,6 +523,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
title: 'Not found',
description: 'We could not find the page you requested.',
},
galleryPublic: {
title: 'Gallery',
loading: 'Loading gallery ...',
loadingMore: 'Loading more photos',
loadError: 'The gallery could not be loaded.',
loadMore: 'Show more',
download: 'Download',
expiredTitle: 'Gallery unavailable',
expiredDescription: 'The gallery for this event has expired.',
emptyTitle: 'No photos yet',
emptyDescription: 'Once photos are approved they will appear here.',
lightboxGuestFallback: 'Guest',
},
uploadQueue: {
title: 'Uploads',
description: 'Queue with progress/retry and background sync toggle.',

View File

@@ -0,0 +1,333 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog';
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '../services/galleryApi';
import { useTranslation } from '../i18n/useTranslation';
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
import { AlertTriangle, Download, Loader2, X } from 'lucide-react';
interface GalleryState {
meta: GalleryMetaResponse | null;
photos: GalleryPhotoResource[];
cursor: string | null;
loading: boolean;
loadingMore: boolean;
error: string | null;
expired: boolean;
}
const INITIAL_STATE: GalleryState = {
meta: null,
photos: [],
cursor: null,
loading: true,
loadingMore: false,
error: null,
expired: false,
};
const GALLERY_PAGE_SIZE = 30;
export default function PublicGalleryPage(): JSX.Element | null {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const [state, setState] = useState<GalleryState>(INITIAL_STATE);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState<GalleryPhotoResource | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale';
const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null;
const effectiveLocale = storedLocale && isLocaleCode(storedLocale as any) ? (storedLocale as any) : DEFAULT_LOCALE;
const applyMeta = useCallback((meta: GalleryMetaResponse) => {
if (typeof window !== 'undefined' && token) {
localStorage.setItem(localeStorageKey, effectiveLocale);
}
setState((prev) => ({
...prev,
meta,
}));
}, [effectiveLocale, localeStorageKey, token]);
const loadInitial = useCallback(async () => {
if (!token) {
return;
}
setState((prev) => ({ ...prev, loading: true, error: null, expired: false, photos: [], cursor: null }));
try {
const meta = await fetchGalleryMeta(token, effectiveLocale);
applyMeta(meta);
const photoResponse = await fetchGalleryPhotos(token, null, GALLERY_PAGE_SIZE);
setState((prev) => ({
...prev,
loading: false,
photos: photoResponse.data,
cursor: photoResponse.next_cursor,
}));
} catch (error) {
const err = error as Error & { code?: string | number };
if (err.code === 'gallery_expired' || err.code === 410) {
setState((prev) => ({ ...prev, loading: false, expired: true }));
} else {
setState((prev) => ({
...prev,
loading: false,
error: err.message || t('galleryPublic.loadError'),
}));
}
}
}, [token, applyMeta, effectiveLocale, t]);
useEffect(() => {
loadInitial();
}, [loadInitial]);
const loadMore = useCallback(async () => {
if (!token || !state.cursor || state.loadingMore) {
return;
}
setState((prev) => ({ ...prev, loadingMore: true }));
try {
const response = await fetchGalleryPhotos(token, state.cursor, GALLERY_PAGE_SIZE);
setState((prev) => ({
...prev,
photos: [...prev.photos, ...response.data],
cursor: response.next_cursor,
loadingMore: false,
}));
} catch (error) {
const err = error as Error;
setState((prev) => ({
...prev,
loadingMore: false,
error: err.message || t('galleryPublic.loadError'),
}));
}
}, [state.cursor, state.loadingMore, token, t]);
useEffect(() => {
if (!state.cursor || !sentinelRef.current) {
return;
}
const observer = new IntersectionObserver((entries) => {
const firstEntry = entries[0];
if (firstEntry?.isIntersecting) {
loadMore();
}
}, {
rootMargin: '400px',
threshold: 0,
});
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [state.cursor, loadMore]);
const themeStyles = useMemo(() => {
if (!state.meta) {
return {} as React.CSSProperties;
}
return {
'--gallery-primary': state.meta.branding.primary_color,
'--gallery-secondary': state.meta.branding.secondary_color,
'--gallery-background': state.meta.branding.background_color,
} as React.CSSProperties & Record<string, string>;
}, [state.meta]);
const headerStyle = useMemo(() => {
if (!state.meta) {
return {};
}
return {
background: state.meta.branding.primary_color,
color: '#ffffff',
} satisfies React.CSSProperties;
}, [state.meta]);
const accentStyle = useMemo(() => {
if (!state.meta) {
return {};
}
return {
color: state.meta.branding.primary_color,
} satisfies React.CSSProperties;
}, [state.meta]);
const backgroundStyle = useMemo(() => {
if (!state.meta) {
return {};
}
return {
backgroundColor: state.meta.branding.background_color,
} satisfies React.CSSProperties;
}, [state.meta]);
const openLightbox = useCallback((photo: GalleryPhotoResource) => {
setSelectedPhoto(photo);
setLightboxOpen(true);
}, []);
const closeLightbox = useCallback(() => {
setLightboxOpen(false);
setSelectedPhoto(null);
}, []);
if (!token) {
return null;
}
if (state.expired) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center" style={backgroundStyle}>
<AlertTriangle className="h-12 w-12 text-destructive" aria-hidden />
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-foreground">{t('galleryPublic.expiredTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('galleryPublic.expiredDescription')}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen" style={{ ...themeStyles, ...backgroundStyle }}>
<header className="sticky top-0 z-20 shadow-sm" style={headerStyle}>
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-4">
<div className="text-left">
<p className="text-xs uppercase tracking-widest opacity-80">Fotospiel</p>
<h1 className="text-xl font-semibold leading-tight">
{state.meta?.event.name || t('galleryPublic.title')}
</h1>
{state.meta?.event.gallery_expires_at && (
<p className="text-[11px] opacity-80">
{new Date(state.meta.event.gallery_expires_at).toLocaleDateString()}
</p>
)}
</div>
</div>
</header>
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6">
{state.meta?.event.description && (
<div className="rounded-xl bg-white/70 p-4 shadow-sm backdrop-blur">
<p className="text-sm text-muted-foreground">{state.meta.event.description}</p>
</div>
)}
{state.error && (
<Alert variant="destructive">
<AlertTitle>{t('galleryPublic.loadError')}</AlertTitle>
<AlertDescription>
{state.error}
</AlertDescription>
</Alert>
)}
{state.loading && (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<p className="text-sm text-muted-foreground">{t('galleryPublic.loading')}</p>
</div>
)}
{!state.loading && state.photos.length === 0 && !state.error && (
<div className="rounded-xl border border-dashed border-muted/60 p-10 text-center">
<h2 className="text-lg font-semibold text-foreground">{t('galleryPublic.emptyTitle')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('galleryPublic.emptyDescription')}</p>
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{state.photos.map((photo) => (
<button
key={photo.id}
type="button"
className="group relative overflow-hidden rounded-xl bg-white shadow-sm transition-transform hover:-translate-y-1 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={() => openLightbox(photo)}
style={accentStyle}
>
<img
src={photo.thumbnail_url ?? photo.full_url ?? ''}
alt={photo.guest_name ? `${photo.guest_name}s Foto` : `Foto ${photo.id}`}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
))}
</div>
<div ref={sentinelRef} className="h-1 w-full" aria-hidden />
{state.loadingMore && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t('galleryPublic.loadingMore')}
</div>
)}
{!state.loading && state.cursor && (
<div className="flex justify-center">
<Button variant="outline" onClick={loadMore} disabled={state.loadingMore}>
{state.loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{t('galleryPublic.loadMore')}
</Button>
</div>
)}
</main>
<Dialog open={lightboxOpen} onOpenChange={(open) => (open ? setLightboxOpen(true) : closeLightbox())}>
<DialogContent className="max-w-3xl gap-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
{selectedPhoto?.guest_name || t('galleryPublic.lightboxGuestFallback')}
</p>
<p className="text-xs text-muted-foreground">
{selectedPhoto?.created_at ? new Date(selectedPhoto.created_at).toLocaleString() : ''}
</p>
</div>
<Button variant="ghost" size="icon" onClick={closeLightbox}>
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">{t('common.actions.close')}</span>
</Button>
</div>
<div className="relative overflow-hidden rounded-2xl bg-black/5">
{selectedPhoto?.full_url && (
<img
src={selectedPhoto.full_url}
alt={selectedPhoto?.guest_name || `Foto ${selectedPhoto?.id}`}
className="w-full object-contain"
/>
)}
</div>
<DialogFooter className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">
{selectedPhoto?.likes_count ? `${selectedPhoto.likes_count}` : ''}
</div>
{selectedPhoto?.download_url && (
<Button asChild className="gap-2" style={accentStyle}>
<a href={selectedPhoto.download_url} target="_blank" rel="noopener noreferrer">
<Download className="h-4 w-4" aria-hidden />
{t('galleryPublic.download')}
</a>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -21,6 +21,7 @@ import AchievementsPage from './pages/AchievementsPage';
import SlideshowPage from './pages/SlideshowPage';
import SettingsPage from './pages/SettingsPage';
import LegalPage from './pages/LegalPage';
import PublicGalleryPage from './pages/PublicGalleryPage';
import NotFoundPage from './pages/NotFoundPage';
import { LocaleProvider } from './i18n/LocaleContext';
import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
@@ -57,6 +58,7 @@ export const router = createBrowserRouter([
{ index: true, element: <ProfileSetupPage /> },
],
},
{ path: '/g/:token', element: <PublicGalleryPage /> },
{
path: '/e/:token',
element: <HomeLayout />,

View File

@@ -0,0 +1,81 @@
import type { LocaleCode } from '../i18n/messages';
export interface GalleryBranding {
primary_color: string;
secondary_color: string;
background_color: string;
}
export interface GalleryMetaResponse {
event: {
id: number;
name: string;
slug?: string | null;
description?: string | null;
gallery_expires_at?: string | null;
};
branding: GalleryBranding;
}
export interface GalleryPhotoResource {
id: number;
thumbnail_url: string | null;
full_url: string | null;
download_url: string;
likes_count: number;
guest_name?: string | null;
created_at?: string | null;
}
export interface GalleryPhotosResponse {
data: GalleryPhotoResource[];
next_cursor: string | null;
}
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return {} as T;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error((data && data.error && data.error.message) || 'Request failed');
(error as any).code = data?.error?.code ?? response.status;
throw error;
}
return data as T;
}
export async function fetchGalleryMeta(token: string, locale?: LocaleCode): Promise<GalleryMetaResponse> {
const params = new URLSearchParams();
if (locale) params.set('locale', locale);
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`, {
headers: {
'Accept': 'application/json',
},
credentials: 'omit',
});
return handleResponse<GalleryMetaResponse>(response);
}
export async function fetchGalleryPhotos(token: string, cursor?: string | null, limit = 30): Promise<GalleryPhotosResponse> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (cursor) {
params.set('cursor', cursor);
}
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}/photos?${params.toString()}`, {
headers: {
'Accept': 'application/json',
},
credentials: 'omit',
});
return handleResponse<GalleryPhotosResponse>(response);
}