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:
@@ -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 */}
|
||||
|
||||
@@ -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';
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user