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

297 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Button } from '@/components/ui/button';
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, fetchStats, type EventData, type EventStats } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
import { sharePhotoLink } from '../lib/sharePhoto';
import { useToast } from '../components/ToastHost';
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
const parseGalleryFilter = (value: string | null): GalleryFilter =>
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
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 navigate = useNavigate();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
const [searchParams, setSearchParams] = useSearchParams();
const photoIdParam = searchParams.get('photoId');
const modeParam = searchParams.get('mode');
const [filter, setFilterState] = React.useState<GalleryFilter>(() => parseGalleryFilter(modeParam));
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
const [event, setEvent] = useState<EventData | null>(null);
const [stats, setStats] = useState<EventStats | null>(null);
const [eventLoading, setEventLoading] = useState(true);
const { t } = useTranslation();
const toast = useToast();
const [shareTargetId, setShareTargetId] = React.useState<number | null>(null);
useEffect(() => {
setFilterState(parseGalleryFilter(modeParam));
}, [modeParam]);
const setFilter = React.useCallback((next: GalleryFilter) => {
setFilterState(next);
const params = new URLSearchParams(searchParams);
params.set('mode', next);
setSearchParams(params, { replace: true });
}, [searchParams, setSearchParams]);
// Auto-open lightbox if photoId in query params
useEffect(() => {
if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) {
const index = photos.findIndex((photo: any) => photo.id === parseInt(photoIdParam, 10));
if (index !== -1) {
setCurrentPhotoIndex(index);
setHasOpenedPhoto(true);
}
}
}, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
// Load event and package info
useEffect(() => {
if (!token) return;
const loadEventData = async () => {
try {
setEventLoading(true);
const [eventData, statsData] = await Promise.all([
fetchEvent(token),
fetchStats(token),
]);
setEvent(eventData);
setStats(statsData);
} 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 = photos.slice();
if (filter === 'popular') {
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
} else if (filter === 'mine') {
arr = arr.filter((p: any) => myPhotoIds.has(p.id));
} else if (filter === 'photobooth') {
arr = arr.filter((p: any) => p.ingest_source === 'photobooth');
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
} else {
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
}
return arr;
}, [photos, filter, myPhotoIds]);
const [liked, setLiked] = React.useState<Set<number>>(new Set());
const [counts, setCounts] = React.useState<Record<number, number>>({});
const totalLikes = React.useMemo(
() => photos.reduce((sum, photo: any) => sum + (photo.likes_count ?? 0), 0),
[photos],
);
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 {}
} catch {
const s = new Set(liked); s.delete(id); setLiked(s);
}
}
async function onShare(photo: any) {
if (!token) return;
setShareTargetId(photo.id);
try {
const result = await sharePhotoLink({
token,
photoId: photo.id,
title: photo.task_title ?? event?.name ?? t('share.title', 'Geteiltes Foto'),
text: t('share.shareText', { event: event?.name ?? 'Fotospiel' }),
});
if (result.method === 'clipboard') {
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
} else if (result.method === 'manual') {
window.prompt(t('share.manualPrompt', 'Link kopieren'), result.url);
}
} catch (error) {
console.error('share failed', error);
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
} finally {
setShareTargetId(null);
}
}
if (!token) {
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
}
if (eventLoading) {
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
}
return (
<Page title="Galerie">
<section className="space-y-4 px-4">
<div className="rounded-[32px] border border-white/40 bg-gradient-to-br from-pink-50 via-white to-white p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold text-foreground">
<ImageIcon className="h-6 w-6 text-pink-500" aria-hidden />
<span>{event?.name ?? 'Event'}</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{photos.length} Fotos · {totalLikes} · {stats?.onlineGuests ?? 0} Gäste online
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
{newCount > 0 && (
<Button size="sm" className="rounded-full" onClick={acknowledgeNew}>
{newCount} neue Fotos ansehen
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/e/${encodeURIComponent(token)}/upload`)}
>
Neues Foto hochladen
</Button>
</div>
</div>
</div>
</section>
<FiltersBar value={filter} onChange={setFilter} className="mt-2" />
{loading && <p className="px-4">Lade</p>}
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
{list.map((p: any) => {
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('gallery.justNow', 'Gerade eben');
const likeCount = counts[p.id] ?? (p.likes_count || 0);
const openPhoto = () => {
const index = list.findIndex((photo: any) => 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 rounded-[28px] border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
>
<img
src={imageUrl}
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
className="h-64 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/80 via-black/10 to-transparent" aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4">
{p.task_title && <p className="text-sm font-medium leading-tight line-clamp-2">{p.task_title}</p>}
<div className="flex items-center justify-between text-xs text-white/80">
<span>{createdLabel}</span>
<span>{p.uploader_name || 'Gast'}</span>
</div>
</div>
<div className="absolute bottom-3 right-3 flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShare(p);
}}
className={`flex h-9 w-9 items-center justify-center rounded-full border border-white/30 bg-white/10 transition ${shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/20'}`}
aria-label={t('share.button', 'Teilen')}
disabled={shareTargetId === p.id}
>
<Share2 className="h-4 w-4" aria-hidden />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onLike(p.id);
}}
className={`flex items-center gap-1 rounded-full border border-white/30 bg-white/10 px-3 py-1 text-sm font-medium transition ${liked.has(p.id) ? 'text-pink-300' : 'text-white'}`}
aria-label="Foto liken"
>
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button>
</div>
</div>
);
})}
</div>
{currentPhotoIndex !== null && list.length > 0 && (
<PhotoLightbox
photos={list}
currentIndex={currentPhotoIndex}
onClose={() => setCurrentPhotoIndex(null)}
onIndexChange={(index: number) => setCurrentPhotoIndex(index)}
token={token}
/>
)}
</Page>
);
}