Enhance email templates with clickable links, branding, and improved styling
- Add clickable gallery links in all email templates - Include application logo in email header and footer (custom or PicPeak default) - Redesign emails with professional styling matching gallery login page - Gray background with white content box - PicPeak green header with centered logo - Clean typography and proper spacing - Responsive design for mobile devices - Styled call-to-action buttons - Footer with branding and copyright - Update email processor to fetch branding settings dynamically - Use proper API URLs for logo images in emails 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface GalleryLayoutProps {
|
||||
event: {
|
||||
@@ -44,45 +45,104 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid' && theme.galleryLayout !== 'hero';
|
||||
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Dynamic Favicon */}
|
||||
<DynamicFavicon />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
||||
<div className="container py-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Top row - Title and mobile logout */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
{/* Logo - Mobile optimized */}
|
||||
{brandingSettings?.logo_url && (
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}`}
|
||||
alt={brandingSettings.company_name || 'Company Logo'}
|
||||
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Header structure */}
|
||||
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
||||
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
||||
{isNonGridLayout && (
|
||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button and other header extras */}
|
||||
<div className="flex items-center gap-3">
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Right side - Download and Logout */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For grid and hero layouts - everything in one bar */}
|
||||
{(!isNonGridLayout || theme.galleryLayout === 'hero') && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left side - Logo, Title, and Dates */}
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* Menu button */}
|
||||
<div className="flex-shrink-0">
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-10 sm:h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold text-neutral-900 leading-tight truncate">
|
||||
<h1
|
||||
className="text-lg sm:text-xl lg:text-2xl font-bold text-neutral-900 leading-tight truncate"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs text-neutral-600">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-neutral-600">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||
<span>{format(parseISO(event.event_date), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||
<span>{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -90,57 +150,102 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile logout button - top right */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLogout}
|
||||
className="sm:hidden p-2"
|
||||
title={t('common.logout')}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header extra content (upload button, countdown) */}
|
||||
{headerExtra && headerExtra}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
||||
{isNonGridLayout && (
|
||||
<div
|
||||
className="relative text-white overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.accentColor || '#22c55e',
|
||||
backgroundImage: theme.backgroundPattern !== 'none'
|
||||
? `url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E")`
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<div className="container py-12 sm:py-16 lg:py-20 relative z-10">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto filter brightness-0 invert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Event Name */}
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
|
||||
{/* Desktop logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
{/* Event Details */}
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
{format(parseISO(event.event_date), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative bottom wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z"
|
||||
fill="var(--color-background, #fafafa)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container">{children}</main>
|
||||
@@ -160,7 +265,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs sm:text-sm text-neutral-500">
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'} | Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { PhotoCategory } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GallerySidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: PhotoCategory[];
|
||||
selectedCategoryId: number | null;
|
||||
onCategoryChange: (categoryId: number | null) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
sortBy: 'date' | 'name' | 'size';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size') => void;
|
||||
isSelectionMode: boolean;
|
||||
onToggleSelectionMode: () => void;
|
||||
selectedCount: number;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
isDownloading: boolean;
|
||||
photoCounts?: Record<number, number>;
|
||||
totalPhotos: number;
|
||||
isMobile: boolean;
|
||||
galleryLayout?: string;
|
||||
}
|
||||
|
||||
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
selectedCategoryId,
|
||||
onCategoryChange,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
isSelectionMode,
|
||||
onToggleSelectionMode,
|
||||
selectedCount,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
isDownloading,
|
||||
photoCounts = {},
|
||||
totalPhotos,
|
||||
isMobile,
|
||||
galleryLayout
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isMobile, isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when sidebar is open on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}
|
||||
}, [isMobile, isOpen]);
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
|
||||
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
|
||||
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobile && isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`
|
||||
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out
|
||||
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Search Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t('gallery.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Section */}
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
{t('gallery.download')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading || totalPhotos === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{t('gallery.downloadAll')} ({totalPhotos})
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isSelectionMode ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onToggleSelectionMode}
|
||||
className="w-full"
|
||||
>
|
||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||
</Button>
|
||||
|
||||
{isSelectionMode && selectedCount > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadSelected}
|
||||
disabled={isDownloading}
|
||||
className="w-full"
|
||||
>
|
||||
{t('gallery.downloadSelected')} ({selectedCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('gallery.categories')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onCategoryChange(null);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${selectedCategoryId === null
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{t('gallery.allCategories')}</span>
|
||||
<span className="text-sm text-neutral-500">{totalPhotos}</span>
|
||||
</button>
|
||||
|
||||
{categories.map((category) => {
|
||||
const count = photoCounts[category.id] || 0;
|
||||
const isSelected = selectedCategoryId === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
onCategoryChange(category.id);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{isSelected && <Check className="w-4 h-4" />}
|
||||
{category.name}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
{t('gallery.sortBy')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
{sortOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = sortBy === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onSortChange(option.value as 'date' | 'name' | 'size');
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{option.label}</span>
|
||||
{isSelected && <Check className="w-4 h-4 ml-auto" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -10,11 +10,14 @@ import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
|
||||
import { ExpirationBanner } from './ExpirationBanner';
|
||||
import { CountdownTimer } from './CountdownTimer';
|
||||
import { GalleryLayout } from './GalleryLayout';
|
||||
import { GallerySidebar } from './GallerySidebar';
|
||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
@@ -28,24 +31,37 @@ interface GalleryViewProps {
|
||||
expires_at: string;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { setTheme } = useTheme();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const themeAppliedRef = useRef(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
// Fetch photos
|
||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
||||
const downloadAllMutation = useDownloadAllPhotos();
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['gallery-settings'],
|
||||
@@ -70,9 +86,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
// Apply theme only once when component mounts and settings are loaded
|
||||
// Apply theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (!themeAppliedRef.current && settingsData) {
|
||||
if (settingsData && event) {
|
||||
let themeToApply = null;
|
||||
|
||||
if (event.color_theme) {
|
||||
@@ -82,9 +98,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const eventTheme = JSON.parse(event.color_theme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// Handle legacy theme names - use global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
// Handle legacy theme names - check if it's a preset
|
||||
const preset = GALLERY_THEME_PRESETS[event.color_theme];
|
||||
if (preset) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
// Unknown theme name, fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -99,13 +121,23 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
// Apply theme only once
|
||||
// Apply theme with a small delay to ensure it overrides any global theme
|
||||
if (themeToApply) {
|
||||
themeAppliedRef.current = true;
|
||||
setTheme(themeToApply);
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (event.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = event.hero_photo_id;
|
||||
} else if (event.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: event.hero_photo_id };
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData]); // Only depend on settingsData, not setTheme or event
|
||||
}, [settingsData, event, setTheme]); // Include all dependencies
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
@@ -157,6 +189,39 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
|
||||
|
||||
// Track bulk download
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: selectedPhotos.size
|
||||
});
|
||||
|
||||
// Download each selected photo
|
||||
for (const photo of selectedPhotosList) {
|
||||
await galleryService.downloadPhoto(slug, photo.id, photo.filename);
|
||||
}
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
};
|
||||
|
||||
// Calculate photo counts per category
|
||||
const photoCounts = useMemo(() => {
|
||||
if (!data?.photos) return {};
|
||||
const counts: Record<number, number> = {};
|
||||
data.photos.forEach(photo => {
|
||||
if (photo.category_id) {
|
||||
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [data?.photos]);
|
||||
|
||||
// Track search usage with debouncing
|
||||
useEffect(() => {
|
||||
if (searchTerm.length > 0) {
|
||||
@@ -216,23 +281,71 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const showSidebar = theme.galleryLayout !== 'grid';
|
||||
|
||||
return (
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={true}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
headerExtra={
|
||||
(daysUntilExpiration <= 1 && daysUntilExpiration > 0) || event.allow_user_uploads ? (
|
||||
<>
|
||||
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
|
||||
<CountdownTimer expiresAt={event.expires_at} className="mr-2" />
|
||||
)}
|
||||
{event.allow_user_uploads && (
|
||||
<>
|
||||
{/* Sidebar for non-grid layouts */}
|
||||
{showSidebar ? (
|
||||
<GallerySidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(!sidebarOpen)}
|
||||
categories={(data?.categories || []).filter(cat => photoCounts[cat.id] > 0)}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
selectedCount={selectedPhotos.size}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
photoCounts={photoCounts}
|
||||
totalPhotos={data?.photos.length || 0}
|
||||
isMobile={isMobile}
|
||||
galleryLayout={theme.galleryLayout}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={!showSidebar}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
headerExtra={(() => {
|
||||
const items = [];
|
||||
|
||||
if (showSidebar) {
|
||||
items.push(
|
||||
<Button
|
||||
key="menu-button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<Menu className="w-4 h-4" />}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label={t('gallery.toggleMenu')}
|
||||
>
|
||||
{t('common.menu')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
items.push(
|
||||
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
||||
);
|
||||
}
|
||||
|
||||
if (event.allow_user_uploads) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
@@ -242,50 +355,64 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
|
||||
<span className="sm:hidden">{t('common.upload')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
return <>{items}</>;
|
||||
})()}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="mt-6">
|
||||
<PhotoFilterBar
|
||||
categories={data.categories}
|
||||
photos={data.photos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
{/* Search and Filters - Only for grid layout */}
|
||||
{!showSidebar ? (
|
||||
<div className="mt-6">
|
||||
<PhotoFilterBar
|
||||
categories={data.categories}
|
||||
photos={data.photos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className="mt-6">
|
||||
<PhotoGridWithLayouts photos={filteredPhotos} slug={slug} categoryId={selectedCategoryId} />
|
||||
<div className={showSidebar ? "mt-6" : "mt-6"}>
|
||||
<PhotoGridWithLayouts
|
||||
photos={filteredPhotos}
|
||||
slug={slug}
|
||||
categoryId={selectedCategoryId}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectionChange={setSelectedPhotos}
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
showSelectionControls={!showSidebar}
|
||||
eventName={event.event_name}
|
||||
eventLogo={brandingSettings?.logo_url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<UserPhotoUpload
|
||||
eventId={event.id}
|
||||
categoryId={event.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
window.location.reload(); // Simple reload for now
|
||||
}}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</GalleryLayout>
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<UserPhotoUpload
|
||||
eventId={event.id}
|
||||
categoryId={event.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
window.location.reload(); // Simple reload for now
|
||||
}}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</GalleryLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -25,20 +25,40 @@ interface PhotoGridWithLayoutsProps {
|
||||
photos: Photo[];
|
||||
slug: string;
|
||||
categoryId?: number | null;
|
||||
isSelectionMode?: boolean;
|
||||
selectedPhotos?: Set<number>;
|
||||
onSelectionChange?: (photos: Set<number>) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
showSelectionControls?: boolean;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
categoryId
|
||||
categoryId,
|
||||
isSelectionMode: parentSelectionMode,
|
||||
selectedPhotos: parentSelectedPhotos,
|
||||
onSelectionChange,
|
||||
onToggleSelectionMode: parentToggleSelectionMode,
|
||||
showSelectionControls = true,
|
||||
eventName,
|
||||
eventLogo
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [localSelectionMode, setLocalSelectionMode] = useState(false);
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
|
||||
// Use parent state if provided, otherwise use local state
|
||||
const selectedPhotos = parentSelectedPhotos ?? localSelectedPhotos;
|
||||
const isSelectionMode = parentSelectionMode ?? localSelectionMode;
|
||||
const setSelectedPhotos = onSelectionChange ?? setLocalSelectedPhotos;
|
||||
const toggleSelectionMode = parentToggleSelectionMode ?? (() => setLocalSelectionMode(!localSelectionMode));
|
||||
|
||||
// Clear selection when category changes
|
||||
useEffect(() => {
|
||||
setSelectedPhotos(new Set());
|
||||
@@ -71,10 +91,6 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
setSelectedPhotos(new Set());
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedPhotos(new Set(photos.map(p => p.id)));
|
||||
@@ -112,7 +128,11 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
if (parentToggleSelectionMode) {
|
||||
parentToggleSelectionMode();
|
||||
} else {
|
||||
setLocalSelectionMode(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
}
|
||||
@@ -138,6 +158,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
selectedPhotos,
|
||||
isSelectionMode,
|
||||
onPhotoSelect: handlePhotoSelect,
|
||||
eventName,
|
||||
eventLogo,
|
||||
};
|
||||
|
||||
let LayoutComponent;
|
||||
@@ -163,8 +185,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Selection Mode Controls - Not shown for carousel layout */}
|
||||
{photos.length > 1 && galleryLayout !== 'carousel' && (
|
||||
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
|
||||
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
|
||||
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -181,7 +203,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsSelectionMode(true);
|
||||
toggleSelectionMode();
|
||||
selectAll();
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface BaseGalleryLayoutProps {
|
||||
selectedPhotos?: Set<number>;
|
||||
isSelectionMode?: boolean;
|
||||
onPhotoSelect?: (photoId: number) => void;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Maximize2, Check, ChevronDown } from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage, Button } from '../../common';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
export const HeroGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect
|
||||
onPhotoSelect,
|
||||
eventName,
|
||||
eventLogo
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||
@@ -50,30 +57,25 @@ export const HeroGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
|
||||
{/* Hero Content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-white px-4">
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4 drop-shadow-lg">
|
||||
{heroPhoto.category_name || 'Featured Photo'}
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
leftIcon={<Maximize2 className="w-5 h-5" />}
|
||||
onClick={() => onPhotoClick(0)}
|
||||
className="bg-white/20 backdrop-blur-sm hover:bg-white/30"
|
||||
>
|
||||
View Gallery
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
leftIcon={<Download className="w-5 h-5" />}
|
||||
onClick={(e) => onDownload(heroPhoto, e)}
|
||||
className="border-white text-white hover:bg-white/20"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center px-4">
|
||||
{/* Logo */}
|
||||
{eventLogo && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo}
|
||||
alt="Event logo"
|
||||
className="h-20 sm:h-24 lg:h-32 mx-auto drop-shadow-lg filter brightness-0 invert"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Title */}
|
||||
{eventName && (
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg">
|
||||
{eventName}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,16 +24,21 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative group cursor-pointer overflow-hidden ${className}`}
|
||||
onClick={onClick}
|
||||
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
<div className="absolute inset-0">
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
@@ -90,96 +95,6 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
// const gallerySettings = theme.gallerySettings || {};
|
||||
// const pattern = gallerySettings.mosaicPattern || 'structured';
|
||||
|
||||
// Create mosaic patterns
|
||||
const renderStructuredPattern = () => {
|
||||
const patterns = [
|
||||
// Pattern 1: Large left, 2 small right
|
||||
<div key="pattern1" className="grid grid-cols-2 gap-2 h-96">
|
||||
{photos[0] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[0]}
|
||||
isSelected={selectedPhotos.has(photos[0].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(0, photos[0].id)}
|
||||
onDownload={(e) => onDownload(photos[0], e)}
|
||||
className="col-span-1 row-span-2"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photos[1] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[1]}
|
||||
isSelected={selectedPhotos.has(photos[1].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(1, photos[1].id)}
|
||||
onDownload={(e) => onDownload(photos[1], e)}
|
||||
/>
|
||||
)}
|
||||
{photos[2] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[2]}
|
||||
isSelected={selectedPhotos.has(photos[2].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(2, photos[2].id)}
|
||||
onDownload={(e) => onDownload(photos[2], e)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
// Pattern 2: 3 equal columns
|
||||
<div key="pattern2" className="grid grid-cols-3 gap-2 h-64">
|
||||
{photos.slice(3, 6).map((photo, idx) => {
|
||||
const index = idx + 3;
|
||||
return photo ? (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(index, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>,
|
||||
|
||||
// Pattern 3: Large center with sides
|
||||
<div key="pattern3" className="grid grid-cols-3 gap-2 h-96">
|
||||
{photos[6] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[6]}
|
||||
isSelected={selectedPhotos.has(photos[6].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(6, photos[6].id)}
|
||||
onDownload={(e) => onDownload(photos[6], e)}
|
||||
/>
|
||||
)}
|
||||
{photos[7] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[7]}
|
||||
isSelected={selectedPhotos.has(photos[7].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(7, photos[7].id)}
|
||||
onDownload={(e) => onDownload(photos[7], e)}
|
||||
className="row-span-2"
|
||||
/>
|
||||
)}
|
||||
{photos[8] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[8]}
|
||||
isSelected={selectedPhotos.has(photos[8].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(8, photos[8].id)}
|
||||
onDownload={(e) => onDownload(photos[8], e)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
];
|
||||
|
||||
return patterns;
|
||||
};
|
||||
|
||||
const handlePhotoClick = (index: number, photoId: number) => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photoId);
|
||||
@@ -188,21 +103,147 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// For now, we'll use the structured pattern
|
||||
// You can implement random and alternating patterns as needed
|
||||
const mosaicElements = renderStructuredPattern();
|
||||
|
||||
// Add remaining photos in a regular grid
|
||||
const remainingPhotos = photos.slice(9);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{mosaicElements}
|
||||
// Create a more structured mosaic layout
|
||||
const renderMosaicLayout = () => {
|
||||
const elements = [];
|
||||
let photoIndex = 0;
|
||||
let patternIndex = 0;
|
||||
|
||||
while (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.length - photoIndex;
|
||||
|
||||
{remainingPhotos.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
// Choose pattern based on rotation and remaining photos
|
||||
if (patternIndex % 3 === 0 && remainingPhotos >= 3) {
|
||||
// Pattern 1: Large left, 2 small right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
className="col-span-1"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
photo={photo1}
|
||||
isSelected={selectedPhotos.has(photo1.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
||||
onDownload={(e) => onDownload(photo1, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
photo={photo2}
|
||||
isSelected={selectedPhotos.has(photo2.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
||||
onDownload={(e) => onDownload(photo2, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 1 && remainingPhotos >= 3) {
|
||||
// Pattern 2: 3 equal columns
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[250px]">
|
||||
{[0, 1, 2].map(offset => {
|
||||
const currentIndex = photoIndex + offset;
|
||||
const photo = photos[currentIndex];
|
||||
return photo ? (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
className=""
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 2 && remainingPhotos >= 3) {
|
||||
// Pattern 3: Large span-2 with 2 small on right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
photo={photo1}
|
||||
isSelected={selectedPhotos.has(photo1.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
||||
onDownload={(e) => onDownload(photo1, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
photo={photo2}
|
||||
isSelected={selectedPhotos.has(photo2.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
||||
onDownload={(e) => onDownload(photo2, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else {
|
||||
// Handle remaining photos that don't fit patterns
|
||||
break;
|
||||
}
|
||||
|
||||
patternIndex++;
|
||||
}
|
||||
|
||||
// Add remaining photos in a regular grid
|
||||
if (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.slice(photoIndex);
|
||||
elements.push(
|
||||
<div key={`remaining-${photoIndex}`} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{remainingPhotos.map((photo, idx) => {
|
||||
const index = idx + 9;
|
||||
const index = photoIndex + idx;
|
||||
return (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
@@ -216,7 +257,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto">
|
||||
{renderMosaicLayout()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user