feat: decouple hero header from gallery layouts (#158)

- Add separate header_style setting (hero/standard/minimal/none) that can
  be combined with any layout type (grid/masonry/carousel/timeline/mosaic)
- Create HeroHeader and HeroDivider components for reusable hero section
- Add hero_divider_style setting (wave/straight/angle/curve/none)
- Add database migration for header_style and hero_divider_style columns
- Remove deprecated HeroGalleryLayout component
- Fix various TypeScript errors across the codebase:
  - Add missing type properties (css_template_id, updatedAt, justified settings)
  - Fix null handling for event_date and expires_at fields
  - Fix translation function calls and i18n config
  - Remove unused imports and variables
This commit is contained in:
Paul Nothaft
2026-02-01 22:44:28 +01:00
parent 397d33a95a
commit 7b8d8bd92b
35 changed files with 907 additions and 533 deletions
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -7,6 +7,12 @@ import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { BulkCategoryModal } from './BulkCategoryModal';
interface CategoryOption {
id: number;
name: string;
}
interface AdminPhotoGridProps {
photos: AdminPhoto[];
@@ -14,6 +20,7 @@ interface AdminPhotoGridProps {
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
onSelectionChange?: (selectedIds: number[]) => void;
categories?: CategoryOption[];
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
@@ -21,13 +28,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
eventId,
onPhotoClick,
onPhotosDeleted,
onSelectionChange
onSelectionChange,
categories = []
}) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(new Set());
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [isUpdatingCategory, setIsUpdatingCategory] = useState(false);
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
if (e) {
@@ -125,6 +135,35 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
}
};
const handleMoveToCategory = async (categoryId: number | null) => {
if (selectedPhotos.size === 0) return;
setIsUpdatingCategory(true);
const selectedIds = Array.from(selectedPhotos);
try {
await photosService.updatePhotosCategory(eventId, selectedIds, categoryId);
const categoryName = categoryId
? categories.find(c => Number(c.id) === categoryId)?.name || t('photos.selectedCategory', 'selected category')
: t('photos.uncategorized', 'Uncategorized');
toast.success(
t('photos.movedToCategory', '{{count}} photos moved to {{category}}', {
count: selectedIds.length,
category: categoryName
})
);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onSelectionChange?.([]);
setIsCategoryModalOpen(false);
onPhotosDeleted(); // Refresh the photo list
} catch {
toast.error(t('photos.moveToCategoryFailed', 'Failed to move photos to category'));
} finally {
setIsUpdatingCategory(false);
}
};
return (
<div>
{/* Action Bar */}
@@ -154,6 +193,14 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<span className="text-sm text-neutral-600">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setIsCategoryModalOpen(true)}
leftIcon={<FolderOpen className="w-4 h-4" />}
>
{t('photos.moveToCategory', 'Move to Category')}
</Button>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
@@ -309,6 +356,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
</div>
)}
{/* Bulk Category Modal */}
<BulkCategoryModal
isOpen={isCategoryModalOpen}
onClose={() => setIsCategoryModalOpen(false)}
onConfirm={handleMoveToCategory}
photoCount={selectedPhotos.size}
categories={categories}
isLoading={isUpdatingCategory}
/>
</div>
);
};
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, RotateCcw, Eye, Code, AlertTriangle, Check } from 'lucide-react';
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
@@ -28,7 +28,7 @@ interface EventRenameDialogProps {
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
isOpen,
eventName,
eventId,
eventId: _eventId,
customerEmail,
onClose,
onRename,
@@ -151,18 +151,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
</div>
);
case 'hero':
return (
<div className="space-y-3">
<PreviewPhoto photo={mockPhotos[0]} aspectRatio="aspect-[16/9]" className="w-full" />
<div className={`grid grid-cols-4 ${gapClass}`}>
{mockPhotos.slice(1, 5).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
</div>
);
case 'mosaic':
return (
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
@@ -90,7 +90,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
>
{RATING_OPTIONS.map(option => (
<option key={option.label} value={option.value ?? ''}>
{t(option.label, option.label.split('.').pop())}
{t(option.label, { defaultValue: option.label.split('.').pop() })}
</option>
))}
</select>
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode } from 'lucide-react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
// import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify';
@@ -28,10 +28,45 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-5 h-5" />,
carousel: <Play className="w-5 h-5" />,
timeline: <Clock className="w-5 h-5" />,
hero: <Image className="w-5 h-5" />,
mosaic: <LayoutGrid className="w-5 h-5" />
};
const headerStyleIcons: Record<HeaderStyleType, React.ReactNode> = {
hero: <Image className="w-5 h-5" />,
standard: <Layout className="w-5 h-5" />,
minimal: <Minimize2 className="w-5 h-5" />,
none: <EyeOff className="w-5 h-5" />
};
const dividerStylePreviews: Record<HeroDividerStyle, React.ReactNode> = {
wave: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,12 C12,18 37,6 50,12 C63,18 88,6 100,12 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
straight: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<rect x="0" y="12" width="100" height="12" fill="currentColor" className="text-neutral-300" />
</svg>
),
angle: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,24 L100,8 L100,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
curve: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,16 Q50,0 100,16 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
none: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="24" fill="currentColor" className="text-neutral-100" />
<text x="50" y="16" textAnchor="middle" fontSize="10" fill="currentColor" className="text-neutral-400">No divider</text>
</svg>
)
};
// Layout descriptions will use translation keys
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
@@ -439,6 +474,85 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</Card>
)}
{/* Header Style - Decoupled from Layout */}
{showGalleryLayouts && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<ImageIcon className="w-5 h-5" />
{t('branding.headerStyle', 'Header Style')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
{t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => (
<button
key={style}
onClick={() => handleChange('headerStyle', style)}
className={`relative p-4 rounded-lg border-2 transition-all ${
(localTheme.headerStyle || 'standard') === style
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700">
{headerStyleIcons[style]}
</div>
<span className="font-medium text-sm capitalize">
{t(`branding.headerStyleOptions.${style}`, style)}
</span>
<span className="text-xs text-neutral-600 mt-1">
{t(`branding.headerStyleDescriptions.${style}`, '')}
</span>
</div>
{(localTheme.headerStyle || 'standard') === style && (
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
)}
</button>
))}
</div>
{/* Divider Style - Only show when hero header is selected */}
{localTheme.headerStyle === 'hero' && (
<div className="mt-6 pt-6 border-t border-neutral-200">
<h4 className="font-medium text-sm text-neutral-700 mb-3">
{t('branding.heroDividerStyle', 'Divider Style')}
</h4>
<p className="text-xs text-neutral-600 mb-4">
{t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => (
<button
key={divider}
onClick={() => handleChange('heroDividerStyle', divider)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(localTheme.heroDividerStyle || 'wave') === divider
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex flex-col items-center">
<div className="w-full mb-2 bg-neutral-800 rounded-t overflow-hidden">
<div className="h-8"></div>
{dividerStylePreviews[divider]}
</div>
<span className="text-xs font-medium capitalize">
{t(`branding.dividerOptions.${divider}`, divider)}
</span>
</div>
{(localTheme.heroDividerStyle || 'wave') === divider && (
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
)}
</button>
))}
</div>
</div>
)}
</Card>
)}
{/* Color Customization */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
+8 -11
View File
@@ -1,12 +1,11 @@
import React from 'react';
import {
Palette,
Type,
Grid3X3,
Layers,
Play,
Clock,
Image,
import {
Palette,
Type,
Grid3X3,
Layers,
Play,
Clock,
LayoutGrid,
Layout
} from 'lucide-react';
@@ -25,9 +24,7 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />,
justified: <Layers className="w-4 h-4" />
mosaic: <LayoutGrid className="w-4 h-4" />
};
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, Check } from 'lucide-react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, LayoutGrid, Check } from 'lucide-react';
import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview';
@@ -21,9 +21,7 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />,
justified: <Layers className="w-4 h-4" />
mosaic: <LayoutGrid className="w-4 h-4" />
};
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
@@ -8,13 +8,14 @@ import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { buildResourceUrl } from '../../utils/url';
import type { HeaderStyleType } from '../../types/theme.types';
interface GalleryLayoutProps {
event: {
event_name: string;
event_type?: string;
event_date?: string;
expires_at?: string;
event_date?: string | null;
expires_at?: string | null;
};
brandingSettings?: {
company_name?: string;
@@ -56,8 +57,13 @@ 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';
// Determine header style - check theme.headerStyle first, then fall back to legacy behavior
const headerStyle: HeaderStyleType = theme.headerStyle || 'standard';
const isHeroHeader = headerStyle === 'hero';
// Non-grid layouts that need the sidebar (excluding layouts using hero header)
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid';
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
const headingFontFamily = theme.headingFontFamily || fontFamily;
@@ -123,9 +129,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<DynamicFavicon />
{/* Header structure */}
<header className={`gallery-header 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 && (
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
{/* For non-grid layouts - keep the current structure */}
{isNonGridLayout && !isHeroHeader && (
<div className="bg-neutral-50 border-b border-neutral-200">
<div className="container py-2">
<div className="flex items-center justify-between">
@@ -170,8 +176,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</div>
)}
{/* For grid layout - everything in one bar */}
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
{/* For grid layout - everything in one bar (standard header) */}
{!isNonGridLayout && !isHeroHeader && (
<div className="container py-3">
<div className="flex items-center justify-between gap-2 sm:gap-4">
{/* Left side - Menu button, Logo */}
@@ -292,8 +298,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</div>
)}
{/* For hero layout - minimal header with just menu and logout */}
{theme.galleryLayout === 'hero' && (
{/* For hero header style - minimal header with just menu and logout */}
{isHeroHeader && (
<div className="container py-3">
<div className="flex items-center justify-between">
{/* Left side - Menu button */}
@@ -337,8 +343,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
)}
</header>
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
{isNonGridLayout && (
{/* Hero Header for non-grid layouts when using standard header style */}
{isNonGridLayout && !isHeroHeader && (
<div
className="gallery-hero relative text-white overflow-hidden"
style={{
@@ -30,10 +30,10 @@ interface GalleryViewProps {
id: number;
event_name: string;
event_type: string;
event_date: string;
event_date: string | null;
welcome_message?: string;
color_theme?: string;
expires_at: string;
expires_at: string | null;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
@@ -604,7 +604,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerExtra={(() => {
const items = [];
if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0 && event.expires_at) {
items.push(
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
);
@@ -632,7 +632,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
})()}
>
{/* Expiration Banner */}
{showUrgentWarning && (
{showUrgentWarning && event.expires_at && (
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
@@ -694,6 +694,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
heroLogoVisible={data?.event?.hero_logo_visible !== false}
heroLogoSize={data?.event?.hero_logo_size || 'medium'}
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
/>
</div>
@@ -0,0 +1,62 @@
import React from 'react';
import type { HeroDividerStyle } from '../../types/theme.types';
interface HeroDividerProps {
style: HeroDividerStyle;
fillColor?: string;
className?: string;
}
export const HeroDivider: React.FC<HeroDividerProps> = ({
style,
fillColor = 'var(--color-background, #fafafa)',
className = ''
}) => {
if (style === 'none' || style === 'straight') {
// No visible divider - straight clean edge
return null;
}
switch (style) {
case 'wave':
return (
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
<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={fillColor}
/>
</svg>
</div>
);
case 'angle':
return (
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path
d="M0,120 L1200,40 L1200,120 Z"
fill={fillColor}
/>
</svg>
</div>
);
case 'curve':
return (
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path
d="M0,80 Q600,0 1200,80 L1200,120 L0,120 Z"
fill={fillColor}
/>
</svg>
</div>
);
default:
return null;
}
};
HeroDivider.displayName = 'HeroDivider';
@@ -0,0 +1,259 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ChevronDown, Calendar, Clock } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useTheme } from '../../contexts/ThemeContext';
import { AuthenticatedImage } from '../common';
import { HeroDivider } from './HeroDivider';
import { buildResourceUrl } from '../../utils/url';
import type { Photo } from '../../types';
import type { HeroDividerStyle } from '../../types/theme.types';
interface HeroHeaderProps {
photos: Photo[];
slug: string;
eventName?: string;
eventLogo?: string | null;
eventDate?: string | null;
expiresAt?: string | null;
heroPhotoOverride?: Photo | null;
heroLogoVisible?: boolean;
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
heroLogoPosition?: 'top' | 'center' | 'bottom';
dividerStyle?: HeroDividerStyle;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
onScrollToContent?: () => void;
}
export const HeroHeader: React.FC<HeroHeaderProps> = ({
photos,
slug,
eventName,
eventLogo,
eventDate,
expiresAt,
heroPhotoOverride,
heroLogoVisible = true,
heroLogoSize = 'medium',
heroLogoPosition = 'top',
dividerStyle = 'wave',
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
onScrollToContent
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
const gallerySettings = theme.gallerySettings || {};
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
// Helper function to get logo size classes
const getLogoSizeClasses = (size: string): string => {
switch (size) {
case 'small':
return 'h-12 sm:h-14 lg:h-16';
case 'medium':
return 'h-20 sm:h-24 lg:h-32';
case 'large':
return 'h-28 sm:h-32 lg:h-40';
case 'xlarge':
return 'h-36 sm:h-40 lg:h-48';
default:
return 'h-20 sm:h-24 lg:h-32';
}
};
const handleScrollToContent = useCallback(() => {
if (onScrollToContent) {
onScrollToContent();
} else {
// Default: scroll to gallery grid section
const gridSection = document.getElementById('gallery-grid-section');
if (gridSection) {
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Fallback: scroll down by hero section height
window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' });
}
}
}, [onScrollToContent]);
// If an override is provided, always use it and skip initialization logic
useEffect(() => {
if (heroPhotoOverride) {
setHeroPhoto(heroPhotoOverride);
setHasInitialized(true);
}
}, [heroPhotoOverride]);
// Reset initialization when heroImageId changes
useEffect(() => {
if (gallerySettings.heroImageId) {
setHasInitialized(false);
}
}, [gallerySettings.heroImageId]);
// Select hero photo (admin-selected or first photo only if gallery was empty)
useEffect(() => {
// When an override is provided, the effect above has already set the hero.
if (heroPhotoOverride) return;
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
// If admin has selected a specific hero image, always use it when available
if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId);
if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero);
setHasInitialized(true);
return;
}
}
// Only auto-select first photo on initial load
if (!hasInitialized) {
setHeroPhoto(photos[0]);
setHasInitialized(true);
}
}
}, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]);
if (!heroPhoto) return null;
return (
<div className="relative -mt-6">
{/* Hero Section */}
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
<AuthenticatedImage
src={heroPhoto.url}
fallbackSrc={heroPhoto.thumbnail_url || undefined}
alt={heroPhoto.filename}
className="w-full h-full object-cover"
isGallery={true}
slug={slug}
photoId={heroPhoto.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay */}
<div
className="absolute inset-0 bg-black"
style={{ opacity: overlayOpacity }}
/>
{/* Hero Content */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center px-4">
{/* Logo at top position */}
{heroLogoVisible && heroLogoPosition === 'top' && (
<div className="mb-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) 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 mb-4">
{eventName}
</h1>
)}
{/* Logo at center position (between title and dates) */}
{heroLogoVisible && heroLogoPosition === 'center' && (
<div className="my-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
{/* Event Dates */}
{(eventDate || expiresAt) && (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
{eventDate && (
<span className="flex items-center text-lg sm:text-xl">
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{format(parseISO(eventDate), 'PP')}
</span>
)}
{expiresAt && (
<span className="flex items-center text-lg sm:text-xl">
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
</span>
)}
</div>
)}
{/* Logo at bottom position */}
{heroLogoVisible && heroLogoPosition === 'bottom' && (
<div className="mt-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
</div>
</div>
{/* Scroll Indicator */}
<button
onClick={handleScrollToContent}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
aria-label="Scroll to gallery"
>
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</button>
{/* Decorative Divider */}
<HeroDivider style={dividerStyle} />
</div>
</div>
);
};
HeroHeader.displayName = 'HeroHeader';
@@ -5,7 +5,7 @@ import { Button, Input } from '../common';
import type { FilterType } from './GalleryFilter';
interface PhotoCategory {
id: number;
id: number | string;
name: string;
slug: string;
is_global: boolean;
@@ -13,7 +13,7 @@ interface PhotoCategory {
interface Photo {
id: number;
category_id?: number;
category_id?: number | string | null;
like_count?: number;
favorite_count?: number;
}
@@ -21,8 +21,8 @@ interface Photo {
interface PhotoFilterBarProps {
categories?: PhotoCategory[];
photos: Photo[];
selectedCategoryId: number | null;
onCategoryChange: (categoryId: number | null) => void;
selectedCategoryId: number | string | null;
onCategoryChange: (categoryId: number | string | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating';
@@ -17,14 +17,15 @@ import {
MasonryGalleryLayout,
CarouselGalleryLayout,
TimelineGalleryLayout,
HeroGalleryLayout,
MosaicGalleryLayout,
} from './layouts';
import { HeroHeader } from './HeroHeader';
import type { HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
interface PhotoGridWithLayoutsProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
categoryId?: number | string | null;
// When provided, the hero layout will use this photo
// instead of deriving from the filtered photo list.
heroPhotoOverride?: Photo | null;
@@ -35,8 +36,8 @@ interface PhotoGridWithLayoutsProps {
showSelectionControls?: boolean;
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
eventDate?: string | null;
expiresAt?: string | null;
feedbackEnabled?: boolean;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
@@ -56,6 +57,9 @@ interface PhotoGridWithLayoutsProps {
heroLogoVisible?: boolean;
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
heroLogoPosition?: 'top' | 'center' | 'bottom';
// Header style (decoupled from layout)
headerStyle?: HeaderStyleType;
heroDividerStyle?: HeroDividerStyle;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -83,7 +87,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
expiresAt,
heroLogoVisible = true,
heroLogoSize = 'medium',
heroLogoPosition = 'top'
heroLogoPosition = 'top',
headerStyle,
heroDividerStyle = 'wave'
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -212,6 +218,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoPosition,
};
// Determine if we should show hero header (decoupled from layout)
const effectiveHeaderStyle = headerStyle || theme.headerStyle;
const showHeroHeader = effectiveHeaderStyle === 'hero';
let LayoutComponent;
switch (galleryLayout) {
case 'masonry':
@@ -223,9 +233,6 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
case 'timeline':
LayoutComponent = TimelineGalleryLayout;
break;
case 'hero':
LayoutComponent = HeroGalleryLayout;
break;
case 'mosaic':
LayoutComponent = MosaicGalleryLayout;
break;
@@ -235,6 +242,27 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
return (
<>
{/* Hero Header - shown when headerStyle is 'hero' */}
{showHeroHeader && (
<HeroHeader
photos={photos}
slug={slug}
eventName={eventName}
eventLogo={eventLogo}
eventDate={eventDate}
expiresAt={expiresAt}
heroPhotoOverride={heroPhotoOverride}
heroLogoVisible={heroLogoVisible}
heroLogoSize={heroLogoSize}
heroLogoPosition={heroLogoPosition}
dividerStyle={heroDividerStyle}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
/>
)}
{/* 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">
@@ -15,8 +15,8 @@ export interface BaseGalleryLayoutProps {
onPhotoSelect?: (photoId: number) => void;
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
eventDate?: string | null;
expiresAt?: string | null;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
@@ -1,6 +1,7 @@
import React from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
@@ -59,6 +60,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
liked = false,
onLikeSuccess
}) => {
const { t } = useTranslation();
const [overlayVisible, setOverlayVisible] = React.useState(false);
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
const overlayTimeoutRef = React.useRef<number | null>(null);
@@ -1,412 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { buildResourceUrl } from '../../../utils/url';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
// Use a static hero photo independent of current filter
heroPhotoOverride?: Photo | null;
// Hero logo customization options
heroLogoVisible?: boolean;
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
heroLogoPosition?: 'top' | 'center' | 'bottom';
}
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
photos,
slug,
onPhotoClick,
onOpenPhotoWithFeedback,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
eventName,
eventLogo,
eventDate,
expiresAt,
heroPhotoOverride,
heroLogoVisible = true,
heroLogoSize = 'medium',
heroLogoPosition = 'top',
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const gallerySettings = theme.gallerySettings || {};
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
// Helper function to get logo size classes
const getLogoSizeClasses = (size: string): string => {
switch (size) {
case 'small':
return 'h-12 sm:h-14 lg:h-16';
case 'medium':
return 'h-20 sm:h-24 lg:h-32';
case 'large':
return 'h-28 sm:h-32 lg:h-40';
case 'xlarge':
return 'h-36 sm:h-40 lg:h-48';
default:
return 'h-20 sm:h-24 lg:h-32';
}
};
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
const gridRef = useRef<HTMLDivElement | null>(null);
const handleScrollToGrid = useCallback(() => {
if (gridRef.current) {
gridRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
// If an override is provided, always use it and skip initialization logic
useEffect(() => {
if (heroPhotoOverride) {
setHeroPhoto(heroPhotoOverride);
setHasInitialized(true);
}
}, [heroPhotoOverride]);
// Reset initialization when heroImageId changes
useEffect(() => {
if (gallerySettings.heroImageId) {
setHasInitialized(false);
}
}, [gallerySettings.heroImageId]);
// Select hero photo (admin-selected or first photo only if gallery was empty)
useEffect(() => {
// When an override is provided, the effect above has already set the hero.
if (heroPhotoOverride) return;
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
// If admin has selected a specific hero image, always use it when available
if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId);
if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero);
setHasInitialized(true);
return;
}
}
// Only auto-select first photo on initial load
if (!hasInitialized) {
setHeroPhoto(photos[0]);
setHasInitialized(true);
}
}
}, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]);
if (!heroPhoto) return null;
// Show all photos including the hero photo in the grid
const remainingPhotos = photos;
return (
<>
<div className="relative -mt-6">
{/* Hero Section */}
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
<AuthenticatedImage
src={heroPhoto.url}
fallbackSrc={heroPhoto.thumbnail_url || undefined}
alt={heroPhoto.filename}
className="w-full h-full object-cover"
isGallery={true}
slug={slug}
photoId={heroPhoto.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay */}
<div
className="absolute inset-0 bg-black"
style={{ opacity: overlayOpacity }}
/>
{/* Hero Content */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center px-4">
{/* Logo at top position */}
{heroLogoVisible && heroLogoPosition === 'top' && (
<div className="mb-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) 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 mb-4">
{eventName}
</h1>
)}
{/* Logo at center position (between title and dates) */}
{heroLogoVisible && heroLogoPosition === 'center' && (
<div className="my-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
{/* Event Dates */}
{(eventDate || expiresAt) && (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
{eventDate && (
<span className="flex items-center text-lg sm:text-xl">
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{format(parseISO(eventDate), 'PP')}
</span>
)}
{expiresAt && (
<span className="flex items-center text-lg sm:text-xl">
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
</span>
)}
</div>
)}
{/* Logo at bottom position */}
{heroLogoVisible && heroLogoPosition === 'bottom' && (
<div className="mt-6">
<img
src={eventLogo ?
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
style={{
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
)}
</div>
</div>
{/* Scroll Indicator */}
<button
onClick={() => {
// Scroll to the grid section
const gridSection = document.getElementById('gallery-grid-section');
if (gridSection) {
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Fallback: scroll down by hero section height
window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' });
}
}}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
aria-label="Scroll to gallery"
>
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</button>
</div>
{/* Grid Section */}
<div id="gallery-grid-section" className="photo-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{remainingPhotos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg"
onClick={() => onPhotoClick(actualIndex)}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
slug={slug}
photoId={photo.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
<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 && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onPhotoClick(actualIndex);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
{allowDownloads && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onDownload(photo, e);
}}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
setLikedIds(prev => new Set(prev).add(photo.id));
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (_) {}
}}
aria-label="Like photo"
aria-pressed={likedIds.has(photo.id)}
title="Like"
>
<Heart className={`w-5 h-5 ${likedIds.has(photo.id) ? 'text-white fill-white' : 'text-neutral-800'}`} />
</button>
)}
{canQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onOpenPhotoWithFeedback?.(actualIndex); }}
aria-label="Comment on photo"
title="Comment"
>
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
</>
)}
</div>
{/* Selection Checkbox (visible on hover or when selected) */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={selectedPhotos.has(photo.id)}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{/* Feedback indicators (always visible, bottom-left). Show like immediately when liked */}
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id) || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-20`}>
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
)}
{(photo.average_rating ?? 0) > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-yellow-500 fill-current"><path d="M12 .587l3.668 7.431 8.2 1.193-5.934 5.787 1.402 8.168L12 18.897l-7.336 3.869 1.402-8.168L.132 9.211l8.2-1.193z"/></svg>
</span>
)}
{(photo.comment_count ?? 0) > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-blue-600 fill-current"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>
</span>
)}
</div>
)}
</div>
);
})}
</div>
</div>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
onSubmit={async (name, email) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
guest_email: email,
});
setPendingAction(null);
}
}}
feedbackType="like"
/>
</>
);
};
@@ -2,6 +2,7 @@ export { GridGalleryLayout } from './GridGalleryLayout';
export { MasonryGalleryLayout } from './MasonryGalleryLayout';
export { CarouselGalleryLayout } from './CarouselGalleryLayout';
export { TimelineGalleryLayout } from './TimelineGalleryLayout';
export { HeroGalleryLayout } from './HeroGalleryLayout';
export { MosaicGalleryLayout } from './MosaicGalleryLayout';
export type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
export type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
// Note: HeroGalleryLayout has been deprecated in favor of HeroHeader component
// which can be used with any layout via the headerStyle setting