feat: add Gallery Premium and Gallery Story layouts (Beta)

- Add Gallery Premium layout: elegant light theme with masonry grid,
  hero section, sticky navigation, and integrated lightbox
- Add Gallery Story layout: cinematic dark theme with scene-based
  sections, carousels, and gold accents
- Implement full-page layout support: bypass standard header/footer/
  sidebar for immersive experience
- Add logout button to both layouts for authenticated galleries
- Mark both layouts as (Beta) in theme editor and layout selectors
- Fix hero title color visibility in Gallery Premium layout
This commit is contained in:
Paul Nothaft
2026-02-06 18:03:47 +01:00
parent bc6c48bb24
commit e179def3cc
20 changed files with 3227 additions and 121 deletions
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from '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 { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
@@ -28,7 +28,9 @@ 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" />,
mosaic: <LayoutGrid className="w-5 h-5" />
mosaic: <LayoutGrid className="w-5 h-5" />,
'gallery-premium': <Columns className="w-5 h-5" />,
'gallery-story': <Film className="w-5 h-5" />
};
const headerStyleIcons: Record<HeaderStyleType, React.ReactNode> = {
@@ -178,7 +180,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="space-y-6">
{/* Preset Themes */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Sparkles className="w-5 h-5" />
{t('branding.themePresets')}
</h3>
@@ -189,15 +191,15 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onClick={() => handlePresetSelect(key)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
selectedPreset === key
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<span className="font-medium text-sm block">{theme.name}</span>
<span className="font-medium text-sm block text-neutral-900 dark:text-neutral-100">{theme.name}</span>
{theme.description && (
<span className="text-xs text-neutral-600 mt-1 block">{theme.description}</span>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">{theme.description}</span>
)}
</div>
{selectedPreset === key && (
@@ -207,15 +209,15 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="flex items-center gap-2 mt-3">
<div className="flex gap-1">
<div
className="w-5 h-5 rounded-full border border-neutral-200"
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.primaryColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200"
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.accentColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200"
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.backgroundColor }}
/>
</div>
@@ -233,7 +235,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Gallery Layout */}
{showGalleryLayouts && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Layout className="w-5 h-5" />
{t('branding.galleryLayout')}
</h3>
@@ -244,16 +246,21 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onClick={() => handleChange('galleryLayout', layout)}
className={`relative p-4 rounded-lg border-2 transition-all ${
localTheme.galleryLayout === layout
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700">
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
{layoutIcons[layout]}
</div>
<span className="font-medium text-sm capitalize">{layout}</span>
<span className="text-xs text-neutral-600 mt-1">
<span className="font-medium text-sm capitalize text-neutral-900 dark:text-neutral-100">
{layout}
{(layout === 'gallery-premium' || layout === 'gallery-story') && (
<span className="ml-1 text-xs text-amber-600 dark:text-amber-400">(Beta)</span>
)}
</span>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t(`branding.layoutDescriptions.${layout}`)}
</span>
</div>
@@ -266,19 +273,19 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Layout-specific settings */}
{localTheme.galleryLayout && (
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200">
<h4 className="font-medium text-sm text-neutral-700">{t('branding.layoutSettings')}</h4>
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-medium text-sm text-neutral-700 dark:text-neutral-300">{t('branding.layoutSettings')}</h4>
{/* Common settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.photoSpacing')}
</label>
<select
value={localTheme.gallerySettings?.spacing || 'normal'}
onChange={(e) => updateGallerySettings('spacing', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="tight">{t('branding.spacing.tight')}</option>
<option value="normal">{t('branding.spacing.normal')}</option>
@@ -287,13 +294,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.photoAnimation')}
</label>
<select
value={localTheme.gallerySettings?.photoAnimation || 'fade'}
onChange={(e) => updateGallerySettings('photoAnimation', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="none">{t('branding.animation.none')}</option>
<option value="fade">{t('branding.animation.fade')}</option>
@@ -306,12 +313,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Grid specific */}
{localTheme.galleryLayout === 'grid' && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.columns')}
</label>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-neutral-600">{t('branding.mobile')}</label>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
<Input
type="number"
min="1"
@@ -324,7 +331,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
/>
</div>
<div>
<label className="text-xs text-neutral-600">{t('branding.tablet')}</label>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
<Input
type="number"
min="2"
@@ -337,7 +344,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
/>
</div>
<div>
<label className="text-xs text-neutral-600">{t('branding.desktop')}</label>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
<Input
type="number"
min="3"
@@ -364,12 +371,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)}
className="rounded"
/>
<span className="text-sm font-medium text-neutral-700">{t('branding.enableAutoplay')}</span>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('branding.enableAutoplay')}</span>
</label>
</div>
{localTheme.gallerySettings?.carouselAutoplay && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.autoplayInterval')}
</label>
<Input
@@ -387,13 +394,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Timeline specific */}
{localTheme.galleryLayout === 'timeline' && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.groupPhotosBy')}
</label>
<select
value={localTheme.gallerySettings?.timelineGrouping || 'day'}
onChange={(e) => updateGallerySettings('timelineGrouping', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="day">{t('branding.grouping.day')}</option>
<option value="week">{t('branding.grouping.week')}</option>
@@ -406,20 +413,20 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{localTheme.galleryLayout === 'masonry' && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.masonryMode', 'Layout Mode')}
</label>
<select
value={localTheme.gallerySettings?.masonryMode || 'columns'}
onChange={(e) => updateGallerySettings('masonryMode', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="columns">{t('branding.masonryModeOptions.columns', 'Columns (Pinterest-style)')}</option>
<option value="rows">{t('branding.masonryModeOptions.rows', 'Rows (Custom justified)')}</option>
<option value="flickr">{t('branding.masonryModeOptions.flickr', 'Flickr (Battle-tested justified)')}</option>
<option value="justified">{t('branding.masonryModeOptions.justified', 'Google Photos (Knuth-Plass algorithm)')}</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{localTheme.gallerySettings?.masonryMode === 'columns'
? t('branding.masonryModeHint.columns', 'Pinterest-style vertical columns with varied heights')
: localTheme.gallerySettings?.masonryMode === 'flickr'
@@ -434,7 +441,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.targetRowHeight', 'Target Row Height')}
</label>
<Input
@@ -444,20 +451,20 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
value={localTheme.gallerySettings?.masonryRowHeight || 250}
onChange={(e) => updateGallerySettings('masonryRowHeight', parseInt(e.target.value))}
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')}
</p>
</div>
{/* Last row behavior - only for rows and flickr modes */}
{['rows', 'flickr'].includes(localTheme.gallerySettings?.masonryMode || '') && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.lastRowBehavior', 'Last Row Alignment')}
</label>
<select
value={localTheme.gallerySettings?.masonryLastRowBehavior || 'left'}
onChange={(e) => updateGallerySettings('masonryLastRowBehavior', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="left">{t('branding.lastRowOptions.left', 'Left aligned')}</option>
<option value="center">{t('branding.lastRowOptions.center', 'Centered')}</option>
@@ -477,11 +484,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* 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">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 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">
<p className="text-sm text-neutral-600 dark:text-neutral-400 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">
@@ -491,18 +498,18 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
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'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700">
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
{headerStyleIcons[style]}
</div>
<span className="font-medium text-sm capitalize">
<span className="font-medium text-sm capitalize text-neutral-900 dark:text-neutral-100">
{t(`branding.headerStyleOptions.${style}`, style)}
</span>
<span className="text-xs text-neutral-600 mt-1">
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t(`branding.headerStyleDescriptions.${style}`, '')}
</span>
</div>
@@ -515,11 +522,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* 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">
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-medium text-sm text-neutral-700 dark:text-neutral-300 mb-3">
{t('branding.heroDividerStyle', 'Divider Style')}
</h4>
<p className="text-xs text-neutral-600 mb-4">
<p className="text-xs text-neutral-600 dark:text-neutral-400 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">
@@ -529,8 +536,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
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'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center">
@@ -538,7 +545,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="h-8"></div>
{dividerStylePreviews[divider]}
</div>
<span className="text-xs font-medium capitalize">
<span className="text-xs font-medium capitalize text-neutral-900 dark:text-neutral-100">
{t(`branding.dividerOptions.${divider}`, divider)}
</span>
</div>
@@ -553,15 +560,141 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</Card>
)}
{/* Controls Style */}
{showGalleryLayouts && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<SlidersHorizontal className="w-5 h-5" />
{t('branding.controlsStyle', 'Controls Style')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.controlsStyleDescription', 'Choose how gallery filters and controls are displayed.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button
onClick={() => handleChange('controlsStyle', 'classic')}
className={`relative p-4 rounded-lg border-2 transition-all ${
(localTheme.controlsStyle || 'classic') === 'classic'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
<SlidersHorizontal className="w-6 h-6" />
</div>
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
{t('branding.controlsStyleOptions.classic', 'Classic')}
</span>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t('branding.controlsStyleDescriptions.classic', 'Inline filter bar below header')}
</span>
</div>
{(localTheme.controlsStyle || 'classic') === 'classic' && (
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
)}
</button>
<button
onClick={() => handleChange('controlsStyle', 'sidebar')}
className={`relative p-4 rounded-lg border-2 transition-all ${
localTheme.controlsStyle === 'sidebar'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
<Menu className="w-6 h-6" />
</div>
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
{t('branding.controlsStyleOptions.sidebar', 'Sidebar')}
</span>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t('branding.controlsStyleDescriptions.sidebar', 'Menu button opens sidebar with filters')}
</span>
</div>
{localTheme.controlsStyle === 'sidebar' && (
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
)}
</button>
</div>
{/* Info about hero header */}
{localTheme.headerStyle === 'hero' && localTheme.controlsStyle !== 'sidebar' && (
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p className="text-sm text-amber-800 dark:text-amber-200 flex items-center gap-2">
<Info className="w-4 h-4 flex-shrink-0" />
{t('branding.controlsStyleHeroWarning', 'Sidebar is recommended for hero headers to prevent controls appearing above the hero image.')}
</p>
</div>
)}
</Card>
)}
{/* Color Customization */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('branding.colors')}
</h3>
{/* Color Mode Selector */}
<div className="mb-6">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.colorMode', 'Color Mode')}
</label>
<div className="flex gap-2">
{(['light', 'dark', 'auto'] as const).map((mode) => (
<button
key={mode}
onClick={() => {
handleChange('colorMode', mode);
// When switching to dark, auto-populate dark defaults if colors are still light
if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) {
const updated = {
...localTheme,
colorMode: mode,
backgroundColor: '#0f0f0f',
textColor: '#e5e5e5',
surfaceColor: '#1a1a1a',
surfaceBorderColor: '#2e2e2e',
mutedTextColor: '#a3a3a3',
};
setLocalTheme(updated);
if (isPreviewMode) onChange({ ...updated, customCss });
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
const updated = {
...localTheme,
colorMode: mode,
backgroundColor: '#fafafa',
textColor: '#171717',
surfaceColor: '#ffffff',
surfaceBorderColor: '#e5e5e5',
mutedTextColor: '#737373',
};
setLocalTheme(updated);
if (isPreviewMode) onChange({ ...updated, customCss });
}
}}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
(localTheme.colorMode || 'light') === mode
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
: 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
{mode === 'light' ? t('branding.colorModeLight', 'Light') :
mode === 'dark' ? t('branding.colorModeDark', 'Dark') :
t('branding.colorModeAuto', 'Auto')}
</button>
))}
</div>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.primaryColor')}
</label>
<div className="flex gap-2">
@@ -569,7 +702,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
type="color"
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={localTheme.primaryColor || '#5C8762'}
@@ -581,7 +714,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.accentColor')}
</label>
<div className="flex gap-2">
@@ -589,7 +722,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
type="color"
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={localTheme.accentColor || '#22c55e'}
@@ -601,7 +734,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.backgroundColor')}
</label>
<div className="flex gap-2">
@@ -609,7 +742,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
type="color"
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={localTheme.backgroundColor || '#fafafa'}
@@ -621,7 +754,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.textColor')}
</label>
<div className="flex gap-2">
@@ -629,7 +762,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
type="color"
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={localTheme.textColor || '#171717'}
@@ -644,20 +777,20 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Typography & Style */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Type className="w-5 h-5" />
{t('branding.typographyAndStyle')}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.bodyFont')}
</label>
<select
value={localTheme.fontFamily || 'Inter, sans-serif'}
onChange={(e) => handleChange('fontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="Inter, sans-serif">Inter</option>
<option value="system-ui, sans-serif">System UI</option>
@@ -670,13 +803,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.headingFont')}
</label>
<select
value={localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'}
onChange={(e) => handleChange('headingFontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('branding.sameAsBody')}</option>
<option value="'Playfair Display', serif">Playfair Display</option>
@@ -690,13 +823,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Row 1: Font Size & Border Radius */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.fontSize')}
</label>
<select
value={localTheme.fontSize || 'normal'}
onChange={(e) => handleChange('fontSize', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="small">{t('branding.fontSizes.small')}</option>
<option value="normal">{t('branding.fontSizes.normal')}</option>
@@ -705,13 +838,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.borderRadius')}
</label>
<select
value={localTheme.borderRadius || 'md'}
onChange={(e) => handleChange('borderRadius', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="none">{t('branding.borderRadiusOptions.none')}</option>
<option value="sm">{t('branding.borderRadiusOptions.small')}</option>
@@ -724,13 +857,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Row 2: Shadow Style & Background Pattern */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.shadowStyle')}
</label>
<select
value={localTheme.shadowStyle || 'normal'}
onChange={(e) => handleChange('shadowStyle', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="none">{t('branding.shadowOptions.none')}</option>
<option value="subtle">{t('branding.shadowOptions.subtle')}</option>
@@ -740,13 +873,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.backgroundPattern')}
</label>
<select
value={localTheme.backgroundPattern || 'none'}
onChange={(e) => handleChange('backgroundPattern', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="none">{t('branding.backgroundOptions.none')}</option>
<option value="dots">{t('branding.backgroundOptions.dots')}</option>
@@ -761,11 +894,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* CSS Template Selector - only show if templates are provided */}
{cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<FileCode className="w-5 h-5" />
{t('branding.cssTemplate', 'CSS Template')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.cssTemplateDescription', 'Select a pre-built CSS template to apply application-wide styling to this gallery. Templates can be managed in Settings > CSS Templates.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -774,17 +907,17 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onClick={() => onCssTemplateChange(null)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
!cssTemplateId
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{t('branding.noTemplate', 'No Template')}</span>
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{t('branding.noTemplate', 'No Template')}</span>
{!cssTemplateId && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 mt-1 block">
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
{t('branding.noTemplateDescription', 'Use only theme settings without a CSS template')}
</span>
</button>
@@ -795,17 +928,17 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onClick={() => onCssTemplateChange(template.id)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
cssTemplateId === template.id
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{template.name}</span>
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{template.name}</span>
{cssTemplateId === template.id && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 mt-1 block">
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
{t('branding.templateSlot', 'Slot {{slot}}', { slot: template.slot_number })}
</span>
</button>
@@ -816,7 +949,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Event-specific Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
</h3>
@@ -834,13 +967,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</button>
{showCssInstructions && (
<div className="mt-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200 text-sm space-y-4">
<div className="mt-3 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm space-y-4">
{/* Available CSS Variables */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('branding.cssInstructions.variables', 'Theme CSS Variables')}
</h4>
<p className="text-neutral-600 mb-2">
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
@@ -855,10 +988,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Custom Gallery Layouts */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('branding.cssInstructions.layouts', 'Custom Gallery Layouts')}
</h4>
<p className="text-neutral-600 mb-2">
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
{t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
@@ -874,10 +1007,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Glassmorphism Example */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('branding.cssInstructions.glassEffect', 'Glassmorphism Effect')}
</h4>
<p className="text-neutral-600 mb-2">
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
{t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
@@ -892,9 +1025,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
{/* Tips */}
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-blue-800 text-xs">
<div className="text-blue-800 dark:text-blue-200 text-xs">
<strong>{t('branding.cssInstructions.tip', 'Tip')}:</strong>{' '}
{t('branding.cssInstructions.tipText', 'Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass.')}
</div>
@@ -919,9 +1052,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
}
}}
placeholder="/* Add custom CSS here */"
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
/>
<p className="mt-2 text-sm text-neutral-600">
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
{t('branding.customCSSHelp')}
</p>
</Card>
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, LayoutGrid, Check } from 'lucide-react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, LayoutGrid, Check, Columns, Film } from 'lucide-react';
import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview';
@@ -21,7 +21,9 @@ 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" />,
mosaic: <LayoutGrid className="w-4 h-4" />
mosaic: <LayoutGrid className="w-4 h-4" />,
'gallery-premium': <Columns className="w-4 h-4" />,
'gallery-story': <Film className="w-4 h-4" />
};
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
@@ -175,7 +177,12 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
<div className="text-neutral-700">
{layoutIcons[layout]}
</div>
<span className="text-xs capitalize">{layout}</span>
<span className="text-xs capitalize">
{layout}
{(layout === 'gallery-premium' || layout === 'gallery-story') && (
<span className="ml-0.5 text-amber-600">(Beta)</span>
)}
</span>
</div>
{(previewLayout || theme.galleryLayout || 'grid') === layout && (
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
@@ -513,9 +513,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
if (isLoading) {
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}>
{/* Header Skeleton */}
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
<header className="bg-surface border-b border-surface sticky top-0 z-40">
<div className="container py-4">
<div className="flex items-center justify-between">
<div>
@@ -550,9 +550,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<div className="min-h-screen bg-surface flex items-center justify-center">
<div className="text-center">
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
<p className="text-lg text-muted-theme">{t('gallery.failedToLoad')}</p>
<Button onClick={() => refetch()} className="mt-4">
{t('gallery.tryAgain')}
</Button>
@@ -561,7 +561,77 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
const showSidebar = theme.galleryLayout !== 'grid';
// Determine controls style (sidebar vs classic inline filter bar)
// If controlsStyle is explicitly set in theme, use that
// Otherwise: use sidebar for non-grid layouts OR hero headers (prevents filter bar above hero)
const headerStyle = data?.event?.header_style || theme.headerStyle || 'standard';
const isHeroHeader = headerStyle === 'hero';
const controlsStyle = theme.controlsStyle;
const showSidebar = controlsStyle
? controlsStyle === 'sidebar'
: (theme.galleryLayout !== 'grid' || isHeroHeader);
// Full-page layouts (gallery-premium, gallery-story) have their own integrated UI
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
// For full-page layouts, render just the PhotoGridWithLayouts without any wrappers
if (isFullPageLayout) {
return (
<>
<PhotoGridWithLayouts
photos={filteredPhotos}
slug={slug}
categoryId={selectedCategoryId}
onFeedbackChange={() => refetch()}
heroPhotoOverride={staticHeroPhoto}
feedbackEnabled={feedbackEnabled}
feedbackOptions={{
allowLikes: !!feedbackSettings?.allow_likes,
allowFavorites: !!feedbackSettings?.allow_favorites,
allowRatings: !!feedbackSettings?.allow_ratings,
allowComments: !!feedbackSettings?.allow_comments,
requireNameEmail: !!feedbackSettings?.require_name_email,
}}
isSelectionMode={isSelectionMode}
selectedPhotos={selectedPhotos}
onSelectionChange={setSelectedPhotos}
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
showSelectionControls={false}
eventName={event.event_name}
eventLogo={data?.event?.hero_logo_url || brandingSettings?.logo_url}
eventDate={event.event_date}
expiresAt={event.expires_at}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
useCanvasRendering={useCanvasRendering}
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'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
onLogout={logout}
/>
{/* Upload Modal for full-page layouts */}
{showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && (
<UserPhotoUpload
eventId={data?.event?.id || event?.id}
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
onUploadComplete={() => {
setShowUploadModal(false);
window.location.reload();
}}
onClose={() => setShowUploadModal(false)}
/>
)}
</>
);
}
return (
<>
@@ -18,6 +18,8 @@ import {
CarouselGalleryLayout,
TimelineGalleryLayout,
MosaicGalleryLayout,
GalleryPremiumLayout,
GalleryStoryLayout,
} from './layouts';
import { HeroHeader } from './HeroHeader';
import type { HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
@@ -62,6 +64,8 @@ interface PhotoGridWithLayoutsProps {
heroDividerStyle?: HeroDividerStyle;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
// Logout callback for full-page layouts
onLogout?: () => void;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -92,7 +96,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoPosition = 'top',
headerStyle,
heroDividerStyle = 'wave',
heroImageAnchor = 'center'
heroImageAnchor = 'center',
onLogout
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -186,7 +191,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
<p className="text-muted-theme">{t('gallery.noPhotosFound')}</p>
</div>
);
}
@@ -219,6 +224,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoVisible,
heroLogoSize,
heroLogoPosition,
onLogout,
};
// Determine if we should show hero header (decoupled from layout)
@@ -239,14 +245,23 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
case 'mosaic':
LayoutComponent = MosaicGalleryLayout;
break;
case 'gallery-premium':
LayoutComponent = GalleryPremiumLayout;
break;
case 'gallery-story':
LayoutComponent = GalleryStoryLayout;
break;
default:
LayoutComponent = GridGalleryLayout;
}
// Gallery Premium and Gallery Story layouts have their own integrated hero/header
const isFullPageLayout = galleryLayout === 'gallery-premium' || galleryLayout === 'gallery-story';
return (
<>
{/* Hero Header - shown when headerStyle is 'hero' */}
{showHeroHeader && (
{/* Hero Header - shown when headerStyle is 'hero' (skip for full-page layouts with integrated hero) */}
{showHeroHeader && !isFullPageLayout && (
<HeroHeader
photos={photos}
slug={slug}
@@ -267,8 +282,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
/>
)}
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
{/* Selection Mode Controls - Not shown for carousel, full-page layouts, or when controls are hidden */}
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && !isFullPageLayout && (
<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
@@ -297,7 +312,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
{isSelectionMode && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-neutral-600">
<span className="text-xs sm:text-sm text-muted-theme">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<div className="flex items-center gap-2 flex-wrap">
@@ -328,8 +343,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
{/* Render the selected layout */}
<LayoutComponent {...layoutProps} />
{/* Lightbox */}
{selectedPhotoIndex !== null && (
{/* Lightbox - skip for full-page layouts which have their own lightbox */}
{selectedPhotoIndex !== null && !isFullPageLayout && (
<PhotoLightbox
photos={photos}
initialIndex={selectedPhotoIndex}
@@ -29,6 +29,8 @@ export interface BaseGalleryLayoutProps {
allowComments?: boolean;
requireNameEmail?: boolean;
};
// Logout callback for full-page layouts
onLogout?: () => void;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
@@ -0,0 +1,410 @@
/* Gallery Premium Layout - Light, elegant aesthetic */
.gallery-premium-layout {
min-height: 100vh;
background-color: #ffffff;
color: #18181b;
font-family: Inter, sans-serif;
}
.gallery-premium-layout ::selection {
background-color: #e4e4e7;
}
/* Hero Section */
.gallery-premium-hero {
position: relative;
height: 60vh;
width: 100%;
overflow: hidden;
}
.gallery-premium-hero-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
}
.gallery-premium-hero-overlay {
position: absolute;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.gallery-premium-hero-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
padding: 1rem;
text-align: center;
}
.gallery-premium-hero-date {
margin-bottom: 1rem;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.2em;
text-transform: uppercase;
opacity: 0.8;
color: white !important;
}
.gallery-premium-hero-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 3rem;
margin-bottom: 1.5rem;
letter-spacing: -0.025em;
line-height: 1.1;
color: white !important;
}
@media (min-width: 768px) {
.gallery-premium-hero-title {
font-size: 4.5rem;
}
}
@media (min-width: 1024px) {
.gallery-premium-hero-title {
font-size: 6rem;
}
}
.gallery-premium-hero-location {
font-size: 0.875rem;
font-weight: 300;
letter-spacing: 0.25em;
opacity: 0.9;
}
/* Sticky Navigation */
.gallery-premium-nav {
position: sticky;
top: 0;
z-index: 40;
width: 100%;
border-bottom: 1px solid #f4f4f5;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
}
.gallery-premium-nav-inner {
margin: 0 auto;
display: flex;
height: 4rem;
max-width: 1600px;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
}
@media (min-width: 640px) {
.gallery-premium-nav-inner {
padding: 0 2rem;
}
}
.gallery-premium-nav-categories {
display: none;
align-items: center;
gap: 2rem;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.05em;
color: #71717a;
}
@media (min-width: 768px) {
.gallery-premium-nav-categories {
display: flex;
}
}
.gallery-premium-nav-category {
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.2s ease;
}
.gallery-premium-nav-category:hover,
.gallery-premium-nav-category.active {
color: #18181b;
}
.gallery-premium-nav-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.125rem;
}
@media (min-width: 768px) {
.gallery-premium-nav-title {
display: none;
}
}
.gallery-premium-nav-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 768px) {
.gallery-premium-nav-actions {
gap: 1rem;
}
}
.gallery-premium-nav-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: none;
border: none;
border-radius: 9999px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.gallery-premium-nav-btn:hover {
background-color: #f4f4f5;
}
/* Selection Mode Controls */
.gallery-premium-selection-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: 0.5rem;
}
.gallery-premium-selection-btn {
font-size: 0.75rem;
font-weight: 400;
padding: 0.375rem 0.75rem;
background: none;
border: 1px solid #e4e4e7;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.2s ease;
}
.gallery-premium-selection-btn:hover {
background-color: #f4f4f5;
}
.gallery-premium-download-btn {
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 1rem;
background-color: #18181b;
color: white;
border: none;
border-radius: 9999px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.gallery-premium-download-btn:hover {
background-color: #27272a;
}
/* Main Gallery */
.gallery-premium-main {
margin: 0 auto;
max-width: 1600px;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.gallery-premium-main {
padding: 2rem;
}
}
/* Photo Card */
.gallery-premium-photo-card {
position: relative;
cursor: pointer;
overflow: hidden;
background-color: #f4f4f5;
border-radius: 0.125rem;
}
.gallery-premium-photo-card img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease-out;
will-change: transform;
}
.gallery-premium-photo-card:hover img {
transform: scale(1.05);
}
.gallery-premium-photo-card.selected img {
transform: scale(0.95);
opacity: 0.9;
}
/* Photo Overlay */
.gallery-premium-photo-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent, transparent, rgba(0, 0, 0, 0.3));
opacity: 0;
transition: opacity 0.3s ease;
}
.gallery-premium-photo-card:hover .gallery-premium-photo-overlay {
opacity: 1;
}
/* Selection Checkbox */
.gallery-premium-checkbox {
position: absolute;
top: 0.75rem;
left: 0.75rem;
z-index: 10;
display: flex;
height: 1.5rem;
width: 1.5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.5);
background-color: rgba(0, 0, 0, 0.2);
color: white;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s ease;
cursor: pointer;
opacity: 0;
}
.gallery-premium-photo-card:hover .gallery-premium-checkbox,
.gallery-premium-checkbox.visible {
opacity: 1;
}
.gallery-premium-checkbox:hover {
background-color: rgba(0, 0, 0, 0.4);
transform: scale(1.1);
}
.gallery-premium-checkbox.selected {
background-color: white;
color: #18181b;
border-color: white;
}
/* Like Button */
.gallery-premium-like-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 10;
padding: 0.5rem;
color: white;
background: none;
border: none;
cursor: pointer;
transition: all 0.2s ease;
opacity: 0;
}
.gallery-premium-photo-card:hover .gallery-premium-like-btn,
.gallery-premium-like-btn.liked {
opacity: 1;
}
.gallery-premium-like-btn:hover {
transform: scale(1.1);
color: #ef4444;
}
.gallery-premium-like-btn.liked {
color: #ef4444;
}
.gallery-premium-like-btn svg {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
}
/* Selection Border */
.gallery-premium-selection-border {
position: absolute;
inset: 0;
border: 3px solid rgba(255, 255, 255, 0.8);
pointer-events: none;
border-radius: 0.125rem;
}
/* Feedback Indicators */
.gallery-premium-feedback {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
display: flex;
align-items: center;
gap: 0.25rem;
z-index: 10;
}
.gallery-premium-feedback-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Footer */
.gallery-premium-footer {
margin-top: 5rem;
border-top: 1px solid #f4f4f5;
padding: 3rem 0;
text-align: center;
font-size: 0.875rem;
color: #a1a1aa;
}
.gallery-premium-footer p {
margin: 0;
}
.gallery-premium-footer p:first-child {
margin-bottom: 0.5rem;
}
/* Animation */
@keyframes galleryPremiumFadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.gallery-premium-animate-in {
animation: galleryPremiumFadeIn 0.4s ease forwards;
}
@@ -0,0 +1,538 @@
import React, { useState, useMemo, useCallback } from 'react';
import { MasonryPhotoAlbum } from 'react-photo-album';
import 'react-photo-album/masonry.css';
import Lightbox from 'yet-another-react-lightbox';
import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails';
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
import Download from 'yet-another-react-lightbox/plugins/download';
import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/thumbnails.css';
import { motion, AnimatePresence } from 'framer-motion';
import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useInView } from 'react-intersection-observer';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { AuthenticatedImage } from '../../common';
import { feedbackService } from '../../../services/feedback.service';
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
import { galleryService } from '../../../services/gallery.service';
import { analyticsService } from '../../../services/analytics.service';
import { useDownloadPhoto } from '../../../hooks/useGallery';
import { toast } from 'react-toastify';
import './GalleryPremiumLayout.css';
interface PhotoCardProps {
photo: Photo;
width: number;
height: number;
onClick: () => void;
onLike: (e: React.MouseEvent) => void;
onSelect: (e: React.MouseEvent) => void;
isSelected: boolean;
isSelectionMode: boolean;
isLiked: boolean;
slug: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
index: number;
}
const PhotoCard: React.FC<PhotoCardProps> = ({
photo,
width,
height: _height,
onClick,
onLike,
onSelect,
isSelected,
isSelectionMode,
isLiked,
slug,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
index
}) => {
// Note: height is passed but not used as we maintain aspect ratio via width
void _height;
const { ref, inView } = useInView({
triggerOnce: true,
threshold: 0.1,
});
const likeCount = photo.like_count ?? 0;
const averageRating = photo.average_rating ?? 0;
const commentCount = photo.comment_count ?? 0;
return (
<motion.div
ref={ref}
className={`gallery-premium-photo-card group ${isSelected ? 'selected' : ''}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
initial={{ opacity: 0, y: 20 }}
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.4, delay: Math.min(index * 0.05, 0.3) }}
onClick={onClick}
data-testid={`photo-card-${photo.id}`}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
style={{ width, height: 'auto' }}
className="w-full h-auto object-cover"
loading="lazy"
isGallery={true}
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay Gradient */}
<div className="gallery-premium-photo-overlay" />
{/* Selection Checkbox */}
<button
onClick={onSelect}
className={`gallery-premium-checkbox ${isSelected || isSelectionMode ? 'visible' : ''} ${isSelected ? 'selected' : ''}`}
>
{isSelected && <Check className="w-3.5 h-3.5" strokeWidth={3} />}
</button>
{/* Like Button */}
<button
onClick={onLike}
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
>
<Heart
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
/>
</button>
{/* Selection Border */}
{isSelected && (
<div className="gallery-premium-selection-border" />
)}
{/* Feedback Indicators */}
{feedbackEnabled && (likeCount > 0 || averageRating > 0 || commentCount > 0 || isLiked) && (
<div className="gallery-premium-feedback">
{(likeCount > 0 || isLiked) && (
<span className="gallery-premium-feedback-indicator" title="Liked">
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
</span>
)}
{averageRating > 0 && (
<span className="gallery-premium-feedback-indicator" title="Rated">
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
</span>
)}
{commentCount > 0 && (
<span className="gallery-premium-feedback-indicator" title="Commented">
<MessageSquare className="w-3.5 h-3.5 text-blue-500" fill="currentColor" />
</span>
)}
</div>
)}
</motion.div>
);
};
interface GalleryPremiumLayoutProps extends BaseGalleryLayoutProps {
heroPhotoOverride?: Photo | null;
}
export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
photos,
slug,
onPhotoClick: _onPhotoClick,
onOpenPhotoWithFeedback: _onOpenPhotoWithFeedback,
onFeedbackChange,
onDownload: _onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
eventName,
eventDate,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
onLogout
}) => {
// These props are passed by parent but we use our own lightbox, so mark as intentionally unused
void _onPhotoClick;
void _onOpenPhotoWithFeedback;
void _onDownload;
const { t } = useTranslation();
const downloadPhotoMutation = useDownloadPhoto();
const [lightboxIndex, setLightboxIndex] = useState(-1);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
// Get unique categories from photos
const categories = useMemo(() => {
const cats = new Set<string>();
photos.forEach(photo => {
if (photo.category_name) {
cats.add(photo.category_name);
}
});
return Array.from(cats);
}, [photos]);
// Filter photos by active category
const filteredPhotos = useMemo(() => {
if (!activeCategory) return photos;
return photos.filter(photo => photo.category_name === activeCategory);
}, [photos, activeCategory]);
// Get hero photo
const heroPhoto = heroPhotoOverride || photos[0];
// Convert photos to react-photo-album format
const albumPhotos = useMemo(() => {
return filteredPhotos.map(photo => ({
src: photo.thumbnail_url || photo.url,
width: photo.width || 1200,
height: photo.height || 800,
key: String(photo.id),
// Keep original photo data
_photo: photo
}));
}, [filteredPhotos]);
// Lightbox slides
const slides = useMemo(() => {
return filteredPhotos.map(photo => ({
src: photo.url,
alt: photo.filename,
width: photo.width || 1200,
height: photo.height || 800,
download: allowDownloads ? photo.url : undefined
}));
}, [filteredPhotos, allowDownloads]);
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingLikePhotoId(photo.id);
setShowIdentityModal(true);
return;
}
// Optimistic update
setLikedPhotoIds(prev => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
try {
await feedbackService.submitFeedback(slug, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
onFeedbackChange?.();
} catch (err) {
console.warn('Like submit failed', err);
}
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange]);
const handleIdentitySubmit = useCallback(async (name: string, email: string) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingLikePhotoId) {
setLikedPhotoIds(prev => {
const next = new Set(prev);
next.add(pendingLikePhotoId);
return next;
});
try {
await feedbackService.submitFeedback(slug, String(pendingLikePhotoId), {
feedback_type: 'like',
guest_name: name,
guest_email: email,
});
onFeedbackChange?.();
} catch (err) {
console.warn('Like submit failed', err);
}
setPendingLikePhotoId(null);
}
}, [slug, pendingLikePhotoId, onFeedbackChange]);
const handleSelectAll = useCallback(() => {
if (selectedPhotos.size === filteredPhotos.length) {
// Deselect all
filteredPhotos.forEach(p => onPhotoSelect?.(p.id));
} else {
// Select all
filteredPhotos.forEach(p => {
if (!selectedPhotos.has(p.id)) {
onPhotoSelect?.(p.id);
}
});
}
}, [selectedPhotos, filteredPhotos, onPhotoSelect]);
const handleDownloadSelected = useCallback(async () => {
if (selectedPhotos.size === 0) return;
const ids = Array.from(selectedPhotos);
toast.info(t('gallery.downloading', { count: ids.length }));
try {
await galleryService.downloadSelectedPhotos(slug, ids);
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
} catch {
toast.error(t('gallery.downloadError'));
}
}, [selectedPhotos, slug, t]);
const handleDownloadFromLightbox = useCallback((slide: { src?: string }) => {
if (!allowDownloads || !slide.src) return;
const photo = filteredPhotos.find(p => p.url === slide.src);
if (photo) {
analyticsService.trackDownload(photo.id, slug, false);
downloadPhotoMutation.mutate({
slug,
photoId: photo.id,
filename: photo.filename,
});
}
}, [allowDownloads, filteredPhotos, slug, downloadPhotoMutation]);
const formattedDate = eventDate ? new Date(eventDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: '2-digit'
}) : null;
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">{t('gallery.noPhotosFound')}</p>
</div>
);
}
return (
<div className="gallery-premium-layout">
{/* Hero Section */}
<div className="gallery-premium-hero">
<div
className="gallery-premium-hero-bg"
style={{
backgroundImage: heroPhoto ? `url(${heroPhoto.thumbnail_url || heroPhoto.url})` : undefined
}}
/>
<div className="gallery-premium-hero-overlay" />
<div className="gallery-premium-hero-content">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
>
{formattedDate && (
<p className="gallery-premium-hero-date">
{formattedDate}
</p>
)}
<h1 className="gallery-premium-hero-title">
{eventName || t('gallery.photoGallery')}
</h1>
</motion.div>
</div>
</div>
{/* Sticky Navigation */}
<div className="gallery-premium-nav">
<div className="gallery-premium-nav-inner">
<div className="gallery-premium-nav-categories">
<button
className={`gallery-premium-nav-category ${!activeCategory ? 'active' : ''}`}
onClick={() => setActiveCategory(null)}
>
{t('gallery.allPhotos', 'All Photos')}
</button>
{categories.map(cat => (
<button
key={cat}
className={`gallery-premium-nav-category ${activeCategory === cat ? 'active' : ''}`}
onClick={() => setActiveCategory(cat)}
>
{cat}
</button>
))}
</div>
<div className="gallery-premium-nav-title">
{eventName ? eventName.split(' ').map(w => w[0]).join('').slice(0, 3) : 'Gallery'}
</div>
<div className="gallery-premium-nav-actions">
<AnimatePresence>
{isSelectionMode && selectedPhotos.size > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="gallery-premium-selection-controls"
>
<button
className="gallery-premium-selection-btn"
onClick={handleSelectAll}
>
{selectedPhotos.size === filteredPhotos.length ? t('gallery.deselectAll') : t('gallery.selectAll')}
</button>
<button
className="gallery-premium-download-btn"
onClick={handleDownloadSelected}
>
<Package className="w-3 h-3 mr-1 inline" />
{t('common.download')} ({selectedPhotos.size})
</button>
</motion.div>
)}
</AnimatePresence>
{feedbackEnabled && feedbackOptions?.allowLikes && (
<button className="gallery-premium-nav-btn" title={t('gallery.favorites', 'Favorites')}>
<Heart className="w-4 h-4" />
</button>
)}
{allowDownloads && (
<button
className="gallery-premium-nav-btn"
title={t('common.downloadAll', 'Download All')}
onClick={() => {
filteredPhotos.forEach(p => {
if (!selectedPhotos.has(p.id)) {
onPhotoSelect?.(p.id);
}
});
}}
>
<DownloadIcon className="w-4 h-4" />
</button>
)}
{onLogout && (
<button
className="gallery-premium-nav-btn"
title={t('common.logout', 'Logout')}
onClick={onLogout}
>
<LogOut className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
{/* Main Gallery */}
<main className="gallery-premium-main">
<MasonryPhotoAlbum
photos={albumPhotos}
render={{
photo: (_props, { photo, width, height }) => {
const originalPhoto = (photo as any)._photo as Photo;
const photoIndex = filteredPhotos.findIndex(p => p.id === originalPhoto.id);
return (
<PhotoCard
photo={originalPhoto}
width={width}
height={height}
onClick={() => setLightboxIndex(photoIndex)}
onLike={(e) => handleLike(originalPhoto, e)}
onSelect={(e) => {
e.stopPropagation();
onPhotoSelect?.(originalPhoto.id);
}}
isSelected={selectedPhotos.has(originalPhoto.id)}
isSelectionMode={isSelectionMode}
isLiked={likedPhotoIds.has(originalPhoto.id) || (originalPhoto.like_count ?? 0) > 0}
slug={slug}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
index={photoIndex}
/>
);
}
}}
columns={(containerWidth) => {
if (containerWidth < 640) return 1;
if (containerWidth < 1024) return 2;
return 3;
}}
spacing={16}
/>
</main>
{/* Footer */}
<footer className="gallery-premium-footer">
<p>{t('gallery.poweredBy', 'Powered by PicPeak')}</p>
<p>© {new Date().getFullYear()} {t('gallery.allRightsReserved', 'All rights reserved')}</p>
</footer>
{/* Lightbox */}
<Lightbox
open={lightboxIndex >= 0}
close={() => setLightboxIndex(-1)}
index={lightboxIndex}
slides={slides}
plugins={allowDownloads ? [Thumbnails, Zoom, Fullscreen, Download] : [Thumbnails, Zoom, Fullscreen]}
animation={{ fade: 300, swipe: 250 }}
styles={{
container: { backgroundColor: 'rgba(0, 0, 0, 0.95)' },
thumbnail: { border: 'none' }
}}
render={{
buttonPrev: slides.length <= 1 ? () => null : undefined,
buttonNext: slides.length <= 1 ? () => null : undefined,
}}
controller={{ closeOnBackdropClick: true }}
download={{
download: ({ slide }) => {
handleDownloadFromLightbox(slide);
}
}}
/>
{/* Identity Modal */}
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingLikePhotoId(null); }}
onSubmit={handleIdentitySubmit}
feedbackType="like"
/>
</div>
);
};
@@ -0,0 +1,743 @@
/* Gallery Story Layout - Dark cinematic aesthetic with gold accents */
.gallery-story-layout {
--story-background: #0d0d0d;
--story-foreground: #f2f2f2;
--story-primary: #c9a961;
--story-primary-foreground: #0d0d0d;
--story-muted: #1a1a1a;
--story-muted-foreground: #a3a3a3;
--story-border: #262626;
min-height: 100vh;
background-color: var(--story-background);
color: var(--story-foreground);
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
}
.gallery-story-layout ::selection {
background-color: var(--story-primary);
color: var(--story-primary-foreground);
}
.gallery-story-layout h1,
.gallery-story-layout h2,
.gallery-story-layout h3,
.gallery-story-layout h4,
.gallery-story-layout h5,
.gallery-story-layout h6 {
font-family: 'Playfair Display', Georgia, serif;
}
/* Custom Scrollbar */
.gallery-story-layout ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.gallery-story-layout ::-webkit-scrollbar-track {
background: transparent;
}
.gallery-story-layout ::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
.gallery-story-layout ::-webkit-scrollbar-thumb:hover {
background: #525252;
}
/* Navigation */
.story-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
transition: all 0.3s ease;
}
.story-nav.scrolled {
background-color: rgba(13, 13, 13, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.story-nav-logo {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.25em;
}
.story-nav-actions {
display: flex;
align-items: center;
gap: 1.5rem;
}
.story-nav-search {
display: none;
align-items: center;
gap: 0.5rem;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 9999px;
padding: 0.375rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
@media (min-width: 768px) {
.story-nav-search {
display: flex;
}
}
.story-nav-search input {
background: transparent;
border: none;
outline: none;
font-size: 0.875rem;
width: 8rem;
color: var(--story-foreground);
transition: width 0.2s ease;
}
.story-nav-search input:focus {
width: 12rem;
}
.story-nav-search input::placeholder {
color: var(--story-muted-foreground);
}
.story-nav-btn {
position: relative;
padding: 0.5rem;
background: none;
border: none;
color: var(--story-foreground);
cursor: pointer;
transition: color 0.2s ease;
}
.story-nav-btn:hover {
color: var(--story-primary);
}
.story-nav-favorites-count {
position: absolute;
top: -0.25rem;
right: -0.25rem;
display: flex;
height: 0.75rem;
width: 0.75rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: #ef4444;
font-size: 0.5rem;
font-weight: 700;
color: white;
}
/* Hero Section */
.story-hero {
position: relative;
height: 100vh;
width: 100%;
overflow: hidden;
}
.story-hero-bg {
position: absolute;
inset: 0;
}
.story-hero-bg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.story-hero-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.3), transparent, rgba(0, 0, 0, 0.8));
}
.story-hero-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: white;
padding: 1rem;
}
.story-hero-date {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.2em;
opacity: 0.8;
}
.story-hero-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 3.75rem;
font-style: italic;
line-height: 1.1;
mix-blend-mode: overlay;
}
@media (min-width: 768px) {
.story-hero-title {
font-size: 6rem;
}
}
@media (min-width: 1024px) {
.story-hero-title {
font-size: 8rem;
}
}
.story-hero-stats {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
font-size: 0.875rem;
font-weight: 500;
opacity: 0.7;
}
.story-hero-stats-divider {
height: 1px;
width: 3rem;
background-color: rgba(255, 255, 255, 0.5);
}
/* Scroll Indicator */
.story-scroll-indicator {
position: absolute;
bottom: 3rem;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.5);
}
/* Scene Section */
.story-scene {
padding: 6rem 1rem;
max-width: 1800px;
margin: 0 auto;
}
@media (min-width: 768px) {
.story-scene {
padding: 6rem 2rem;
}
}
.story-scene.full-width {
padding-left: 0;
padding-right: 0;
max-width: 100%;
}
.story-scene-header {
margin-bottom: 3rem;
space-y: 0.5rem;
}
.story-scene-title {
font-size: 2.25rem;
color: var(--story-primary);
}
@media (min-width: 768px) {
.story-scene-title {
font-size: 3rem;
}
}
.story-scene-subtitle {
color: var(--story-muted-foreground);
font-weight: 300;
letter-spacing: 0.05em;
}
/* Gallery Grid */
.story-gallery-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 0.25rem;
grid-auto-rows: 300px;
}
@media (min-width: 768px) {
.story-gallery-grid {
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
grid-auto-rows: 400px;
}
}
.story-gallery-grid-featured {
grid-column: span 1;
grid-row: span 1;
}
@media (min-width: 768px) {
.story-gallery-grid-featured {
grid-column: span 2;
grid-row: span 2;
}
}
/* Photo Card */
.story-photo-card {
position: relative;
overflow: hidden;
border-radius: 0.125rem;
background-color: var(--story-muted);
}
.story-photo-card a {
display: block;
width: 100%;
height: 100%;
}
.story-photo-card img {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.7s ease-out;
will-change: transform;
}
.story-photo-card:hover img {
transform: scale(1.05);
}
.story-photo-card-overlay {
position: absolute;
inset: 0;
background-color: transparent;
transition: background-color 0.3s ease;
pointer-events: none;
}
.story-photo-card:hover .story-photo-card-overlay {
background-color: rgba(0, 0, 0, 0.2);
}
.story-photo-card-actions {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
opacity: 0;
transform: translateY(0.5rem);
transition: all 0.3s ease;
}
.story-photo-card:hover .story-photo-card-actions {
opacity: 1;
transform: translateY(0);
}
.story-photo-card-btn {
padding: 0.5rem;
border-radius: 9999px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.3s ease;
background-color: rgba(0, 0, 0, 0.3);
color: white;
border: none;
cursor: pointer;
}
.story-photo-card-btn:hover {
transform: scale(1.1);
background-color: white;
color: #0d0d0d;
}
.story-photo-card-btn.favorite {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.story-photo-card-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), transparent);
opacity: 0;
transition: opacity 0.3s ease;
}
.story-photo-card:hover .story-photo-card-caption {
opacity: 1;
}
.story-photo-card-caption p {
color: white;
font-size: 0.875rem;
font-weight: 500;
font-family: 'Playfair Display', Georgia, serif;
letter-spacing: 0.05em;
}
/* Carousel */
.story-carousel {
width: 100%;
}
.story-carousel .swiper {
width: 100%;
padding: 0 1rem 3rem 1rem;
}
@media (min-width: 768px) {
.story-carousel .swiper {
padding: 0 2rem 3rem 2rem;
}
}
.story-carousel .swiper-slide {
width: auto;
}
.story-carousel-item {
width: 300px;
height: 400px;
}
@media (min-width: 768px) {
.story-carousel-item {
width: 400px;
height: 500px;
}
}
/* Feedback Sheet */
.story-feedback-sheet {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: 28rem;
background-color: rgba(13, 13, 13, 0.95);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-left: 1px solid rgba(255, 255, 255, 0.1);
z-index: 60;
transform: translateX(100%);
transition: transform 0.3s ease;
padding: 1.5rem;
overflow-y: auto;
}
.story-feedback-sheet.open {
transform: translateX(0);
}
.story-feedback-sheet-header {
margin-bottom: 1.5rem;
}
.story-feedback-sheet-title {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.story-feedback-sheet-description {
color: var(--story-muted-foreground);
font-size: 0.875rem;
}
.story-feedback-rating {
margin-bottom: 2rem;
}
.story-feedback-rating-label {
display: block;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--story-muted-foreground);
margin-bottom: 0.5rem;
}
.story-feedback-rating-stars {
display: flex;
gap: 0.5rem;
}
.story-feedback-rating-star {
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: transform 0.2s ease;
}
.story-feedback-rating-star:hover {
transform: scale(1.1);
}
.story-feedback-rating-star.active svg {
fill: var(--story-primary);
color: var(--story-primary);
}
.story-feedback-rating-star svg {
color: var(--story-muted-foreground);
transition: color 0.2s ease;
}
.story-feedback-rating-star:hover svg {
color: rgba(201, 169, 97, 0.5);
}
.story-feedback-comments {
max-height: 40vh;
overflow-y: auto;
padding-right: 0.5rem;
margin-bottom: 2rem;
}
.story-feedback-comments-label {
display: block;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--story-muted-foreground);
margin-bottom: 1rem;
}
.story-feedback-comment {
display: flex;
gap: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.story-feedback-comment-avatar {
width: 2rem;
height: 2rem;
border-radius: 9999px;
background-color: var(--story-muted);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.story-feedback-comment-content {
flex: 1;
}
.story-feedback-comment-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.25rem;
}
.story-feedback-comment-author {
font-weight: 500;
}
.story-feedback-comment-date {
font-size: 0.625rem;
color: var(--story-muted-foreground);
}
.story-feedback-comment-text {
color: var(--story-muted-foreground);
line-height: 1.5;
}
.story-feedback-empty {
font-size: 0.875rem;
color: rgba(163, 163, 163, 0.5);
font-style: italic;
}
.story-feedback-form {
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.story-feedback-textarea {
width: 100%;
min-height: 100px;
padding: 0.75rem;
background-color: rgba(26, 26, 26, 0.5);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 0.25rem;
color: var(--story-foreground);
font-size: 0.875rem;
resize: none;
margin-bottom: 0.75rem;
}
.story-feedback-textarea:focus {
outline: none;
border-color: rgba(201, 169, 97, 0.3);
}
.story-feedback-textarea::placeholder {
color: var(--story-muted-foreground);
}
.story-feedback-submit {
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--story-primary);
color: var(--story-primary-foreground);
border: none;
border-radius: 0.125rem;
font-weight: 500;
letter-spacing: 0.05em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: background-color 0.2s ease;
}
.story-feedback-submit:hover {
background-color: white;
}
.story-feedback-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.story-feedback-close {
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem;
background: none;
border: none;
color: var(--story-muted-foreground);
cursor: pointer;
transition: color 0.2s ease;
}
.story-feedback-close:hover {
color: var(--story-foreground);
}
/* Scroll to Top */
.story-scroll-to-top {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 50;
padding: 0.75rem;
border-radius: 9999px;
background-color: rgba(201, 169, 97, 0.9);
color: #0d0d0d;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
transition: all 0.3s ease;
}
.story-scroll-to-top:hover {
background-color: var(--story-primary);
transform: scale(1.1);
}
.story-scroll-to-top:active {
transform: scale(0.95);
}
/* Footer */
.story-footer {
padding: 6rem 1rem;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.story-footer-title {
font-size: 2.25rem;
margin-bottom: 1.5rem;
}
.story-footer-text {
color: var(--story-muted-foreground);
max-width: 28rem;
margin: 0 auto 3rem auto;
}
.story-footer-btn {
padding: 0.75rem 2rem;
background-color: var(--story-primary);
color: var(--story-primary-foreground);
font-weight: 500;
letter-spacing: 0.05em;
border: none;
border-radius: 0.125rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.story-footer-btn:hover {
background-color: white;
}
/* Backdrop Overlay */
.story-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 55;
}
/* Animation */
@keyframes storyFadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.story-animate-fade-in {
animation: storyFadeIn 0.8s ease-out forwards;
}
@@ -0,0 +1,376 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { Search, Heart, Menu, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { feedbackService } from '../../../services/feedback.service';
import { galleryService } from '../../../services/gallery.service';
import { analyticsService } from '../../../services/analytics.service';
import { toast } from 'react-toastify';
import {
StoryHero,
StoryScene,
StoryPhotoCard,
StoryCarousel,
StoryFeedbackSheet,
StoryScrollToTop
} from './story';
import './GalleryStoryLayout.css';
interface PhotosByCategory {
[categoryName: string]: Photo[];
}
interface CategoryScene {
id: string;
title: string;
subtitle?: string;
type: 'grid' | 'carousel';
photos: Photo[];
}
interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps {
heroPhotoOverride?: Photo | null;
}
export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
photos,
slug,
onPhotoClick: _onPhotoClick,
onOpenPhotoWithFeedback: _onOpenPhotoWithFeedback,
onFeedbackChange,
onDownload: _onDownload,
selectedPhotos: _selectedPhotos,
isSelectionMode: _isSelectionMode,
onPhotoSelect: _onPhotoSelect,
eventName,
eventDate,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
onLogout
}) => {
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
void _onPhotoClick;
void _onOpenPhotoWithFeedback;
void _onDownload;
void _selectedPhotos;
void _isSelectionMode;
void _onPhotoSelect;
const { t } = useTranslation();
const [scrolled, setScrolled] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [favorites, setFavorites] = useState<Set<number>>(new Set());
const [selectedPhotoForFeedback, setSelectedPhotoForFeedback] = useState<Photo | null>(null);
const [comments, setComments] = useState<Record<number, Array<{ id: string; author: string; text: string; date: string }>>>({});
const [ratings, setRatings] = useState<Record<number, number>>({});
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
// Track scroll for nav background
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 50);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Initialize favorites from photo like_counts
useEffect(() => {
const initialFavorites = new Set<number>();
photos.forEach(photo => {
if ((photo.like_count ?? 0) > 0) {
initialFavorites.add(photo.id);
}
});
setFavorites(initialFavorites);
}, [photos]);
// Get hero photo
const heroPhoto = heroPhotoOverride || photos[0];
// Group photos by category into scenes
const scenes = useMemo<CategoryScene[]>(() => {
const photosByCategory: PhotosByCategory = {};
// Filter by search query
const filteredPhotos = searchQuery
? photos.filter(p =>
p.filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
(p.category_name && p.category_name.toLowerCase().includes(searchQuery.toLowerCase()))
)
: photos;
// Group by category
filteredPhotos.forEach(photo => {
const categoryName = photo.category_name || t('gallery.uncategorized', 'Gallery');
if (!photosByCategory[categoryName]) {
photosByCategory[categoryName] = [];
}
photosByCategory[categoryName].push(photo);
});
// Convert to scenes with alternating types
return Object.entries(photosByCategory).map(([categoryName, categoryPhotos], index) => ({
id: `scene-${index}`,
title: categoryName,
subtitle: `${categoryPhotos.length} ${t('gallery.photos', 'photos')}`,
// Alternate between grid and carousel
type: index % 2 === 0 ? 'grid' : 'carousel' as 'grid' | 'carousel',
photos: categoryPhotos
}));
}, [photos, searchQuery, t]);
const totalPhotos = photos.length;
const stats = `${totalPhotos} ${t('gallery.photos', 'Photos')}`;
const handleToggleFavorite = useCallback(async (photoId: number) => {
const newFavorites = new Set(favorites);
const isCurrentlyFavorite = newFavorites.has(photoId);
if (isCurrentlyFavorite) {
newFavorites.delete(photoId);
} else {
newFavorites.add(photoId);
}
setFavorites(newFavorites);
// Only submit like if adding favorite
if (!isCurrentlyFavorite) {
try {
await feedbackService.submitFeedback(slug, String(photoId), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
onFeedbackChange?.();
} catch (err) {
console.warn('Like submit failed', err);
}
}
}, [favorites, slug, savedIdentity, onFeedbackChange]);
const handleOpenFeedback = useCallback((photo: Photo) => {
setSelectedPhotoForFeedback(photo);
}, []);
const handleCloseFeedback = useCallback(() => {
setSelectedPhotoForFeedback(null);
}, []);
const handleAddComment = useCallback(async (text: string, name?: string, email?: string) => {
if (!selectedPhotoForFeedback) return;
if (name && email) {
setSavedIdentity({ name, email });
}
const newComment = {
id: `${Date.now()}`,
author: name || savedIdentity?.name || t('gallery.feedback.anonymous', 'Anonymous'),
text,
date: new Date().toLocaleDateString()
};
setComments(prev => ({
...prev,
[selectedPhotoForFeedback.id]: [...(prev[selectedPhotoForFeedback.id] || []), newComment]
}));
try {
await feedbackService.submitFeedback(slug, String(selectedPhotoForFeedback.id), {
feedback_type: 'comment',
comment_text: text,
guest_name: name || savedIdentity?.name,
guest_email: email || savedIdentity?.email,
});
onFeedbackChange?.();
} catch (err) {
console.warn('Comment submit failed', err);
}
}, [selectedPhotoForFeedback, slug, savedIdentity, onFeedbackChange, t]);
const handleRate = useCallback(async (rating: number) => {
if (!selectedPhotoForFeedback) return;
setRatings(prev => ({
...prev,
[selectedPhotoForFeedback.id]: rating
}));
try {
await feedbackService.submitFeedback(slug, String(selectedPhotoForFeedback.id), {
feedback_type: 'rating',
rating: rating,
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
onFeedbackChange?.();
} catch (err) {
console.warn('Rating submit failed', err);
}
}, [selectedPhotoForFeedback, slug, savedIdentity, onFeedbackChange]);
const handleDownloadAll = useCallback(async () => {
toast.info(t('gallery.downloading', { count: photos.length }));
try {
const ids = photos.map(p => p.id);
await galleryService.downloadSelectedPhotos(slug, ids);
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
} catch {
toast.error(t('gallery.downloadError'));
}
}, [photos, slug, t]);
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">{t('gallery.noPhotosFound')}</p>
</div>
);
}
return (
<div className="gallery-story-layout">
<StoryScrollToTop />
{/* Navigation Overlay */}
<nav className={`story-nav ${scrolled ? 'scrolled' : ''}`}>
<span className="story-nav-logo">
{eventName ? eventName.split(' ').map(w => w[0]).join('').slice(0, 3).toUpperCase() : 'GALLERY'}
</span>
<div className="story-nav-actions">
<div className="story-nav-search">
<Search size={14} className="text-gray-400" />
<input
type="text"
placeholder={t('gallery.searchMemories', 'Search memories...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{feedbackEnabled && (
<button className="story-nav-btn" title={t('gallery.favorites', 'Favorites')}>
<Heart size={20} />
{favorites.size > 0 && (
<span className="story-nav-favorites-count">
{favorites.size > 9 ? '9+' : favorites.size}
</span>
)}
</button>
)}
{onLogout && (
<button
className="story-nav-btn"
onClick={onLogout}
title={t('common.logout', 'Logout')}
>
<LogOut size={20} />
</button>
)}
</div>
</nav>
{/* Hero */}
<StoryHero
title={eventName || t('gallery.photoGallery', 'Photo Gallery')}
date={eventDate}
stats={stats}
photo={heroPhoto}
slug={slug}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
/>
{/* Main Content - Scenes */}
<main className="pb-32 space-y-0">
{scenes.map((scene) => {
if (scene.photos.length === 0) return null;
return (
<StoryScene
key={scene.id}
title={scene.title}
subtitle={scene.subtitle}
fullWidth={scene.type === 'carousel'}
>
{scene.type === 'carousel' ? (
<StoryCarousel
id={`gallery-${scene.id}`}
photos={scene.photos}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
onPhotoClick={handleOpenFeedback}
slug={slug}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
/>
) : (
<div id={`gallery-${scene.id}`} className="story-gallery-grid">
{scene.photos.map((photo, index) => (
<StoryPhotoCard
key={photo.id}
photo={photo}
index={index}
isFavorite={favorites.has(photo.id)}
onToggleFavorite={handleToggleFavorite}
onClick={() => handleOpenFeedback(photo)}
slug={slug}
galleryId={`gallery-${scene.id}`}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
// Mark first photo in each grid as featured
featured={index === 0 && scene.photos.length > 4}
/>
))}
</div>
)}
</StoryScene>
);
})}
</main>
{/* Footer */}
<footer className="story-footer">
<h2 className="story-footer-title">{t('gallery.thankYou', 'Thank You')}</h2>
<p className="story-footer-text">
{t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
</p>
{allowDownloads && (
<button className="story-footer-btn" onClick={handleDownloadAll}>
{t('common.downloadAll', 'Download All Photos')}
</button>
)}
</footer>
{/* Feedback Sheet */}
{feedbackEnabled && (
<StoryFeedbackSheet
isOpen={!!selectedPhotoForFeedback}
onClose={handleCloseFeedback}
photo={selectedPhotoForFeedback}
comments={selectedPhotoForFeedback ? (comments[selectedPhotoForFeedback.id] || []) : []}
rating={selectedPhotoForFeedback ? (ratings[selectedPhotoForFeedback.id] || selectedPhotoForFeedback.average_rating || 0) : 0}
onAddComment={handleAddComment}
onRate={handleRate}
requireNameEmail={feedbackOptions?.requireNameEmail}
savedIdentity={savedIdentity}
/>
)}
</div>
);
};
@@ -3,6 +3,8 @@ export { MasonryGalleryLayout } from './MasonryGalleryLayout';
export { CarouselGalleryLayout } from './CarouselGalleryLayout';
export { TimelineGalleryLayout } from './TimelineGalleryLayout';
export { MosaicGalleryLayout } from './MosaicGalleryLayout';
export { GalleryPremiumLayout } from './GalleryPremiumLayout';
export { GalleryStoryLayout } from './GalleryStoryLayout';
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
@@ -0,0 +1,66 @@
import React from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { FreeMode, Mousewheel } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/free-mode';
import { StoryPhotoCard } from './StoryPhotoCard';
import type { Photo } from '../../../../types';
interface StoryCarouselProps {
photos: Photo[];
favorites: Set<number>;
onToggleFavorite: (id: number) => void;
onPhotoClick: (photo: Photo) => void;
slug: string;
id: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
}
export const StoryCarousel: React.FC<StoryCarouselProps> = ({
photos,
favorites,
onToggleFavorite,
onPhotoClick,
slug,
id,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false
}) => {
return (
<div id={id} className="story-carousel">
<Swiper
modules={[FreeMode, Mousewheel]}
spaceBetween={16}
slidesPerView="auto"
freeMode={true}
mousewheel={{ forceToAxis: true }}
className="w-full"
>
{photos.map((photo, index) => (
<SwiperSlide key={photo.id} className="!w-auto">
<div className="story-carousel-item">
<StoryPhotoCard
photo={photo}
index={index}
isFavorite={favorites.has(photo.id)}
onToggleFavorite={onToggleFavorite}
onClick={() => onPhotoClick(photo)}
slug={slug}
galleryId={id}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
/>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
);
};
@@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { Star, Send, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { Photo } from '../../../../types';
interface Comment {
id: string;
author: string;
text: string;
date: string;
}
interface StoryFeedbackSheetProps {
isOpen: boolean;
onClose: () => void;
photo: Photo | null;
comments: Comment[];
rating: number;
onAddComment: (text: string, name?: string, email?: string) => void;
onRate: (rating: number) => void;
requireNameEmail?: boolean;
savedIdentity?: { name: string; email: string } | null;
}
export const StoryFeedbackSheet: React.FC<StoryFeedbackSheetProps> = ({
isOpen,
onClose,
photo,
comments,
rating,
onAddComment,
onRate,
requireNameEmail = false,
savedIdentity
}) => {
const { t } = useTranslation();
const [commentText, setCommentText] = useState('');
const [guestName, setGuestName] = useState(savedIdentity?.name || '');
const [guestEmail, setGuestEmail] = useState(savedIdentity?.email || '');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (commentText.trim()) {
if (requireNameEmail && (!guestName.trim() || !guestEmail.trim())) {
return;
}
onAddComment(commentText, guestName || undefined, guestEmail || undefined);
setCommentText('');
}
};
if (!photo) return null;
return (
<>
{/* Backdrop */}
{isOpen && (
<div className="story-backdrop" onClick={onClose} />
)}
{/* Sheet */}
<div className={`story-feedback-sheet ${isOpen ? 'open' : ''}`}>
<button className="story-feedback-close" onClick={onClose}>
<X size={20} />
</button>
<div className="story-feedback-sheet-header">
<h3 className="story-feedback-sheet-title">
{t('gallery.feedback.title', 'Feedback')}
</h3>
<p className="story-feedback-sheet-description">
{t('gallery.feedback.shareThoughts', 'Share your thoughts on "{{name}}"', { name: photo.filename })}
</p>
</div>
<div className="space-y-8">
{/* Rating Section */}
<div className="story-feedback-rating">
<label className="story-feedback-rating-label">
{t('gallery.feedback.rateThisMoment', 'Rate this moment')}
</label>
<div className="story-feedback-rating-stars">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => onRate(star)}
className={`story-feedback-rating-star ${star <= rating ? 'active' : ''}`}
>
<Star
size={28}
strokeWidth={1.5}
fill={star <= rating ? 'currentColor' : 'none'}
/>
</button>
))}
</div>
</div>
{/* Comments List */}
<div>
<label className="story-feedback-comments-label">
{t('gallery.feedback.comments', 'Comments')} ({comments.length})
</label>
<div className="story-feedback-comments">
{comments.length === 0 ? (
<p className="story-feedback-empty">
{t('gallery.feedback.noComments', 'No comments yet. Be the first!')}
</p>
) : (
comments.map((comment) => (
<div key={comment.id} className="story-feedback-comment story-animate-fade-in">
<div className="story-feedback-comment-avatar">
{comment.author[0]?.toUpperCase() || '?'}
</div>
<div className="story-feedback-comment-content">
<div className="story-feedback-comment-header">
<span className="story-feedback-comment-author">{comment.author}</span>
<span className="story-feedback-comment-date">{comment.date}</span>
</div>
<p className="story-feedback-comment-text">{comment.text}</p>
</div>
</div>
))
)}
</div>
</div>
{/* Comment Form */}
<form onSubmit={handleSubmit} className="story-feedback-form">
{requireNameEmail && !savedIdentity && (
<div className="space-y-3 mb-3">
<input
type="text"
placeholder={t('gallery.feedback.yourName', 'Your name')}
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
className="story-feedback-textarea"
style={{ minHeight: 'auto', padding: '0.5rem 0.75rem' }}
required
/>
<input
type="email"
placeholder={t('gallery.feedback.yourEmail', 'Your email')}
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
className="story-feedback-textarea"
style={{ minHeight: 'auto', padding: '0.5rem 0.75rem' }}
required
/>
</div>
)}
<textarea
placeholder={t('gallery.feedback.writeLovelyNote', 'Write a lovely note...')}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="story-feedback-textarea"
/>
<button
type="submit"
className="story-feedback-submit"
disabled={!commentText.trim() || (requireNameEmail && !savedIdentity && (!guestName.trim() || !guestEmail.trim()))}
>
<Send size={14} />
{t('gallery.feedback.postComment', 'Post Comment')}
</button>
</form>
</div>
</div>
</>
);
};
@@ -0,0 +1,93 @@
import React from 'react';
import { motion } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import { AuthenticatedImage } from '../../../common';
import type { Photo } from '../../../../types';
interface StoryHeroProps {
title: string;
date?: string | null;
stats: string;
photo?: Photo | null;
slug: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
}
export const StoryHero: React.FC<StoryHeroProps> = ({
title,
date,
stats,
photo,
slug,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false
}) => {
const formattedDate = date
? new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
: null;
return (
<div className="story-hero">
{/* Background */}
<div className="story-hero-bg">
{photo && (photo.url || photo.thumbnail_url) ? (
<AuthenticatedImage
src={photo.url || photo.thumbnail_url || ''}
alt="Hero"
className="w-full h-full object-cover"
isGallery={true}
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
) : (
<div className="w-full h-full bg-gray-900" />
)}
<div className="story-hero-gradient" />
</div>
{/* Content */}
<div className="story-hero-content">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className="space-y-6"
>
{formattedDate && (
<p className="story-hero-date">{formattedDate}</p>
)}
<h1 className="story-hero-title">{title}</h1>
<div className="story-hero-stats">
<span className="story-hero-stats-divider" />
<span>{stats}</span>
<span className="story-hero-stats-divider" />
</div>
</motion.div>
</div>
{/* Scroll Indicator */}
<motion.div
className="story-scroll-indicator"
animate={{ y: [0, 10, 0] }}
transition={{ duration: 2, repeat: Infinity }}
>
<ChevronDown size={32} strokeWidth={1} />
</motion.div>
</div>
);
};
@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Heart } from 'lucide-react';
import { AuthenticatedImage } from '../../../common';
import type { Photo } from '../../../../types';
interface StoryPhotoCardProps {
photo: Photo;
index: number;
isFavorite: boolean;
onToggleFavorite: (id: number) => void;
onClick?: () => void;
slug: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
featured?: boolean;
galleryId: string;
}
export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
photo,
index,
isFavorite,
onToggleFavorite,
onClick,
slug,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
featured = false,
galleryId: _galleryId
}) => {
// galleryId is kept for potential PhotoSwipe integration but not currently used
void _galleryId;
const [isLoaded, setIsLoaded] = useState(false);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ delay: Math.min(index * 0.05, 0.3) }}
className={`story-photo-card group ${featured ? 'story-gallery-grid-featured' : ''}`}
>
<a
href={photo.url}
data-pswp-src={photo.url}
data-pswp-width={photo.width || 1200}
data-pswp-height={photo.height || 800}
data-photo-id={photo.id}
onClick={(e) => {
if (onClick) {
e.preventDefault();
onClick();
}
}}
className="block w-full h-full"
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
onLoad={() => setIsLoaded(true)}
className={`w-full h-full object-cover transition-all duration-700 ease-out will-change-transform ${
!isLoaded ? 'opacity-0 scale-110' : 'opacity-100 scale-100'
}`}
isGallery={true}
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
</a>
{/* Overlay */}
<div className="story-photo-card-overlay" />
{/* Actions */}
<div className="story-photo-card-actions">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleFavorite(photo.id);
}}
className={`story-photo-card-btn ${isFavorite ? 'favorite' : ''}`}
>
<Heart size={18} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
</div>
{/* Caption */}
<div className="story-photo-card-caption">
<p>{photo.filename}</p>
</div>
</motion.div>
);
};
@@ -0,0 +1,45 @@
import React from 'react';
import { motion } from 'framer-motion';
interface StorySceneProps {
title: string;
subtitle?: string;
children: React.ReactNode;
className?: string;
fullWidth?: boolean;
}
export const StoryScene: React.FC<StorySceneProps> = ({
title,
subtitle,
children,
className = '',
fullWidth = false
}) => {
return (
<section className={`story-scene ${fullWidth ? 'full-width' : ''} ${className}`}>
<div className="story-scene-header">
<motion.h2
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
className="story-scene-title"
>
{title}
</motion.h2>
{subtitle && (
<motion.p
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="story-scene-subtitle"
>
{subtitle}
</motion.p>
)}
</div>
{children}
</section>
);
};
@@ -0,0 +1,45 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowUp } from 'lucide-react';
export const StoryScrollToTop: React.FC = () => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const toggleVisibility = () => {
if (window.scrollY > 300) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
window.addEventListener('scroll', toggleVisibility);
return () => window.removeEventListener('scroll', toggleVisibility);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<AnimatePresence>
{isVisible && (
<motion.button
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
onClick={scrollToTop}
className="story-scroll-to-top"
aria-label="Scroll to top"
>
<ArrowUp size={24} strokeWidth={2} />
</motion.button>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,6 @@
export { StoryHero } from './StoryHero';
export { StoryScene } from './StoryScene';
export { StoryPhotoCard } from './StoryPhotoCard';
export { StoryCarousel } from './StoryCarousel';
export { StoryFeedbackSheet } from './StoryFeedbackSheet';
export { StoryScrollToTop } from './StoryScrollToTop';