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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user