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

@@ -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}