- 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:
@@ -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.',
|
||||
|
||||
333
resources/js/guest/pages/PublicGalleryPage.tsx
Normal file
333
resources/js/guest/pages/PublicGalleryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />,
|
||||
|
||||
81
resources/js/guest/services/galleryApi.ts
Normal file
81
resources/js/guest/services/galleryApi.ts
Normal 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user