feat: implement gallery preview with layout selector

- Add GalleryPreview component that shows simplified gallery layouts
- Update ThemeEditorModal with split view: theme customizer on left, preview on right
- Add grid style selector above preview to switch between layouts
- Update BrandingPage to show live preview alongside theme customizer
- Add preview to CreateEventPageEnhanced when customizing themes
- Support all 6 gallery layouts: grid, masonry, carousel, timeline, hero, mosaic
- Add translation keys for preview layout and live preview

The preview accurately reflects different grid layouts and theme settings,
helping users visualize how their galleries will look before saving.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-11 08:38:48 +02:00
parent 9006b754a8
commit d89a605579
7 changed files with 326 additions and 31 deletions
@@ -0,0 +1,184 @@
import React, { useMemo } from 'react';
import { Card } from '../common';
import { Camera } from 'lucide-react';
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
interface GalleryPreviewProps {
theme: ThemeConfig;
layoutType?: GalleryLayoutType;
className?: string;
}
// Mock photo data for preview
const generateMockPhotos = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
filename: `photo-${i + 1}.jpg`,
url: '',
thumbnail_url: '',
type: i % 3 === 0 ? 'collage' : 'individual',
category_id: (i % 4) + 1,
category_name: ['Ceremony', 'Reception', 'Portraits', 'Party'][i % 4],
category_slug: ['ceremony', 'reception', 'portraits', 'party'][i % 4],
size: Math.floor(Math.random() * 5000000) + 1000000,
uploaded_at: new Date().toISOString(),
}));
};
// Preview photo component
const PreviewPhoto: React.FC<{
photo: any;
className?: string;
aspectRatio?: string;
}> = ({
photo,
className = '',
aspectRatio = 'aspect-square'
}) => (
<div className={`relative overflow-hidden rounded-lg bg-gradient-to-br from-neutral-200 to-neutral-300 ${aspectRatio} ${className}`}>
<div className="absolute inset-0 flex items-center justify-center">
<Camera className="w-8 h-8 text-neutral-400" />
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
{photo.category_name && (
<p className="text-white/70 text-[10px]">{photo.category_name}</p>
)}
</div>
{photo.type === 'collage' && (
<div className="absolute top-1 right-1">
<span className="px-1.5 py-0.5 bg-black/60 text-white text-[10px] rounded">
Collage
</span>
</div>
)}
</div>
);
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
theme,
layoutType,
className = ''
}) => {
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
// Use the provided layoutType or fallback to theme's gallery layout
const activeLayout = layoutType || theme.galleryLayout || 'grid';
const renderLayout = () => {
const spacing = theme.gallerySettings?.spacing || 'normal';
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
switch (activeLayout) {
case 'grid': {
const cols = theme.gallerySettings?.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
return (
<div className={`grid grid-cols-3 md:grid-cols-4 ${gapClass}`}>
{mockPhotos.slice(0, 8).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
);
}
case 'masonry':
return (
<div className={`columns-3 md:columns-4 ${gapClass}`}>
{mockPhotos.slice(0, 10).map((photo, idx) => (
<div key={photo.id} className={`break-inside-avoid mb-${spacing === 'tight' ? '1' : spacing === 'relaxed' ? '4' : '2'}`}>
<PreviewPhoto
photo={photo}
aspectRatio={idx % 3 === 0 ? 'aspect-[4/5]' : idx % 3 === 1 ? 'aspect-[4/3]' : 'aspect-square'}
/>
</div>
))}
</div>
);
case 'carousel':
return (
<div className="relative">
<div className="flex items-center gap-2 overflow-hidden">
<PreviewPhoto photo={mockPhotos[0]} className="w-full max-w-md mx-auto" aspectRatio="aspect-[4/3]" />
</div>
<div className="flex justify-center gap-1 mt-3">
{[0, 1, 2, 3].map((idx) => (
<div key={idx} className={`w-2 h-2 rounded-full ${idx === 0 ? 'bg-primary-600' : 'bg-neutral-300'}`} />
))}
</div>
</div>
);
case 'timeline':
return (
<div className="space-y-6">
{['Today', 'Yesterday'].map((date, dateIdx) => (
<div key={date}>
<h4 className="text-sm font-medium text-neutral-700 mb-2">{date}</h4>
<div className={`grid grid-cols-3 ${gapClass}`}>
{mockPhotos.slice(dateIdx * 3, (dateIdx * 3) + 3).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
</div>
))}
</div>
);
case 'hero':
return (
<div className="space-y-3">
<PreviewPhoto photo={mockPhotos[0]} aspectRatio="aspect-[16/9]" className="w-full" />
<div className={`grid grid-cols-4 ${gapClass}`}>
{mockPhotos.slice(1, 5).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
</div>
);
case 'mosaic':
return (
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
<PreviewPhoto photo={mockPhotos[0]} className="col-span-2 row-span-2" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[1]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[2]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[3]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[4]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[5]} className="col-span-2 row-span-1" aspectRatio="aspect-auto h-full" />
</div>
);
default:
return null;
}
};
return (
<div
className={`bg-white rounded-lg shadow-sm overflow-hidden ${className}`}
style={{
backgroundColor: theme.backgroundColor || '#ffffff',
color: theme.textColor || '#171717',
fontFamily: theme.fontFamily || 'Inter, sans-serif',
}}
>
{/* Preview Header */}
<div
className="px-4 py-3 border-b"
style={{
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
}}
>
<h3 className="text-sm font-medium capitalize">{activeLayout} Layout</h3>
</div>
{/* Preview Content */}
<div className="p-4" style={{ maxHeight: '400px', overflowY: 'auto' }}>
{renderLayout()}
</div>
</div>
);
};
GalleryPreview.displayName = 'GalleryPreview';
@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import { X, Save, RotateCcw } from 'lucide-react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, Check } from 'lucide-react';
import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { GalleryPreview } from './GalleryPreview';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import { useTranslation } from 'react-i18next';
interface ThemeEditorModalProps {
@@ -13,6 +14,15 @@ interface ThemeEditorModalProps {
eventName: string;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
grid: <Grid3X3 className="w-4 h-4" />,
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />
};
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
isOpen,
onClose,
@@ -23,6 +33,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
const { t } = useTranslation();
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
const [presetName, setPresetName] = useState<string>('default');
const [previewLayout, setPreviewLayout] = useState<GalleryLayoutType | undefined>(undefined);
useEffect(() => {
if (currentTheme) {
@@ -105,16 +116,68 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<ThemeCustomizerEnhanced
value={theme}
onChange={handleThemeChange}
presetName={presetName}
onPresetChange={handlePresetChange}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
/>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
{/* Left side - Theme Customizer */}
<div className="p-6 overflow-y-auto border-r border-neutral-200">
<ThemeCustomizerEnhanced
value={theme}
onChange={handleThemeChange}
presetName={presetName}
onPresetChange={handlePresetChange}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
/>
</div>
{/* Right side - Gallery Preview */}
<div className="p-6 bg-neutral-50 overflow-y-auto">
<div className="space-y-4">
{/* Grid Style Selector */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-3">
{t('branding.previewLayout')}
</h3>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
<button
key={layout}
onClick={() => setPreviewLayout(layout)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(previewLayout || theme.galleryLayout || 'grid') === layout
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300 bg-white'
}`}
>
<div className="flex flex-col items-center gap-1">
<div className="text-neutral-700">
{layoutIcons[layout]}
</div>
<span className="text-xs capitalize">{layout}</span>
</div>
{(previewLayout || theme.galleryLayout || 'grid') === layout && (
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
)}
</button>
))}
</div>
</div>
{/* Gallery Preview */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-3">
{t('branding.livePreview')}
</h3>
<GalleryPreview
theme={theme}
layoutType={previewLayout}
className="shadow-lg"
/>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
+2 -1
View File
@@ -20,4 +20,5 @@ export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal';
export { HeroPhotoSelector } from './HeroPhotoSelector';
export { PhotoUploadModal } from './PhotoUploadModal';
export { PhotoUploadModal } from './PhotoUploadModal';
export { GalleryPreview } from './GalleryPreview';