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:
2025-07-10 22:19:15 +02:00
parent 6438374258
commit 5328b4f73a
52 changed files with 3237 additions and 683 deletions
+171 -66
View File
@@ -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>
</>
);
};
+195 -68
View File
@@ -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>
);
};