Files
fotospiel-app/resources/js/guest/pages/PublicGalleryPage.tsx
2026-01-15 08:06:21 +01:00

403 lines
14 KiB
TypeScript

// @ts-nocheck
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, Share, X } from 'lucide-react';
import { createPhotoShareLink } from '../services/photosApi';
import { getContrastingTextColor } from '../lib/color';
import { applyGuestTheme } from '../lib/guestTheme';
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(): React.ReactElement | 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 [shareLoading, setShareLoading] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale';
const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null;
const effectiveLocale: LocaleCode = storedLocale && isLocaleCode(storedLocale) ? storedLocale : 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 resolvedBranding = useMemo(() => {
if (!state.meta) {
return null;
}
const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color ?? '#f43f5e';
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? '#fb7185';
const background = palette.background ?? state.meta.branding.background_color ?? '#ffffff';
const surface = palette.surface ?? state.meta.branding.surface_color ?? background;
const mode = state.meta.branding.mode ?? 'auto';
return {
primary,
secondary,
background,
surface,
mode,
};
}, [state.meta]);
useEffect(() => {
if (!resolvedBranding) {
return;
}
return applyGuestTheme(resolvedBranding);
}, [resolvedBranding]);
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 (!resolvedBranding) {
return {} as React.CSSProperties;
}
return {
'--gallery-primary': resolvedBranding.primary,
'--gallery-secondary': resolvedBranding.secondary,
'--gallery-background': resolvedBranding.background,
'--gallery-surface': resolvedBranding.surface,
} as React.CSSProperties & Record<string, string>;
}, [resolvedBranding]);
const headerStyle = useMemo(() => {
if (!resolvedBranding) {
return {};
}
const textColor = getContrastingTextColor(resolvedBranding.primary, '#0f172a', '#ffffff');
return {
background: `linear-gradient(135deg, ${resolvedBranding.primary}, ${resolvedBranding.secondary})`,
color: textColor,
} satisfies React.CSSProperties;
}, [resolvedBranding]);
const accentStyle = useMemo(() => {
if (!resolvedBranding) {
return {};
}
return {
color: resolvedBranding.primary,
} satisfies React.CSSProperties;
}, [resolvedBranding]);
const backgroundStyle = useMemo(() => {
if (!resolvedBranding) {
return {};
}
return {
backgroundColor: resolvedBranding.background,
} satisfies React.CSSProperties;
}, [resolvedBranding]);
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>
<div className="flex flex-wrap gap-2">
{(state.meta?.event?.guest_downloads_enabled ?? true) && 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>
) : null}
{(state.meta?.event?.guest_sharing_enabled ?? true) && selectedPhoto ? (
<Button
variant="outline"
className="gap-2"
disabled={shareLoading}
onClick={async () => {
if (!token || !selectedPhoto) return;
setShareLoading(true);
try {
const payload = await createPhotoShareLink(token, selectedPhoto.id);
const shareData: ShareData = {
title: selectedPhoto.guest_name ?? t('share.title', 'Geteiltes Foto'),
text: t('share.shareText', { event: state.meta?.event?.name ?? 'Fotospiel' }),
url: payload.url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
await navigator.share(shareData).catch(() => undefined);
} else if (payload.url) {
await navigator.clipboard.writeText(payload.url);
}
} catch (err) {
console.error('share failed', err);
} finally {
setShareLoading(false);
}
}}
>
{shareLoading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : <Share className="h-4 w-4" aria-hidden />}
{t('share.shareCta', 'Teilen')}
</Button>
) : null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}