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:
@@ -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';
|
||||
Reference in New Issue
Block a user