updated table structure for photobooth/sparkbooth settings. now there's a separate table for it. update all references and tests. also fixed the notification panel and the lightbox in the guest app.

This commit is contained in:
Codex Agent
2025-12-18 08:49:56 +01:00
parent ece38fc009
commit 1c4acda332
30 changed files with 734 additions and 538 deletions

View File

@@ -56,7 +56,7 @@ export default function GalleryPreview({ token }: Props) {
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
}
return arr.slice(0, 4); // 2x2 = 4 items
return arr.slice(0, 9); // up to 3x3 preview
}, [typedPhotos, mode]);
React.useEffect(() => {
@@ -134,7 +134,7 @@ export default function GalleryPreview({ token }: Props) {
</div>
)}
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2 grid-cols-2 md:grid-cols-3">
{items.map((p: PreviewPhoto) => (
<Link
key={p.id}

View File

@@ -149,7 +149,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const { event, status } = useEventData();
const notificationCenter = useOptionalNotificationCenter();
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new');
const taskProgress = useGuestTaskProgress(eventToken);
const tasksEnabled = isTaskModeEnabled(event);
const panelRef = React.useRef<HTMLDivElement | null>(null);
@@ -275,8 +274,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
: 0;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new');
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'uploads' | 'tips' | 'general'>('all');
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
const pushState = usePushSubscription(eventToken);
React.useEffect(() => {
@@ -306,19 +304,14 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task
default:
base = center.notifications;
}
if (statusFilter === 'all') return base;
return base.filter((item) => item.status === (statusFilter === 'new' ? 'new' : statusFilter));
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications, statusFilter]);
return base;
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
const scopedNotifications = React.useMemo(() => {
if (scopeFilter === 'all') {
return filteredNotifications;
}
return filteredNotifications.filter((item) => {
if (scopeFilter === 'uploads') {
return item.type === 'upload_alert' || item.type === 'photo_activity';
}
if (scopeFilter === 'tips') {
return item.type === 'support_tip' || item.type === 'achievement_major';
}
@@ -332,7 +325,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task
type="button"
onClick={onToggle}
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
aria-label={t('header.notifications.open', 'Benachrichtigungen anzeigen')}
aria-label={open ? t('header.notifications.close', 'Benachrichtigungen schließen') : t('header.notifications.open', 'Benachrichtigungen anzeigen')}
>
<Bell className="h-5 w-5" aria-hidden />
{badgeCount > 0 && (
@@ -374,46 +367,33 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task
activeTab={activeTab}
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
/>
<div className="mt-2">
<label className="sr-only" htmlFor="notification-scope">
{t('header.notifications.scopeLabel', 'Bereich filtern')}
</label>
<select
id="notification-scope"
value={scopeFilter}
onChange={(event) => {
const next = event.target.value as typeof scopeFilter;
setScopeFilter(next);
notificationCenter?.setFilters({ scope: next, status: statusFilter });
}}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm"
>
<option value="all">{t('header.notifications.scope.all', 'Alle')}</option>
<option value="uploads">{t('header.notifications.scope.uploads', 'Uploads/Status')}</option>
<option value="tips">{t('header.notifications.scope.tips', 'Tipps & Achievements')}</option>
<option value="general">{t('header.notifications.scope.general', 'Allgemein')}</option>
</select>
<select
value={statusFilter}
onChange={(event) => {
const value = event.target.value as typeof statusFilter;
setStatusFilter(value);
notificationCenter?.setFilters({ status: value, scope: scopeFilter });
}}
className="mt-2 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm"
>
<option value="new">{t('header.notifications.filter.unread', 'Neu')}</option>
<option value="read">{t('header.notifications.filter.read', 'Gelesen')}</option>
<option value="dismissed">{t('header.notifications.filter.dismissed', 'Ausgeblendet')}</option>
<option value="all">{t('header.notifications.filter.all', 'Alle')}</option>
</select>
<div className="mt-3">
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
{(
[
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
setScopeFilter(option.key);
center.setFilters({ scope: option.key });
}}
className={`rounded-full border px-3 py-1 font-semibold transition ${
scopeFilter === option.key
? 'border-pink-200 bg-pink-50 text-pink-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}
push={pushState}
t={t}
/>
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
{center.loading ? (
<NotificationSkeleton />
@@ -440,26 +420,27 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task
))
)}
</div>
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50/80 p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">
{t('header.notifications.queueLabel', 'Uploads in Warteschlange')}
</span>
<span className="font-semibold text-slate-900">{center.queueCount}</span>
{activeTab === 'status' && (
<div className="mt-3 flex items-center justify-between rounded-xl bg-slate-50/90 px-3 py-2 text-xs text-slate-600">
<div className="flex items-center gap-2">
<UploadCloud className="h-4 w-4 text-slate-400" aria-hidden />
<span>{t('header.notifications.queueLabel', 'Uploads in Warteschlange')}</span>
<span className="font-semibold text-slate-900">{center.queueCount}</span>
</div>
<Link
to={`/e/${encodeURIComponent(eventToken)}/queue`}
className="inline-flex items-center gap-1 font-semibold text-pink-600"
onClick={() => {
if (center.unreadCount > 0) {
void center.refresh();
}
}}
>
{t('header.notifications.queueCta', 'Verlauf')}
<ArrowUpRight className="h-4 w-4" aria-hidden />
</Link>
</div>
<Link
to={`/e/${encodeURIComponent(eventToken)}/queue`}
className="mt-2 inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
onClick={() => {
if (center.unreadCount > 0) {
void center.refresh();
}
}}
>
{t('header.notifications.queueCta', 'Upload-Verlauf öffnen')}
<ArrowUpRight className="h-4 w-4" aria-hidden />
</Link>
</div>
)}
{taskProgress && (
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
<div className="flex items-center justify-between">
@@ -484,6 +465,12 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task
</div>
</div>
)}
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}
push={pushState}
t={t}
/>
</div>,
typeof document !== 'undefined' ? document.body : undefined
)}
@@ -719,7 +706,7 @@ function NotificationStatusBar({
const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied';
return (
<div className="mt-2 space-y-2 text-[11px] text-slate-500">
<div className="mt-4 space-y-2 border-t border-slate-200 pt-3 text-[11px] text-slate-500">
<div className="flex items-center justify-between">
<span>
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label}

View File

@@ -322,7 +322,7 @@ export default function GalleryPage() {
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-3 gap-2 px-4 pb-16 sm:grid-cols-3 md:grid-cols-4">
<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
@@ -371,13 +371,6 @@ export default function GalleryPage() {
<span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
</div>
</div>
<div className="absolute left-3 top-3 z-10 flex flex-col items-start gap-2">
{localizedTaskTitle && (
<span className="rounded-full bg-white/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-white shadow" style={{ borderRadius: radius }}>
{localizedTaskTitle}
</span>
)}
</div>
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<button
type="button"

View File

@@ -279,13 +279,17 @@ export default function HomePage() {
introArray.push(candidate);
}
}
const introMessage =
introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : '';
const introMessageRef = React.useRef<string | null>(null);
if (!introMessageRef.current) {
introMessageRef.current =
introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : '';
}
const introMessage = introMessageRef.current;
if (!tasksEnabled) {
return (
<div className="space-y-3 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<section className="space-y-1 px-4">
<section className="space-y-1 px-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
<p className="text-sm font-semibold text-foreground">
{t('home.welcomeLine').replace('{name}', displayName)}
</p>
@@ -312,7 +316,7 @@ export default function HomePage() {
return (
<div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<section className="space-y-1 px-4">
<section className="space-y-1 px-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
<p className="text-sm font-semibold text-foreground">
{t('home.welcomeLine').replace('{name}', displayName)}
</p>
@@ -476,12 +480,13 @@ function MissionActionCard({
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const cards = mission ? [mission, ...stack] : stack;
const shellRadius = `${radius + 10}px`;
const normalizeText = (value: string | undefined | null) =>
(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
const renderCardContent = (card: MissionPreview | null) => {
const theme = getEmotionTheme(card?.emotion ?? null);
const emotionIcon = getEmotionIcon(card?.emotion ?? null);
const durationMinutes = card?.duration ?? 3;
const progressValue = Math.min(100, Math.max(20, (durationMinutes / 8) * 100));
const titleFont = headingFont ? { fontFamily: headingFont } : undefined;
const gradientBackground = card ? theme.gradientBackground : `linear-gradient(135deg, ${primary}, ${secondary})`;
@@ -524,7 +529,7 @@ function MissionActionCard({
</Badge>
<div className="flex items-center gap-2 text-xs font-medium text-slate-600">
<Sparkles className="h-4 w-4 text-amber-500" aria-hidden />
<span>Deine nächste Foto-Aufgabe wartet</span>
<span>Foto-Challenge</span>
</div>
</div>
</div>
@@ -538,7 +543,7 @@ function MissionActionCard({
<div className="mt-4 text-center">
{card ? (
<p
className="text-2xl font-semibold leading-tight text-slate-900 line-clamp-2"
className="text-2xl font-semibold leading-tight text-slate-900 line-clamp-3 min-h-[3.5rem] py-1"
style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }}
>
{card.title}
@@ -554,16 +559,18 @@ function MissionActionCard({
)}
</div>
{card?.description && normalizeText(card.title) !== normalizeText(card.description) ? (
<div className="mt-4 space-y-2">
<Progress
value={card ? progressValue : 35}
className="h-2 overflow-hidden bg-white/70"
/>
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wide text-slate-500">
<span>Mission bereit</span>
<span>{card ? 'Tippe zum Start' : 'Neue Mission lädt'}</span>
<div className="flex items-center justify-between">
<div className="space-y-1 text-left">
<p className="text-sm font-semibold text-slate-800" style={titleFont}>
{card.title}
</p>
<p className="text-sm leading-relaxed text-slate-600">{card.description}</p>
</div>
</div>
</div>
</div>
) : null}
<div className="mt-4 grid grid-cols-[2fr_1fr] gap-2">
<Button

View File

@@ -2,7 +2,9 @@ import React, { useState, useEffect } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Heart, ChevronLeft, ChevronRight, X, Share2 } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Heart, ChevronLeft, ChevronRight, X, Share2, Download } from 'lucide-react';
import { likePhoto, createPhotoShareLink } from '../services/photosApi';
import { useTranslation } from '../i18n/useTranslation';
import { useToast } from '../components/ToastHost';
@@ -18,6 +20,7 @@ type Photo = {
created_at?: string;
task_id?: number;
task_title?: string;
uploader_name?: string | null;
};
type Task = { id: number; title: string };
@@ -230,6 +233,24 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
const shareTitle = photo?.task_title ?? task?.title ?? t('share.title', 'Geteiltes Foto');
const shareText = t('share.shareText', { event: eventName ?? shareTitle ?? 'Fotospiel' });
const createdLabel = React.useMemo(() => {
if (!photo?.created_at) return null;
try {
const date = new Date(photo.created_at);
return date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return null;
}
}, [photo?.created_at, locale]);
const uploaderInitial = React.useMemo(() => {
const name = photo?.uploader_name;
if (!name) return 'G';
return (name.trim()[0] || 'G').toUpperCase();
}, [photo?.uploader_name]);
const primaryColor = branding.primaryColor || '#0ea5e9';
const secondaryColor = branding.secondaryColor || '#6366f1';
async function openShareSheet() {
if (!photo || !eventToken) return;
@@ -295,109 +316,151 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
return (
<Dialog open={true} onOpenChange={onOpenChange}>
<DialogContent hideClose className="max-w-5xl border-0 bg-black p-0 text-white">
{/* Header with controls */}
<div className="flex items-center justify-between p-2">
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={onLike}
disabled={liked}
>
<Heart className={`mr-1 h-4 w-4 ${liked ? 'fill-red-400 text-red-400' : ''}`} />
{likes}
</Button>
<Button
variant="secondary"
size="sm"
onClick={openShareSheet}
disabled={!eventToken || !photo}
>
<Share2 className="mr-1 h-4 w-4" />
{t('share.button', 'Teilen')}
</Button>
<DialogContent
hideClose
className="max-w-6xl overflow-hidden rounded-3xl border border-white/10 bg-white/5 p-0 text-white shadow-2xl backdrop-blur-3xl"
>
<div className="relative">
<div className="absolute inset-0 opacity-50" style={{ background: 'radial-gradient(circle at 20% 20%, rgba(255,255,255,0.12), transparent 40%), radial-gradient(circle at 80% 10%, rgba(255,255,255,0.1), transparent 35%)' }} />
<div className="absolute top-4 left-0 right-0 z-30 flex items-center justify-between px-5">
<div className="flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-2 py-1 shadow-lg backdrop-blur">
<Button variant="ghost" size="icon" onClick={onClose} className="h-10 w-10 rounded-full text-white hover:bg-white/10">
<X className="h-5 w-5" />
</Button>
<div className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white/90">
{currentIndexVal + 1} / {currentPhotos.length}
</div>
</div>
<div className="flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-2 py-1 shadow-lg backdrop-blur">
<Button variant="ghost" size="icon" onClick={onLike} disabled={liked} className="h-10 w-10 rounded-full text-white hover:bg-white/10">
<Heart className={`h-5 w-5 ${liked ? 'fill-red-500 text-red-500' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={openShareSheet}
disabled={!eventToken || !photo}
className="h-10 w-10 rounded-full text-white hover:bg-white/10"
>
<Share2 className="h-5 w-5" />
</Button>
</div>
</div>
<div className="flex items-center gap-1">
{currentIndexVal > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => onIndexChange?.(currentIndexVal - 1)}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
<div className="px-3 pb-5 pt-16">
<div
ref={touchRef}
className="relative flex min-h-[60vh] items-center justify-center overflow-hidden rounded-[30px] border border-white/15 bg-black/30 p-4 shadow-xl backdrop-blur"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<X className="h-4 w-4" />
</Button>
{currentIndexVal < currentPhotos.length - 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => onIndexChange?.(currentIndexVal + 1)}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
{currentIndexVal > 0 && (
<Button
variant="ghost"
size="icon"
onClick={() => onIndexChange?.(currentIndexVal - 1)}
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/90 text-slate-800 shadow-lg hover:bg-white"
>
<ChevronLeft className="h-5 w-5" />
</Button>
)}
<img
src={photo?.file_path || photo?.thumbnail_path}
alt={t('lightbox.photoAlt')
.replace('{id}', `${photo?.id ?? ''}`)
.replace(
'{suffix}',
photo?.task_title
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
: ''
)}
className="max-h-[70vh] max-w-full object-contain transition-transform duration-200"
onError={(e) => {
console.error('Image load error:', e);
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{currentIndexVal < currentPhotos.length - 1 && (
<Button
variant="ghost"
size="icon"
onClick={() => onIndexChange?.(currentIndexVal + 1)}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/90 text-slate-800 shadow-lg hover:bg-white"
>
<ChevronRight className="h-5 w-5" />
</Button>
)}
</div>
<div className="mt-5 grid gap-3 rounded-2xl border border-white/15 bg-black/35 px-4 py-3 text-sm text-white/90 shadow-md backdrop-blur sm:grid-cols-[1.3fr_1fr]">
<div className="flex items-center gap-3">
<Avatar className="h-11 w-11 border border-white/20 bg-white/10">
<AvatarFallback className="text-white">{uploaderInitial}</AvatarFallback>
</Avatar>
<div className="space-y-1">
{photo?.uploader_name ? (
<p className="font-semibold text-white">{photo.uploader_name}</p>
) : (
<p className="font-semibold text-white">{t('galleryPage.photo.anonymous', 'Gast')}</p>
)}
{createdLabel ? <p className="text-xs text-white/70">{createdLabel}</p> : null}
</div>
</div>
<div className="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
{task ? (
<Badge variant="outline" className="border-white/30 bg-white/10 text-white">
{t('lightbox.taskLabel')}: {task.title}
</Badge>
) : null}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={onLike}
disabled={liked}
className="h-9 w-9 rounded-full text-white hover:bg-white/10"
aria-label={t('lightbox.like', 'Like photo')}
>
<Heart className={`h-5 w-5 ${liked ? 'fill-red-500 text-red-500' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={openShareSheet}
disabled={!eventToken || !photo}
className="h-9 w-9 rounded-full text-white hover:bg-white/10"
aria-label={t('share.button', 'Teilen')}
>
<Share2 className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (!photo?.file_path) return;
window.open(photo.file_path, '_blank', 'noopener');
}}
className="h-9 w-9 rounded-full text-white hover:bg-white/10"
aria-label={t('lightbox.download', 'Download')}
disabled={!photo?.file_path}
>
<Download className="h-5 w-5" />
</Button>
</div>
</div>
</div>
{taskLoading && !task && (
<div className="mt-4 rounded-xl border border-white/20 bg-black/40 p-3 text-center text-xs text-white/80 shadow-sm backdrop-blur">
<div className="mx-auto mb-1 h-4 w-4 animate-spin rounded-full border-b-2 border-white/70" />
{t('lightbox.loadingTask')}
</div>
)}
</div>
</div>
{/* Task Info Overlay */}
{task && (
<div className="absolute bottom-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
<div className="text-sm">
<div className="font-semibold mb-1 text-white">{t('lightbox.taskLabel')}: {task.title}</div>
{taskLoading && (
<div className="text-xs opacity-70 text-gray-300">{t('lightbox.loadingTask')}</div>
)}
</div>
</div>
)}
{/* Photo Display */}
<div
ref={touchRef}
className="flex items-center justify-center min-h-[60vh] p-4 relative overflow-hidden"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<img
src={photo?.file_path || photo?.thumbnail_path}
alt={t('lightbox.photoAlt')
.replace('{id}', `${photo?.id ?? ''}`)
.replace(
'{suffix}',
photo?.task_title
? t('lightbox.photoAltTaskSuffix').replace('{taskTitle}', photo.task_title)
: ''
)}
className="max-h-[80vh] max-w-full object-contain transition-transform duration-200"
onError={(e) => {
console.error('Image load error:', e);
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
{/* Loading state for task */}
{taskLoading && !task && (
<div className="absolute top-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
<div className="text-sm text-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mx-auto mb-1"></div>
<div className="text-xs opacity-70">{t('lightbox.loadingTask')}</div>
</div>
</div>
)}
<ShareSheet
open={shareSheet.loading || Boolean(shareSheet.url)}
photoId={photo?.id}

View File

@@ -124,7 +124,6 @@ export default function UploadPage() {
const { t, locale } = useTranslation();
const stats = useEventStats();
const { branding } = useEventBranding();
const { event } = useEventData();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;

View File

@@ -1,7 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { LocaleCode } from '../i18n/messages';
type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string; session_id?: string | null };
type Photo = {
id: number;
file_path?: string;
thumbnail_path?: string;
created_at?: string;
session_id?: string | null;
uploader_name?: string | null;
};
type RawPhoto = Record<string, unknown>;
export function usePollGalleryDelta(token: string, locale: LocaleCode) {
@@ -60,6 +67,12 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
const newPhotos: Photo[] = rawPhotos.map((photo: RawPhoto) => ({
...(photo as Photo),
session_id: typeof photo.session_id === 'string' ? photo.session_id : (photo.guest_name as string | null) ?? null,
uploader_name:
typeof photo.uploader_name === 'string'
? (photo.uploader_name as string)
: typeof photo.guest_name === 'string'
? (photo.guest_name as string)
: null,
}));
if (newPhotos.length > 0) {