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';
+7 -1
View File
@@ -375,6 +375,10 @@
"language": "Sprache",
"defaultLanguage": "Standardsprache",
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
"defaultWelcomeMessage": "Standard-Begrüßungsnachricht",
"welcomeMessage": "Begrüßungsnachricht",
"welcomeMessagePlaceholder": "Geben Sie eine Standard-Begrüßungsnachricht ein, die in E-Mails zur Galerieerstellung enthalten sein wird",
"welcomeMessageHelp": "Diese Nachricht wird in allen E-Mails zur Galerieerstellung enthalten sein, sofern sie beim Erstellen einer Veranstaltung nicht überschrieben wird",
"saveSettings": "Allgemeine Einstellungen speichern",
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
"dateTimeFormat": "Datums- & Zeitformat",
@@ -579,7 +583,9 @@
"applyTheme": "Theme anwenden",
"customTheme": "Benutzerdefiniertes Design",
"customizeTheme": "Design anpassen",
"saveTheme": "Design speichern"
"saveTheme": "Design speichern",
"previewLayout": "Vorschau-Layout",
"livePreview": "Live-Vorschau"
},
"admin": {
"title": "Admin-Panel",
+7 -1
View File
@@ -396,6 +396,10 @@
"language": "Language",
"defaultLanguage": "Default Language",
"defaultLanguageHelp": "Language shown to guests before login",
"defaultWelcomeMessage": "Default Welcome Message",
"welcomeMessage": "Welcome Message",
"welcomeMessagePlaceholder": "Enter a default welcome message that will be included in gallery creation emails",
"welcomeMessageHelp": "This message will be included in all gallery creation emails unless overridden when creating an event",
"saveSettings": "Save General Settings",
"saveGeneralSettings": "Save General Settings",
"dateTimeFormat": "Date & Time Format",
@@ -634,7 +638,9 @@
"applyTheme": "Apply Theme",
"customTheme": "Custom Theme",
"customizeTheme": "Customize Theme",
"saveTheme": "Save Theme"
"saveTheme": "Save Theme",
"previewLayout": "Preview Layout",
"livePreview": "Live Preview"
},
"admin": {
"title": "Admin Panel",
+28 -7
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Save, Eye, Palette, Upload } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
import { ThemeCustomizer } from '../../components/admin/ThemeCustomizer';
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
@@ -472,12 +472,33 @@ export const BrandingPage: React.FC = () => {
<span className="text-sm text-neutral-700">{t('branding.applyLivePreview')}</span>
</label>
</div>
<ThemeCustomizer
value={currentTheme}
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left side - Theme Customizer */}
<div>
<ThemeCustomizerEnhanced
value={currentTheme}
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
showGalleryLayouts={true}
hideActions={true}
/>
</div>
{/* Right side - Gallery Preview */}
<div className="lg:sticky lg:top-4 lg:h-fit">
<Card className="p-4">
<h3 className="text-sm font-medium text-neutral-700 mb-3">
{t('branding.livePreview')}
</h3>
<GalleryPreview
theme={currentTheme}
className="shadow-lg"
/>
</Card>
</div>
</div>
</div>
{/* Event-Specific Themes Info */}
@@ -15,7 +15,7 @@ import { enUS, de } from 'date-fns/locale';
import { toast } from 'react-toastify';
import { Button, Input, Card } from '../../components/common';
import { ThemeCustomizerEnhanced } from '../../components/admin/ThemeCustomizerEnhanced';
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { categoriesService } from '../../services/categories.service';
@@ -375,14 +375,28 @@ export const CreateEventPageEnhanced: React.FC = () => {
{/* Theme Customizer */}
{showThemeCustomizer && (
<ThemeCustomizerEnhanced
value={formData.theme_config}
onChange={handleThemeChange}
presetName={formData.theme_preset}
onPresetChange={handlePresetChange}
isPreviewMode={false}
showGalleryLayouts={true}
/>
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Theme Customizer */}
<ThemeCustomizerEnhanced
value={formData.theme_config}
onChange={handleThemeChange}
presetName={formData.theme_preset}
onPresetChange={handlePresetChange}
isPreviewMode={false}
showGalleryLayouts={true}
hideActions={true}
/>
{/* Gallery Preview */}
<div className="lg:sticky lg:top-4 lg:h-fit">
<GalleryPreview
theme={formData.theme_config}
className="shadow-lg"
/>
</div>
</div>
</div>
)}
</div>
</Card>