Files
fotospiel-app/resources/js/guest/pages/GalleryPage.tsx

465 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-nocheck
import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Heart, Image as ImageIcon, Share2 } from 'lucide-react';
import { likePhoto } from '../services/photosApi';
import PhotoLightbox from './PhotoLightbox';
import { fetchEvent, type EventData } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
import { useToast } from '../components/ToastHost';
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { createPhotoShareLink } from '../services/photosApi';
import { cn } from '@/lib/utils';
import { useEventBranding } from '../context/EventBrandingContext';
import ShareSheet from '../components/ShareSheet';
const allGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
type GalleryPhoto = {
id: number;
likes_count?: number | null;
created_at?: string | null;
ingest_source?: string | null;
session_id?: string | null;
task_id?: number | null;
task_title?: string | null;
emotion_id?: number | null;
emotion_name?: string | null;
thumbnail_path?: string | null;
file_path?: string | null;
title?: string | null;
uploader_name?: string | null;
};
const normalizeImageUrl = (src?: string | null) => {
if (!src) {
return '';
}
if (/^https?:/i.test(src)) {
return src;
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
return `/${cleanPath}`.replace(/\/+/g, '/');
};
export default function GalleryPage() {
const { token } = useParams<{ token?: string }>();
const { t, locale } = useTranslation();
const { branding } = useEventBranding();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '', locale);
const [searchParams, setSearchParams] = useSearchParams();
const photoIdParam = searchParams.get('photoId');
const modeParam = searchParams.get('mode');
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
const [event, setEvent] = useState<EventData | null>(null);
const [eventLoading, setEventLoading] = useState(true);
const toast = useToast();
const [shareTargetId, setShareTargetId] = React.useState<number | null>(null);
const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
const [shareSheet, setShareSheet] = React.useState<{ photo: GalleryPhoto | null; url: string | null; loading: boolean }>({
photo: null,
url: null,
loading: false,
});
const typedPhotos = photos as GalleryPhoto[];
const showPhotoboothFilter = React.useMemo(
() => Boolean(event?.photobooth_enabled) || typedPhotos.some((p) => p.ingest_source === 'photobooth'),
[event?.photobooth_enabled, typedPhotos],
);
const allowedGalleryFilters = React.useMemo<GalleryFilter[]>(
() => (showPhotoboothFilter ? allGalleryFilters : ['latest', 'popular', 'mine']),
[showPhotoboothFilter],
);
const parseGalleryFilter = React.useCallback(
(value: string | null): GalleryFilter =>
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest',
[allowedGalleryFilters],
);
useEffect(() => {
setFilterState(parseGalleryFilter(modeParam));
}, [modeParam, parseGalleryFilter]);
const setFilter = React.useCallback((next: GalleryFilter) => {
setFilterState(next);
const params = new URLSearchParams(searchParams);
params.set('mode', next);
setSearchParams(params, { replace: true });
}, [searchParams, setSearchParams]);
useEffect(() => {
if (filter === 'photobooth' && !showPhotoboothFilter) {
setFilter('latest');
}
}, [filter, showPhotoboothFilter, setFilter]);
// Auto-open lightbox if photoId in query params
useEffect(() => {
if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) {
const index = typedPhotos.findIndex((photo) => photo.id === parseInt(photoIdParam, 10));
if (index !== -1) {
setCurrentPhotoIndex(index);
setHasOpenedPhoto(true);
}
}
}, [typedPhotos, photos.length, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
// Load event and package info
useEffect(() => {
if (!token) return;
const loadEventData = async () => {
try {
setEventLoading(true);
const eventData = await fetchEvent(token);
setEvent(eventData);
} catch (err) {
console.error('Failed to load event data', err);
} finally {
setEventLoading(false);
}
};
loadEventData();
}, [token]);
const myPhotoIds = React.useMemo(() => {
try {
const raw = localStorage.getItem('my-photo-ids');
return new Set<number>(raw ? JSON.parse(raw) : []);
} catch { return new Set<number>(); }
}, []);
const list = React.useMemo(() => {
let arr = typedPhotos.slice();
if (filter === 'popular') {
arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
} else if (filter === 'mine') {
arr = arr.filter((p) => myPhotoIds.has(p.id));
} else if (filter === 'photobooth') {
arr = arr.filter((p) => p.ingest_source === 'photobooth');
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
} else {
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
}
return arr;
}, [typedPhotos, filter, myPhotoIds]);
const [liked, setLiked] = React.useState<Set<number>>(new Set());
const [counts, setCounts] = React.useState<Record<number, number>>({});
async function onLike(id: number) {
if (liked.has(id)) return;
setLiked(new Set(liked).add(id));
try {
const c = await likePhoto(id);
setCounts((m) => ({ ...m, [id]: c }));
// keep a simple record of liked items
try {
const raw = localStorage.getItem('liked-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (!arr.includes(id)) localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, id]));
} catch (error) {
console.warn('Failed to persist liked-photo-ids', error);
}
} catch (error) {
console.warn('Like failed', error);
const s = new Set(liked); s.delete(id); setLiked(s);
}
}
const buildShareText = (fallback?: string) => {
const eventName = event?.name ?? fallback ?? 'Fotospiel';
const base = t('share.shareText', 'Schau dir diesen Moment bei Fotospiel an.');
return `${eventName} ${base}`;
};
async function onShare(photo: GalleryPhoto) {
if (!token) return;
setShareSheet({ photo, url: null, loading: true });
setShareTargetId(photo.id);
try {
const url = await ensureShareUrl(photo);
setShareSheet({ photo, url, loading: false });
} catch (error) {
console.error('share failed', error);
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
setShareSheet({ photo: null, url: null, loading: false });
} finally {
setShareTargetId(null);
}
}
async function ensureShareUrl(photo: GalleryPhoto): Promise<string> {
if (!token) throw new Error('missing token');
const payload = await createPhotoShareLink(token, photo.id);
return payload.url;
}
function shareNative(url?: string | null) {
if (!shareSheet.photo || !url) return;
const localizedTask = localizeTaskLabel(shareSheet.photo.task_title ?? null, locale);
const data: ShareData = {
title: localizedTask ?? event?.name ?? t('share.title', 'Geteiltes Foto'),
text: buildShareText(),
url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
navigator.share(data).catch(() => {
// user cancelled; no toast
});
setShareSheet({ photo: null, url: null, loading: false });
return;
}
void copyLink(url);
}
function shareWhatsApp(url?: string) {
if (!url) return;
const text = `${buildShareText()} ${url}`;
const waUrl = `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(waUrl, '_blank', 'noopener');
setShareSheet({ photo: null, url: null, loading: false });
}
function shareMessages(url?: string) {
if (!url) return;
const text = `${buildShareText()} ${url}`;
const smsUrl = `sms:?&body=${encodeURIComponent(text)}`;
window.open(smsUrl, '_blank', 'noopener');
setShareSheet({ photo: null, url: null, loading: false });
}
async function copyLink(url?: string | null) {
if (!url) return;
try {
await navigator.clipboard?.writeText(url);
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
} catch {
toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' });
} finally {
setShareSheet({ photo: null, url: null, loading: false });
}
}
function closeShareSheet() {
setShareSheet({ photo: null, url: null, loading: false });
}
if (!token) {
return (
<Page title={t('galleryPage.title', 'Galerie')}>
<p>{t('galleryPage.eventNotFound', 'Event nicht gefunden.')}</p>
</Page>
);
}
if (eventLoading) {
return (
<Page title={t('galleryPage.title', 'Galerie')}>
<p>{t('galleryPage.loadingEvent', 'Lade Event-Info...')}</p>
</Page>
);
}
const newPhotosBadgeText = t('galleryPage.badge.newPhotos', {
count: numberFormatter.format(newCount),
}, `${newCount} neue Fotos`);
const badgeEmphasisClass = newCount > 0
? 'border border-pink-200 bg-pink-500/15 text-pink-600'
: 'border border-transparent bg-muted text-muted-foreground';
return (
<Page title="">
<div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
<ImageIcon className="h-5 w-5" aria-hidden />
</div>
<div>
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
</div>
{newCount > 0 ? (
<button
type="button"
onClick={acknowledgeNew}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
style={{ borderRadius: radius }}
>
{newPhotosBadgeText}
</button>
) : (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`} style={{ borderRadius: radius }}>
{newPhotosBadgeText}
</span>
)}
</div>
</div>
<FiltersBar
value={filter}
onChange={setFilter}
className="mt-2"
showPhotobooth={showPhotoboothFilter}
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
/>
{loading && <p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>{t('galleryPage.loading', 'Lade…')}</p>}
<div className="grid grid-cols-2 gap-2 px-2 pb-16 sm:grid-cols-3 lg:grid-cols-4">
{list.map((p: GalleryPhoto) => {
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' })
: t('galleryPage.photo.justNow', 'Gerade eben');
const likeCount = counts[p.id] ?? (p.likes_count || 0);
const localizedTaskTitle = localizeTaskLabel(p.task_title ?? null, locale);
const altSuffix = localizedTaskTitle
? t('galleryPage.photo.altTaskSuffix', { task: localizedTaskTitle })
: '';
const altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`);
const openPhoto = () => {
const index = list.findIndex((photo) => photo.id === p.id);
setCurrentPhotoIndex(index >= 0 ? index : null);
};
return (
<div
key={p.id}
role="button"
tabIndex={0}
onClick={openPhoto}
onKeyDown={(e) => {
if (e.key === 'Enter') {
openPhoto();
}
}}
className="group relative overflow-hidden border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
style={{ borderRadius: radius }}
>
<img
src={imageUrl}
alt={altText}
className="aspect-[3/4] w-full object-cover transition duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}}
loading="lazy"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" aria-hidden />
<div className="absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{localizedTaskTitle && <p className="text-sm font-medium leading-tight line-clamp-2 text-white" style={headingFont ? { fontFamily: headingFont } : undefined}>{localizedTaskTitle}</p>}
<div className="flex items-center justify-between text-xs text-white/90" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<span className="truncate">{createdLabel}</span>
<span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
</div>
</div>
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShare(p);
}}
className={cn(
'flex h-9 w-9 items-center justify-center border text-white transition backdrop-blur',
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/10'
)}
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
disabled={shareTargetId === p.id}
style={{
borderRadius: radius,
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
color: buttonStyle === 'outline' ? linkColor : undefined,
}}
>
<Share2 className="h-4 w-4" aria-hidden />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onLike(p.id);
}}
className={cn(
'flex items-center gap-1 px-3 py-1 text-sm font-medium transition backdrop-blur',
liked.has(p.id) ? 'text-pink-300' : 'text-white'
)}
aria-label={t('galleryPage.photo.likeAria', 'Foto liken')}
style={{
borderRadius: radius,
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
color: buttonStyle === 'outline' ? linkColor : undefined,
}}
>
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button>
</div>
</div>
);
})}
{list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
<div
key={`placeholder-${idx}`}
className="relative overflow-hidden border border-muted/40 bg-[var(--guest-surface,#f7f7f7)] shadow-sm"
style={{ borderRadius: radius }}
>
<div className="absolute inset-0 bg-gradient-to-br from-white/60 via-white/30 to-transparent dark:from-white/5 dark:via-white/0" aria-hidden />
<div className="flex aspect-[3/4] items-center justify-center gap-2 p-4 text-muted-foreground/70">
<ImageIcon className="h-6 w-6" aria-hidden />
<div className="h-2 w-10 rounded-full bg-muted/40" />
</div>
<div className="absolute inset-0 animate-pulse bg-white/30 dark:bg-white/5" aria-hidden />
</div>
))}
</div>
{currentPhotoIndex !== null && list.length > 0 && (
<PhotoLightbox
photos={list}
currentIndex={currentPhotoIndex}
onClose={() => setCurrentPhotoIndex(null)}
onIndexChange={(index: number) => setCurrentPhotoIndex(index)}
token={token}
eventName={event?.name ?? null}
/>
)}
<ShareSheet
open={Boolean(shareSheet.photo)}
photoId={shareSheet.photo?.id ?? null}
eventName={event?.name ?? null}
url={shareSheet.url}
loading={shareSheet.loading}
onClose={closeShareSheet}
onShareNative={() => shareNative(shareSheet.url)}
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
onShareMessages={() => shareMessages(shareSheet.url)}
onCopyLink={() => copyLink(shareSheet.url)}
radius={radius}
bodyFont={bodyFont}
headingFont={headingFont}
/>
</Page>
);
}