Files
fotospiel-app/resources/js/guest/pages/GalleryPage.tsx
Codex Agent d0559bf8c9
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Align gallery layout with achievements structure
2026-01-14 15:50:48 +01:00

510 lines
21 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 { useNavigationType, 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 { motion } from 'framer-motion';
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 { shouldShowPhotoboothFilter } from '../lib/galleryFilters';
import { createPhotoShareLink } from '../services/photosApi';
import { cn } from '@/lib/utils';
import { useEventBranding } from '../context/EventBrandingContext';
import ShareSheet from '../components/ShareSheet';
import {
FADE_SCALE,
FADE_UP,
STAGGER_FAST,
getMotionContainerPropsForNavigation,
getMotionItemPropsForNavigation,
prefersReducedMotion,
} from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
import { triggerHaptic } from '../lib/haptics';
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 navigationType = useNavigationType();
const { t, locale } = useTranslation();
const { branding } = useEventBranding();
const { photos, loading, newCount, acknowledgeNew, refreshNow } = 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 motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const fadeUpMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_UP, navigationType);
const fadeScaleMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_SCALE, navigationType);
const gridMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
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(() => shouldShowPhotoboothFilter(event), [event]);
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
const loadEventData = React.useCallback(async () => {
if (!token) return;
try {
setEventLoading(true);
const eventData = await fetchEvent(token);
setEvent(eventData);
} catch (err) {
console.error('Failed to load event data', err);
} finally {
setEventLoading(false);
}
}, [token]);
useEffect(() => {
void loadEventData();
}, [loadEventData]);
const handleRefresh = React.useCallback(async () => {
await Promise.all([refreshNow(), loadEventData()]);
acknowledgeNew();
}, [acknowledgeNew, loadEventData, refreshNow]);
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 }));
triggerHaptic('selection');
// 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="relative">
<PullToRefresh
onRefresh={handleRefresh}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<motion.div className="space-y-6 pb-24" {...containerMotion}>
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
<div className="flex flex-wrap items-center justify-between gap-2">
<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>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${badgeEmphasisClass}`}
style={{ borderRadius: radius }}
>
{newPhotosBadgeText}
</span>
</div>
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
</div>
</div>
{newCount > 0 && (
<button
type="button"
onClick={acknowledgeNew}
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold text-pink-600 transition hover:bg-pink-50"
style={{ borderRadius: radius }}
>
{t('galleryPage.badge.markSeen', 'Gesehen')}
</button>
)}
</div>
<FiltersBar
value={filter}
onChange={setFilter}
className="mt-0"
showPhotobooth={showPhotoboothFilter}
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
/>
</motion.div>
{loading && (
<motion.p className="px-1" {...fadeUpMotion}>
{t('galleryPage.loading', 'Lade…')}
</motion.p>
)}
<motion.div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
{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 (
<motion.div
key={p.id}
role="button"
tabIndex={0}
onClick={openPhoto}
onKeyDown={(e) => {
if (e.key === 'Enter') {
openPhoto();
}
}}
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400 dark:border-white/10 dark:bg-slate-950 dark:ring-white/10"
style={{ borderRadius: radius }}
{...fadeScaleMotion}
>
<div className="relative">
<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-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 via-black/0 to-transparent" aria-hidden />
</div>
<div className="space-y-2 px-3 pb-3 pt-3" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{localizedTaskTitle && (
<p
className="text-sm font-semibold leading-tight line-clamp-2 text-foreground"
style={headingFont ? { fontFamily: headingFont } : undefined}
>
{localizedTaskTitle}
</p>
)}
<div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{createdLabel}</span>
<span className="truncate">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
</div>
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onLike(p.id);
}}
className={cn(
'inline-flex items-center gap-1 rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-foreground transition',
liked.has(p.id) ? 'border-pink-200 bg-pink-50 text-pink-600' : 'hover:bg-muted/40'
)}
aria-label={t('galleryPage.photo.likeAria', 'Foto liken')}
style={{ borderRadius: radius }}
>
<Heart className={`h-3.5 w-3.5 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShare(p);
}}
className={cn(
'inline-flex items-center gap-1 rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-foreground transition',
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-muted/40'
)}
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
disabled={shareTargetId === p.id}
style={{ borderRadius: radius }}
>
<Share2 className="h-3.5 w-3.5" aria-hidden />
{t('galleryPage.photo.shareLabel', 'Teilen')}
</button>
</div>
</div>
</motion.div>
);
})}
{list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
<motion.div
key={`placeholder-${idx}`}
className="relative overflow-hidden border border-muted/40 bg-white shadow-sm ring-1 ring-black/5 dark:bg-slate-950 dark:ring-white/10"
style={{ borderRadius: radius }}
{...fadeScaleMotion}
>
<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 />
</motion.div>
))}
</motion.div>
</motion.div>
</PullToRefresh>
</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>
);
}