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';
|
||||
@@ -676,7 +676,25 @@
|
||||
"sortByDate": "Nach Datum sortieren",
|
||||
"sortByName": "Nach Name sortieren",
|
||||
"sortBySize": "Nach Größe sortieren",
|
||||
"sortByRating": "Nach Bewertung sortieren"
|
||||
"sortByRating": "Nach Bewertung sortieren",
|
||||
"photoGallery": "Fotogalerie",
|
||||
"photos": "Fotos",
|
||||
"allRightsReserved": "Alle Rechte vorbehalten",
|
||||
"searchMemories": "Erinnerungen suchen...",
|
||||
"thankYou": "Vielen Dank",
|
||||
"thankYouMessage": "Dass Sie Teil unserer Geschichte waren und unseren besonderen Tag unvergesslich gemacht haben.",
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"shareThoughts": "Teilen Sie Ihre Gedanken zu \"{{name}}\"",
|
||||
"rateThisMoment": "Bewerten Sie diesen Moment",
|
||||
"comments": "Kommentare",
|
||||
"noComments": "Noch keine Kommentare. Seien Sie der Erste!",
|
||||
"yourName": "Ihr Name",
|
||||
"yourEmail": "Ihre E-Mail",
|
||||
"writeLovelyNote": "Schreiben Sie eine nette Nachricht...",
|
||||
"postComment": "Kommentar posten",
|
||||
"anonymous": "Anonym"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"title": "Fotokategorien",
|
||||
@@ -1170,6 +1188,34 @@
|
||||
"infoTitle": "Über Bildschutz",
|
||||
"infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz."
|
||||
},
|
||||
"seo": {
|
||||
"title": "SEO & Robots",
|
||||
"indexingTitle": "Suchmaschinen-Indexierung",
|
||||
"allowIndexing": "Suchmaschinen-Indexierung erlauben",
|
||||
"allowIndexingHelp": "Wenn deaktiviert, werden alle Crawler über robots.txt blockiert. Für private Foto-Plattformen empfohlen auszuschalten.",
|
||||
"sitemapUrl": "Sitemap-URL",
|
||||
"sitemapUrlHelp": "Optional. Wird zur robots.txt hinzugefügt, falls angegeben.",
|
||||
"aiBlockingTitle": "KI- & Bot-Blockierung",
|
||||
"blockAiCrawlers": "KI-/LLM-Crawler blockieren",
|
||||
"blockAiCrawlersHelp": "Verhindert, dass KI-Trainings-Bots auf Ihre Inhalte zugreifen.",
|
||||
"blockedAgents": "Blockierte KI-Agenten",
|
||||
"addAgentPlaceholder": "Agentenname eingeben...",
|
||||
"blockSocialBots": "Social-Media-Vorschau-Bots blockieren",
|
||||
"blockSocialBotsHelp": "Verhindert Link-Vorschauen auf Twitter, Facebook, LinkedIn usw.",
|
||||
"metaTagsTitle": "Meta-Tags & Benutzerdefinierte Regeln",
|
||||
"metaNoindex": "noindex-Meta-Tag hinzufügen",
|
||||
"metaNoindexHelp": "Teilt Suchmaschinen mit, Seiten nicht zu indexieren (HTML-Ebene, ergänzt robots.txt).",
|
||||
"metaNofollow": "nofollow-Meta-Tag hinzufügen",
|
||||
"metaNofollowHelp": "Teilt Suchmaschinen mit, Links auf Seiten nicht zu folgen.",
|
||||
"metaNoai": "noai/noimageai-Meta-Tag hinzufügen",
|
||||
"metaNoaiHelp": "Signalisiert, dass Inhalte nicht für KI-Training verwendet werden sollen.",
|
||||
"customRules": "Benutzerdefinierte robots.txt-Regeln",
|
||||
"ruleAgentPlaceholder": "User-Agent",
|
||||
"rulePathPlaceholder": "Disallow-Pfad",
|
||||
"showPreview": "robots.txt-Vorschau anzeigen",
|
||||
"hidePreview": "robots.txt-Vorschau ausblenden",
|
||||
"saveSettings": "SEO-Einstellungen speichern"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien",
|
||||
"about": "Über Fotokategorien",
|
||||
@@ -1253,6 +1299,11 @@
|
||||
"accentColor": "Akzentfarbe",
|
||||
"backgroundColor": "Hintergrundfarbe",
|
||||
"textColor": "Textfarbe",
|
||||
"colorMode": "Farbmodus",
|
||||
"colorModeLight": "Hell",
|
||||
"colorModeDark": "Dunkel",
|
||||
"colorModeAuto": "Auto",
|
||||
"colorModeHelp": "Auto folgt der Systemeinstellung des Besuchers.",
|
||||
"customCSS": "Benutzerdefiniertes CSS",
|
||||
"preview": "Vorschau",
|
||||
"previewInNewTab": "Vorschau in neuem Tab",
|
||||
@@ -1270,7 +1321,9 @@
|
||||
"timeline": "Nach Datum organisierte Fotos",
|
||||
"hero": "Hervorgehobenes Bild mit Raster darunter",
|
||||
"mosaic": "Künstlerisches Layout mit gemischten Größen",
|
||||
"justified": "Zeilenbasiertes Layout mit Seitenverhältnis-Erhaltung"
|
||||
"justified": "Zeilenbasiertes Layout mit Seitenverhältnis-Erhaltung",
|
||||
"gallery-premium": "Elegantes helles Theme mit Hero und Masonry (Beta)",
|
||||
"gallery-story": "Filmisches dunkles Theme mit Szenen-Abschnitten (Beta)"
|
||||
},
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"photoSpacing": "Foto-Abstand",
|
||||
@@ -1425,7 +1478,18 @@
|
||||
"angle": "Winkel",
|
||||
"curve": "Kurve",
|
||||
"none": "Keine"
|
||||
}
|
||||
},
|
||||
"controlsStyle": "Steuerelemente-Stil",
|
||||
"controlsStyleDescription": "Wählen Sie, wie Galerie-Filter und Steuerelemente angezeigt werden.",
|
||||
"controlsStyleOptions": {
|
||||
"classic": "Klassisch",
|
||||
"sidebar": "Seitenleiste"
|
||||
},
|
||||
"controlsStyleDescriptions": {
|
||||
"classic": "Inline-Filterleiste unter der Kopfzeile",
|
||||
"sidebar": "Menü-Schaltfläche öffnet Seitenleiste mit Filtern"
|
||||
},
|
||||
"controlsStyleHeroWarning": "Für Hero-Kopfzeilen wird die Seitenleiste empfohlen, um zu verhindern, dass Steuerelemente über dem Hero-Bild erscheinen."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin-Panel",
|
||||
@@ -1437,6 +1501,8 @@
|
||||
"storageUsed": "Speicher verwendet",
|
||||
"totalPhotos": "Gesamte Fotos",
|
||||
"storagePercent": "{{percent}}% des Limits {{limit}}",
|
||||
"darkMode": "Zum Dunkelmodus wechseln",
|
||||
"lightMode": "Zum Hellmodus wechseln",
|
||||
"storageSoftLimitConfigured": "Konfiguriertes Limit: {{limit}}",
|
||||
"storageSoftLimitRecommended": "Empfohlenes Limit: {{limit}}",
|
||||
"version": "Version",
|
||||
|
||||
@@ -289,7 +289,25 @@
|
||||
"sortByDate": "Sort by Date",
|
||||
"sortByName": "Sort by Name",
|
||||
"sortBySize": "Sort by Size",
|
||||
"sortByRating": "Sort by Rating"
|
||||
"sortByRating": "Sort by Rating",
|
||||
"photoGallery": "Photo Gallery",
|
||||
"photos": "Photos",
|
||||
"allRightsReserved": "All rights reserved",
|
||||
"searchMemories": "Search memories...",
|
||||
"thankYou": "Thank You",
|
||||
"thankYouMessage": "For being part of our story and making our special day unforgettable.",
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"shareThoughts": "Share your thoughts on \"{{name}}\"",
|
||||
"rateThisMoment": "Rate this moment",
|
||||
"comments": "Comments",
|
||||
"noComments": "No comments yet. Be the first!",
|
||||
"yourName": "Your name",
|
||||
"yourEmail": "Your email",
|
||||
"writeLovelyNote": "Write a lovely note...",
|
||||
"postComment": "Post Comment",
|
||||
"anonymous": "Anonymous"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"title": "Photo Categories",
|
||||
@@ -859,6 +877,34 @@
|
||||
"styling": {
|
||||
"title": "Custom CSS"
|
||||
},
|
||||
"seo": {
|
||||
"title": "SEO & Robots",
|
||||
"indexingTitle": "Search Engine Indexing",
|
||||
"allowIndexing": "Allow search engine indexing",
|
||||
"allowIndexingHelp": "When disabled, all crawlers are blocked via robots.txt. Recommended off for private photo platforms.",
|
||||
"sitemapUrl": "Sitemap URL",
|
||||
"sitemapUrlHelp": "Optional. Added to robots.txt if provided.",
|
||||
"aiBlockingTitle": "AI & Bot Blocking",
|
||||
"blockAiCrawlers": "Block AI/LLM crawlers",
|
||||
"blockAiCrawlersHelp": "Prevent AI training bots from accessing your content.",
|
||||
"blockedAgents": "Blocked AI agents",
|
||||
"addAgentPlaceholder": "Enter agent name...",
|
||||
"blockSocialBots": "Block social media preview bots",
|
||||
"blockSocialBotsHelp": "Prevent link previews on Twitter, Facebook, LinkedIn, etc.",
|
||||
"metaTagsTitle": "Meta Tags & Custom Rules",
|
||||
"metaNoindex": "Add noindex meta tag",
|
||||
"metaNoindexHelp": "Tells search engines not to index pages (HTML-level, complements robots.txt).",
|
||||
"metaNofollow": "Add nofollow meta tag",
|
||||
"metaNofollowHelp": "Tells search engines not to follow links on pages.",
|
||||
"metaNoai": "Add noai/noimageai meta tag",
|
||||
"metaNoaiHelp": "Signals that content should not be used for AI training.",
|
||||
"customRules": "Custom robots.txt rules",
|
||||
"ruleAgentPlaceholder": "User-agent",
|
||||
"rulePathPlaceholder": "Disallow path",
|
||||
"showPreview": "Show robots.txt preview",
|
||||
"hidePreview": "Hide robots.txt preview",
|
||||
"saveSettings": "Save SEO Settings"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"umamiIntegration": "Umami Analytics Integration",
|
||||
@@ -968,6 +1014,11 @@
|
||||
"accentColor": "Accent Color",
|
||||
"backgroundColor": "Background Color",
|
||||
"textColor": "Text Color",
|
||||
"colorMode": "Color Mode",
|
||||
"colorModeLight": "Light",
|
||||
"colorModeDark": "Dark",
|
||||
"colorModeAuto": "Auto",
|
||||
"colorModeHelp": "Auto follows the visitor's system preference.",
|
||||
"customCSS": "Custom CSS",
|
||||
"preview": "Preview",
|
||||
"previewInNewTab": "Preview in New Tab",
|
||||
@@ -985,7 +1036,9 @@
|
||||
"timeline": "Photos organized by date",
|
||||
"hero": "Featured image with grid below",
|
||||
"mosaic": "Artistic layout with mixed sizes",
|
||||
"justified": "Row-based layout preserving aspect ratios"
|
||||
"justified": "Row-based layout preserving aspect ratios",
|
||||
"gallery-premium": "Elegant light theme with hero and masonry (Beta)",
|
||||
"gallery-story": "Cinematic dark theme with scene sections (Beta)"
|
||||
},
|
||||
"layoutSettings": "Layout Settings",
|
||||
"photoSpacing": "Photo Spacing",
|
||||
@@ -1140,7 +1193,18 @@
|
||||
"angle": "Angle",
|
||||
"curve": "Curve",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"controlsStyle": "Controls Style",
|
||||
"controlsStyleDescription": "Choose how gallery filters and controls are displayed.",
|
||||
"controlsStyleOptions": {
|
||||
"classic": "Classic",
|
||||
"sidebar": "Sidebar"
|
||||
},
|
||||
"controlsStyleDescriptions": {
|
||||
"classic": "Inline filter bar below header",
|
||||
"sidebar": "Menu button opens sidebar with filters"
|
||||
},
|
||||
"controlsStyleHeroWarning": "Sidebar is recommended for hero headers to prevent controls appearing above the hero image."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Panel",
|
||||
@@ -1226,6 +1290,8 @@
|
||||
"markAllAsRead": "Mark all as read",
|
||||
"notificationSettings": "Notification Settings",
|
||||
"changePassword": "Change Password",
|
||||
"darkMode": "Switch to dark mode",
|
||||
"lightMode": "Switch to light mode",
|
||||
"accountSettings": {
|
||||
"title": "Admin account",
|
||||
"description": "Update the credentials used to sign in to PicPeak.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Gallery Layout Types
|
||||
export type GalleryLayoutType = 'grid' | 'masonry' | 'carousel' | 'timeline' | 'mosaic';
|
||||
export type GalleryLayoutType = 'grid' | 'masonry' | 'carousel' | 'timeline' | 'mosaic' | 'gallery-premium' | 'gallery-story';
|
||||
|
||||
// Header Style Types (decoupled from layout)
|
||||
export type HeaderStyleType = 'hero' | 'standard' | 'minimal' | 'none';
|
||||
@@ -55,17 +55,23 @@ export interface ThemeConfig {
|
||||
accentColor?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
|
||||
surfaceColor?: string;
|
||||
surfaceBorderColor?: string;
|
||||
mutedTextColor?: string;
|
||||
|
||||
// Color Mode
|
||||
colorMode?: 'light' | 'dark' | 'auto';
|
||||
|
||||
// Typography
|
||||
fontFamily?: string;
|
||||
headingFontFamily?: string;
|
||||
fontSize?: 'small' | 'normal' | 'large';
|
||||
|
||||
|
||||
// Styling
|
||||
borderRadius?: 'none' | 'sm' | 'md' | 'lg';
|
||||
buttonStyle?: 'solid' | 'outline' | 'ghost';
|
||||
shadowStyle?: 'none' | 'subtle' | 'normal' | 'dramatic';
|
||||
|
||||
|
||||
// Gallery Layout
|
||||
galleryLayout?: GalleryLayoutType;
|
||||
gallerySettings?: GalleryLayoutSettings;
|
||||
@@ -74,6 +80,9 @@ export interface ThemeConfig {
|
||||
headerStyle?: HeaderStyleType;
|
||||
heroDividerStyle?: HeroDividerStyle;
|
||||
|
||||
// Controls Style - sidebar (menu button) vs classic (inline filter bar)
|
||||
controlsStyle?: 'sidebar' | 'classic';
|
||||
|
||||
// Legacy Header/Footer (kept for backward compatibility)
|
||||
legacyHeaderStyle?: 'minimal' | 'standard' | 'full';
|
||||
footerStyle?: 'minimal' | 'standard' | 'full';
|
||||
@@ -241,5 +250,143 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
shadowStyle: 'dramatic'
|
||||
},
|
||||
isPreset: true
|
||||
},
|
||||
|
||||
darkClassic: {
|
||||
name: 'Dark Classic',
|
||||
description: 'Dark theme with green accents',
|
||||
config: {
|
||||
primaryColor: '#5C8762',
|
||||
accentColor: '#22c55e',
|
||||
backgroundColor: '#0f0f0f',
|
||||
textColor: '#e5e5e5',
|
||||
surfaceColor: '#1a1a1a',
|
||||
surfaceBorderColor: '#2e2e2e',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
colorMode: 'dark',
|
||||
borderRadius: 'md',
|
||||
galleryLayout: 'grid',
|
||||
gallerySettings: {
|
||||
spacing: 'normal',
|
||||
photoAnimation: 'fade',
|
||||
gridColumns: { mobile: 2, tablet: 3, desktop: 4 }
|
||||
},
|
||||
headerStyle: 'standard',
|
||||
footerStyle: 'standard',
|
||||
shadowStyle: 'subtle'
|
||||
},
|
||||
isPreset: true
|
||||
},
|
||||
|
||||
darkElegant: {
|
||||
name: 'Dark Elegant',
|
||||
description: 'Dark theme with gold accents',
|
||||
config: {
|
||||
primaryColor: '#c9a961',
|
||||
accentColor: '#e6ddd4',
|
||||
backgroundColor: '#121212',
|
||||
textColor: '#f0ebe5',
|
||||
surfaceColor: '#1e1e1e',
|
||||
surfaceBorderColor: '#333333',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
colorMode: 'dark',
|
||||
fontFamily: 'Playfair Display, serif',
|
||||
headingFontFamily: 'Playfair Display, serif',
|
||||
borderRadius: 'lg',
|
||||
galleryLayout: 'grid',
|
||||
headerStyle: 'hero',
|
||||
heroDividerStyle: 'wave',
|
||||
gallerySettings: {
|
||||
spacing: 'relaxed',
|
||||
photoAnimation: 'scale',
|
||||
photoShape: 'rounded',
|
||||
heroOverlayOpacity: 0.4
|
||||
},
|
||||
footerStyle: 'minimal',
|
||||
shadowStyle: 'subtle'
|
||||
},
|
||||
isPreset: true
|
||||
},
|
||||
|
||||
darkModern: {
|
||||
name: 'Dark Modern',
|
||||
description: 'Dark theme with blue accents',
|
||||
config: {
|
||||
primaryColor: '#3b82f6',
|
||||
accentColor: '#1e40af',
|
||||
backgroundColor: '#0a0a0a',
|
||||
textColor: '#f5f5f5',
|
||||
surfaceColor: '#171717',
|
||||
surfaceBorderColor: '#262626',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
colorMode: 'dark',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
borderRadius: 'sm',
|
||||
galleryLayout: 'masonry',
|
||||
gallerySettings: {
|
||||
spacing: 'tight',
|
||||
photoAnimation: 'fade',
|
||||
masonryMode: 'columns',
|
||||
masonryGutter: 16
|
||||
},
|
||||
headerStyle: 'minimal',
|
||||
footerStyle: 'minimal',
|
||||
shadowStyle: 'normal'
|
||||
},
|
||||
isPreset: true
|
||||
},
|
||||
|
||||
galleryPremium: {
|
||||
name: 'Gallery Premium (Beta)',
|
||||
description: 'Clean light theme with elegant serif typography and masonry grid',
|
||||
config: {
|
||||
primaryColor: '#18181b',
|
||||
accentColor: '#ef4444',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#18181b',
|
||||
surfaceColor: '#ffffff',
|
||||
surfaceBorderColor: '#f4f4f5',
|
||||
mutedTextColor: '#71717a',
|
||||
colorMode: 'light',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
headingFontFamily: "'Playfair Display', serif",
|
||||
borderRadius: 'sm',
|
||||
galleryLayout: 'gallery-premium',
|
||||
gallerySettings: {
|
||||
spacing: 'normal',
|
||||
photoAnimation: 'fade'
|
||||
},
|
||||
headerStyle: 'none',
|
||||
footerStyle: 'minimal',
|
||||
shadowStyle: 'subtle'
|
||||
},
|
||||
isPreset: true
|
||||
},
|
||||
|
||||
galleryStory: {
|
||||
name: 'Gallery Story (Beta)',
|
||||
description: 'Dark cinematic theme with gold accents and scene-based sections',
|
||||
config: {
|
||||
primaryColor: '#c9a961',
|
||||
accentColor: '#c9a961',
|
||||
backgroundColor: '#0d0d0d',
|
||||
textColor: '#f2f2f2',
|
||||
surfaceColor: '#1a1a1a',
|
||||
surfaceBorderColor: '#262626',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
colorMode: 'dark',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
headingFontFamily: "'Playfair Display', serif",
|
||||
borderRadius: 'sm',
|
||||
galleryLayout: 'gallery-story',
|
||||
gallerySettings: {
|
||||
spacing: 'normal',
|
||||
photoAnimation: 'fade'
|
||||
},
|
||||
headerStyle: 'none',
|
||||
footerStyle: 'minimal',
|
||||
shadowStyle: 'subtle'
|
||||
},
|
||||
isPreset: true
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user