Fix mobile responsiveness and implement enhanced theme system

- Fixed mobile gallery login box sizing and layout
- Fixed header button layout for mobile screens
- Fixed duplicate logo issue on logout
- Fixed '0' rendering when upload button is hidden
- Fixed horizontal scrolling on small screens

- Implemented comprehensive theme system with gallery layouts
- Added 6 different gallery layouts: Grid, Masonry, Carousel, Timeline, Hero, Mosaic
- Created enhanced theme customizer with layout selection
- Added theme presets for different event types
- Updated event creation with theme preview and customization
- Fixed all TypeScript compilation errors

- Added missing translation keys for create event page
- Added translations for theme customization features

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-09 11:26:27 +02:00
parent d8fb4c9565
commit 6438374258
23 changed files with 2798 additions and 168 deletions
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Palette, RotateCcw, Check, Upload } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { PRESET_THEMES, type ThemeConfig } from '../../contexts/ThemeContext';
import { GALLERY_THEME_PRESETS, type ThemeConfig } from '../../contexts/ThemeContext';
import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify';
@@ -43,7 +43,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
};
const handlePresetSelect = (presetKey: string) => {
const preset = PRESET_THEMES[presetKey];
const preset = GALLERY_THEME_PRESETS[presetKey];
console.log('Selecting preset:', presetKey, preset); // Debug log
if (preset) {
setSelectedPreset(presetKey);
@@ -63,7 +63,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
};
const handleReset = () => {
const defaultPreset = PRESET_THEMES['default'];
const defaultPreset = GALLERY_THEME_PRESETS['default'];
if (defaultPreset) {
setSelectedPreset('default');
setLocalTheme(defaultPreset.config);
@@ -97,7 +97,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Preset Themes</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(PRESET_THEMES).map(([key, theme]) => (
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
<button
key={key}
onClick={() => handlePresetSelect(key)}
@@ -0,0 +1,587 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
// import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
interface ThemeCustomizerEnhancedProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
presetName?: string;
onPresetChange?: (presetName: string) => void;
isPreviewMode?: boolean;
showGalleryLayouts?: boolean;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
grid: <Grid3X3 className="w-5 h-5" />,
masonry: <Layers className="w-5 h-5" />,
carousel: <Play className="w-5 h-5" />,
timeline: <Clock className="w-5 h-5" />,
hero: <Image className="w-5 h-5" />,
mosaic: <LayoutGrid className="w-5 h-5" />
};
const layoutDescriptions: Record<GalleryLayoutType, string> = {
grid: 'Classic grid layout with consistent photo sizes',
masonry: 'Pinterest-style layout with varied heights',
carousel: 'Full-screen slideshow with navigation',
timeline: 'Photos organized by date',
hero: 'Featured image with grid below',
mosaic: 'Artistic layout with mixed sizes'
};
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
value,
onChange,
presetName = 'default',
onPresetChange,
isPreviewMode = false,
showGalleryLayouts = true
}) => {
const { t } = useTranslation();
t; // Use to prevent unused warning
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
// const logoInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLocalTheme(value);
setCustomCss(value.customCss || '');
}, [value]);
useEffect(() => {
setSelectedPreset(presetName);
}, [presetName]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
setLocalTheme(updated);
if (isPreviewMode) {
onChange(updated);
}
};
const handlePresetSelect = (presetKey: string) => {
const preset = GALLERY_THEME_PRESETS[presetKey];
if (preset) {
setSelectedPreset(presetKey);
setLocalTheme(preset.config);
if (onPresetChange) {
onPresetChange(presetKey);
}
if (isPreviewMode) {
onChange(preset.config);
}
}
};
const handleApply = () => {
onChange({ ...localTheme, customCss });
};
const handleReset = () => {
const defaultPreset = GALLERY_THEME_PRESETS['default'];
if (defaultPreset) {
setSelectedPreset('default');
setLocalTheme(defaultPreset.config);
setCustomCss('');
onChange(defaultPreset.config);
if (onPresetChange) {
onPresetChange('default');
}
}
};
// const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
// const file = e.target.files?.[0];
// if (file) {
// try {
// const logoUrl = await settingsService.uploadLogo(file);
// handleChange('logoUrl', logoUrl);
// toast.success('Logo uploaded successfully');
// } catch (error) {
// console.error('Failed to upload logo:', error);
// toast.error('Failed to upload logo');
// }
// }
// };
const updateGallerySettings = (key: string, value: any) => {
const updatedSettings = {
...localTheme.gallerySettings,
[key]: value
};
handleChange('gallerySettings', updatedSettings);
};
return (
<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">
<Sparkles className="w-5 h-5" />
Theme Presets
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
<button
key={key}
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'
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<span className="font-medium text-sm block">{theme.name}</span>
{theme.description && (
<span className="text-xs text-neutral-600 mt-1 block">{theme.description}</span>
)}
</div>
{selectedPreset === key && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<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"
style={{ backgroundColor: theme.config.primaryColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.accentColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200"
style={{ backgroundColor: theme.config.backgroundColor }}
/>
</div>
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
<div className="ml-auto text-neutral-400">
{layoutIcons[theme.config.galleryLayout]}
</div>
)}
</div>
</button>
))}
</div>
</Card>
{/* Gallery Layout */}
{showGalleryLayouts && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Layout className="w-5 h-5" />
Gallery Layout
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
<button
key={layout}
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'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700">
{layoutIcons[layout]}
</div>
<span className="font-medium text-sm capitalize">{layout}</span>
<span className="text-xs text-neutral-600 mt-1">
{layoutDescriptions[layout]}
</span>
</div>
{localTheme.galleryLayout === layout && (
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
)}
</button>
))}
</div>
{/* 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">Layout Settings</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">
Photo Spacing
</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"
>
<option value="tight">Tight</option>
<option value="normal">Normal</option>
<option value="relaxed">Relaxed</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Photo Animation
</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"
>
<option value="none">None</option>
<option value="fade">Fade</option>
<option value="scale">Scale</option>
<option value="slide">Slide</option>
</select>
</div>
</div>
{/* Grid specific */}
{localTheme.galleryLayout === 'grid' && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Columns
</label>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-neutral-600">Mobile</label>
<Input
type="number"
min="1"
max="4"
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
mobile: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600">Tablet</label>
<Input
type="number"
min="2"
max="6"
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
tablet: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600">Desktop</label>
<Input
type="number"
min="3"
max="8"
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
desktop: parseInt(e.target.value)
})}
/>
</div>
</div>
</div>
)}
{/* Carousel specific */}
{localTheme.galleryLayout === 'carousel' && (
<>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={localTheme.gallerySettings?.carouselAutoplay || false}
onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)}
className="rounded"
/>
<span className="text-sm font-medium text-neutral-700">Enable Autoplay</span>
</label>
</div>
{localTheme.gallerySettings?.carouselAutoplay && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Autoplay Interval (seconds)
</label>
<Input
type="number"
min="2"
max="10"
value={(localTheme.gallerySettings?.carouselInterval || 5000) / 1000}
onChange={(e) => updateGallerySettings('carouselInterval', parseInt(e.target.value) * 1000)}
/>
</div>
)}
</>
)}
{/* Timeline specific */}
{localTheme.galleryLayout === 'timeline' && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Group Photos By
</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"
>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
</div>
)}
</div>
)}
</Card>
)}
{/* Color Customization */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Palette className="w-5 h-5" />
Colors
</h3>
<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">
Primary Color
</label>
<div className="flex gap-2">
<input
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"
/>
<Input
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
placeholder="#5C8762"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Accent Color
</label>
<div className="flex gap-2">
<input
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"
/>
<Input
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
placeholder="#22c55e"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Background Color
</label>
<div className="flex gap-2">
<input
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"
/>
<Input
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
placeholder="#fafafa"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Text Color
</label>
<div className="flex gap-2">
<input
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"
/>
<Input
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
placeholder="#171717"
className="flex-1"
/>
</div>
</div>
</div>
</Card>
{/* Typography & Style */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Type className="w-5 h-5" />
Typography & Style
</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">
Body Font
</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"
>
<option value="Inter, sans-serif">Inter</option>
<option value="system-ui, sans-serif">System UI</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Playfair Display', serif">Playfair Display</option>
<option value="'Montserrat', sans-serif">Montserrat</option>
<option value="'IBM Plex Sans', sans-serif">IBM Plex Sans</option>
<option value="'Comic Neue', cursive">Comic Neue</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Heading Font
</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"
>
<option value="">Same as body</option>
<option value="'Playfair Display', serif">Playfair Display</option>
<option value="'Montserrat', sans-serif">Montserrat</option>
<option value="Georgia, serif">Georgia</option>
<option value="'IBM Plex Sans', sans-serif">IBM Plex Sans</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Font Size
</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"
>
<option value="small">Small</option>
<option value="normal">Normal</option>
<option value="large">Large</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Border Radius
</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"
>
<option value="none">None</option>
<option value="sm">Small</option>
<option value="md">Medium</option>
<option value="lg">Large</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Shadow Style
</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"
>
<option value="none">None</option>
<option value="subtle">Subtle</option>
<option value="normal">Normal</option>
<option value="dramatic">Dramatic</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Background
</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"
>
<option value="none">None</option>
<option value="dots">Dots</option>
<option value="grid">Grid</option>
<option value="waves">Waves</option>
</select>
</div>
</div>
</div>
</Card>
{/* Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Custom CSS</h3>
<textarea
value={customCss}
onChange={(e) => setCustomCss(e.target.value)}
placeholder="/* Add custom CSS here */"
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg"
/>
<p className="mt-2 text-sm text-neutral-600">
Advanced: Add custom CSS to further customize the appearance
</p>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
Reset to Default
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
>
Apply Theme
</Button>
</div>
</div>
);
};
@@ -51,74 +51,90 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Header */}
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
<div className="container py-3 sm:py-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-start sm:items-center gap-3 sm:gap-4">
{/* Company logo */}
{brandingSettings?.logo_url && (
<div className="hidden sm:block pr-4 border-r border-neutral-200 flex-shrink-0">
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}`}
alt={brandingSettings.company_name || 'Company Logo'}
className="h-10 sm:h-12 w-auto object-contain"
/>
</div>
)}
{/* Company branding */}
{!brandingSettings?.logo_url && brandingSettings?.company_name && (
<div className="hidden sm:block pr-4 border-r border-neutral-200 flex-shrink-0">
<h2 className="text-base sm:text-lg font-semibold text-neutral-800">{brandingSettings.company_name}</h2>
{brandingSettings.company_tagline && (
<p className="hidden lg:block text-xs text-neutral-600">{brandingSettings.company_tagline}</p>
)}
</div>
)}
<div className="flex-1">
<h1 className="text-xl sm:text-2xl font-bold text-neutral-900 leading-tight">{event.event_name}</h1>
{(event.event_date || event.expires_at) && (
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 mt-1 text-xs sm:text-sm text-neutral-600">
{event.event_date && (
<span className="flex items-center">
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
<span className="truncate">{format(parseISO(event.event_date), 'PP')}</span>
</span>
)}
{event.expires_at && (
<span className="flex items-center">
<Clock className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
<span className="truncate">{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
</span>
)}
<div className="container py-3">
<div className="flex flex-col gap-3">
{/* Top row - Title and mobile logout */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Logo - Mobile optimized */}
{brandingSettings?.logo_url && (
<div className="flex-shrink-0">
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}`}
alt={brandingSettings.company_name || 'Company Logo'}
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
/>
</div>
)}
{/* Event info */}
<div className="flex-1 min-w-0">
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold text-neutral-900 leading-tight truncate">
{event.event_name}
</h1>
{(event.event_date || event.expires_at) && (
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs text-neutral-600">
{event.event_date && (
<span className="flex items-center">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{format(parseISO(event.event_date), 'PP')}</span>
</span>
)}
{event.expires_at && (
<span className="flex items-center">
<Clock className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
</span>
)}
</div>
)}
</div>
</div>
{/* Mobile logout button - top right */}
{showLogout && onLogout && (
<Button
variant="ghost"
size="sm"
onClick={onLogout}
className="sm:hidden p-2"
title={t('common.logout')}
>
<LogOut className="w-5 h-5" />
</Button>
)}
</div>
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
{headerExtra}
{/* Action buttons row */}
<div className="flex items-center gap-2">
{/* Header extra content (upload button, countdown) */}
{headerExtra && headerExtra}
{/* Download all button */}
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="md"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="flex-1 sm:flex-initial text-sm sm:text-base"
className="flex-1 sm:flex-initial"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{/* Desktop logout button */}
{showLogout && onLogout && (
<Button
variant="outline"
size="md"
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="text-sm sm:text-base"
className="hidden sm:flex"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
<span className="sm:hidden"><LogOut className="w-4 h-4" /></span>
{t('common.logout')}
</Button>
)}
</div>
+21 -19
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Button, SkeletonGalleryGrid, Skeleton } from '../common';
import { useGalleryAuth, useTheme } from '../../contexts';
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
import { PhotoGrid } from './PhotoGrid';
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
import { ExpirationBanner } from './ExpirationBanner';
import { CountdownTimer } from './CountdownTimer';
import { GalleryLayout } from './GalleryLayout';
@@ -226,23 +226,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onDownloadAll={handleDownloadAll}
isDownloading={downloadAllMutation.isPending}
headerExtra={
<>
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
<CountdownTimer expiresAt={event.expires_at} className="mr-4" />
)}
{event.allow_user_uploads && (
<Button
variant="outline"
size="md"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
className="mr-2 text-sm sm:text-base"
>
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
<span className="sm:hidden">{t('common.upload')}</span>
</Button>
)}
</>
(daysUntilExpiration <= 1 && daysUntilExpiration > 0) || event.allow_user_uploads ? (
<>
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
<CountdownTimer expiresAt={event.expires_at} className="mr-2" />
)}
{event.allow_user_uploads && (
<Button
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
className="flex-1 sm:flex-initial"
>
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
<span className="sm:hidden">{t('common.upload')}</span>
</Button>
)}
</>
) : null
}
>
{/* Expiration Banner */}
@@ -267,7 +269,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
{/* Photo Grid */}
<div className="mt-6">
<PhotoGrid photos={filteredPhotos} slug={slug} categoryId={selectedCategoryId} />
<PhotoGridWithLayouts photos={filteredPhotos} slug={slug} categoryId={selectedCategoryId} />
</div>
</div>
@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import { Package } from 'lucide-react';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { PhotoLightbox } from './PhotoLightbox';
import { Button } from '../common';
import { galleryService } from '../../services/gallery.service';
import { analyticsService } from '../../services/analytics.service';
import { useTheme } from '../../contexts/ThemeContext';
// Import all layouts
import {
GridGalleryLayout,
MasonryGalleryLayout,
CarouselGalleryLayout,
TimelineGalleryLayout,
HeroGalleryLayout,
MosaicGalleryLayout,
} from './layouts';
interface PhotoGridWithLayoutsProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos,
slug,
categoryId
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
// Clear selection when category changes
useEffect(() => {
setSelectedPhotos(new Set());
}, [categoryId]);
const handlePhotoClick = (index: number) => {
setSelectedPhotoIndex(index);
};
const handlePhotoSelect = (photoId: number) => {
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
};
const handleDownload = (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
// Track individual photo download
analyticsService.trackDownload(photo.id, slug, false);
downloadPhotoMutation.mutate({
slug,
photoId: photo.id,
filename: photo.filename,
});
};
const toggleSelectionMode = () => {
setIsSelectionMode(!isSelectionMode);
setSelectedPhotos(new Set());
};
const selectAll = () => {
setSelectedPhotos(new Set(photos.map(p => p.id)));
};
const deselectAll = () => {
setSelectedPhotos(new Set());
};
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
// Download each selected photo
const downloadPromises = selectedPhotosList.map(photo =>
galleryService.downloadPhoto(slug, photo.id, photo.filename)
.catch(err => {
console.error(`Failed to download ${photo.filename}:`, err);
return null;
})
);
try {
await Promise.all(downloadPromises);
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: selectedPhotos.size
});
// Clear selection after download
setSelectedPhotos(new Set());
setIsSelectionMode(false);
} catch (error) {
toastify.error(t('gallery.downloadError'));
}
};
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
</div>
);
}
// Get the current layout from theme
const galleryLayout = theme.galleryLayout || 'grid';
// Select the appropriate layout component
const layoutProps = {
photos,
slug,
onPhotoClick: handlePhotoClick,
onDownload: handleDownload,
selectedPhotos,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
};
let LayoutComponent;
switch (galleryLayout) {
case 'masonry':
LayoutComponent = MasonryGalleryLayout;
break;
case 'carousel':
LayoutComponent = CarouselGalleryLayout;
break;
case 'timeline':
LayoutComponent = TimelineGalleryLayout;
break;
case 'hero':
LayoutComponent = HeroGalleryLayout;
break;
case 'mosaic':
LayoutComponent = MosaicGalleryLayout;
break;
default:
LayoutComponent = GridGalleryLayout;
}
return (
<>
{/* Selection Mode Controls - Not shown for carousel layout */}
{photos.length > 1 && galleryLayout !== 'carousel' && (
<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
variant="outline"
size="sm"
onClick={toggleSelectionMode}
title={t('gallery.selectPhotosHint')}
className="text-xs sm:text-sm"
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{!isSelectionMode && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsSelectionMode(true);
selectAll();
}}
className="text-xs sm:text-sm"
>
{t('gallery.selectAll')}
</Button>
)}
</div>
{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">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="ghost" size="sm" onClick={selectAll} className="text-xs sm:text-sm">
{t('gallery.selectAll')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll} className="text-xs sm:text-sm">
{t('gallery.deselectAll')}
</Button>
{selectedPhotos.size > 0 && (
<Button
variant="primary"
size="sm"
leftIcon={<Package className="w-4 h-4" />}
onClick={handleDownloadSelected}
className="text-xs sm:text-sm"
>
<span className="hidden sm:inline">{t('gallery.downloadSelected', { count: selectedPhotos.size })}</span>
<span className="sm:hidden">{t('common.download')} ({selectedPhotos.size})</span>
</Button>
)}
</div>
</div>
)}
</div>
)}
{/* Render the selected layout */}
<LayoutComponent {...layoutProps} />
{/* Lightbox */}
{selectedPhotoIndex !== null && (
<PhotoLightbox
photos={photos}
initialIndex={selectedPhotoIndex}
onClose={() => setSelectedPhotoIndex(null)}
slug={slug}
/>
)}
</>
);
};
@@ -0,0 +1,16 @@
import React from 'react';
import type { Photo } from '../../../types';
export interface BaseGalleryLayoutProps {
photos: Photo[];
slug: string;
onPhotoClick: (index: number) => void;
onDownload: (photo: Photo, e: React.MouseEvent) => void;
selectedPhotos?: Set<number>;
isSelectionMode?: boolean;
onPhotoSelect?: (photoId: number) => void;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
abstract render(): React.ReactNode;
}
@@ -0,0 +1,187 @@
import React, { useState, useEffect, useRef } from 'react';
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage, Button } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
// selectedPhotos = new Set(),
// isSelectionMode = false
}) => {
const { theme } = useTheme();
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const gallerySettings = theme.gallerySettings || {};
const autoplay = gallerySettings.carouselAutoplay || false;
const interval = gallerySettings.carouselInterval || 5000;
const showThumbnails = gallerySettings.carouselShowThumbnails !== false;
// Auto-play functionality
useEffect(() => {
if (isPlaying && photos.length > 1) {
intervalRef.current = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % photos.length);
}, interval);
} else if (intervalRef.current) {
clearInterval(intervalRef.current);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPlaying, photos.length, interval]);
// Start autoplay if enabled
useEffect(() => {
if (autoplay) {
setIsPlaying(true);
}
}, [autoplay]);
const goToPrevious = () => {
setCurrentIndex((prev) => (prev - 1 + photos.length) % photos.length);
};
const goToNext = () => {
setCurrentIndex((prev) => (prev + 1) % photos.length);
};
const togglePlayPause = () => {
setIsPlaying(!isPlaying);
};
if (photos.length === 0) return null;
const currentPhoto = photos[currentIndex];
return (
<div className="relative">
{/* Main Carousel */}
<div className="relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
<AuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="w-full h-full object-contain"
isGallery={true}
/>
{/* Navigation Controls */}
<div className="absolute inset-0 flex items-center justify-between p-4">
<button
onClick={goToPrevious}
className="p-2 bg-black/50 text-white rounded-full hover:bg-black/70 transition-colors"
aria-label="Previous photo"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={goToNext}
className="p-2 bg-black/50 text-white rounded-full hover:bg-black/70 transition-colors"
aria-label="Next photo"
>
<ChevronRight className="w-6 h-6" />
</button>
</div>
{/* Top Controls */}
<div className="absolute top-4 left-4 right-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="px-3 py-1 bg-black/50 text-white rounded-full text-sm">
{currentIndex + 1} / {photos.length}
</span>
{currentPhoto.category_name && (
<span className="px-3 py-1 bg-black/50 text-white rounded-full text-sm">
{currentPhoto.category_name}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={togglePlayPause}
className="text-white hover:bg-white/20"
title={isPlaying ? 'Pause slideshow' : 'Play slideshow'}
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onPhotoClick(currentIndex)}
className="text-white hover:bg-white/20"
title="View fullscreen"
>
<Maximize2 className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => onDownload(currentPhoto, e)}
className="text-white hover:bg-white/20"
title="Download photo"
>
<Download className="w-5 h-5" />
</Button>
</div>
</div>
{/* Progress Bar */}
{isPlaying && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/20">
<div
className="h-full bg-white transition-all duration-1000 ease-linear"
style={{
width: '100%',
animation: `progress ${interval}ms linear infinite`
}}
/>
</div>
)}
</div>
{/* Thumbnails */}
{showThumbnails && photos.length > 1 && (
<div className="mt-4 relative">
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-neutral-400">
{photos.map((photo, index) => (
<button
key={photo.id}
onClick={() => setCurrentIndex(index)}
className={`relative flex-shrink-0 w-20 h-20 rounded overflow-hidden transition-all ${
index === currentIndex
? 'ring-2 ring-primary-600 scale-110'
: 'opacity-70 hover:opacity-100'
}`}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
isGallery={true}
/>
</button>
))}
</div>
</div>
)}
<style>{`
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}
`}</style>
</div>
);
};
@@ -0,0 +1,146 @@
import React from 'react';
import { Download, Maximize2, Check } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
interface GridPhotoProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
animationType?: string;
}
const GridPhoto: React.FC<GridPhotoProps> = ({
photo,
isSelected,
isSelectionMode,
onClick,
onDownload,
animationType = 'fade'
}) => {
const { ref, inView } = useInView({
triggerOnce: true,
threshold: 0.1,
});
const animationClass = animationType === 'scale'
? 'transition-transform duration-300 hover:scale-105'
: animationType === 'fade'
? 'transition-opacity duration-300'
: '';
return (
<div
ref={ref}
className={`relative group cursor-pointer aspect-square ${animationClass}`}
onClick={onClick}
style={{
opacity: !inView && animationType === 'fade' ? 0 : 1
}}
>
{inView ? (
<>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
isGallery={true}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
</>
)}
</div>
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</div>
)}
</>
) : (
<div className="skeleton aspect-square w-full rounded-lg" />
)}
</div>
);
};
export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
const { theme } = useTheme();
const gallerySettings = theme.gallerySettings || {};
const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
const spacing = gallerySettings.spacing || 'normal';
const animation = gallerySettings.photoAnimation || 'fade';
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
const gridClass = `grid ${spacingClass}
grid-cols-${columns.mobile}
sm:grid-cols-${columns.tablet}
lg:grid-cols-${columns.desktop}
xl:grid-cols-${columns.desktop + 1}`;
return (
<div className={gridClass}>
{photos.map((photo, index) => (
<GridPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(index);
}
}}
onDownload={(e) => onDownload(photo, e)}
animationType={animation}
/>
))}
</div>
);
};
@@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, ChevronDown } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage, Button } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
export const HeroGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
const gallerySettings = theme.gallerySettings || {};
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
// Select hero photo (first photo or specified one)
useEffect(() => {
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
const hero = heroId ? photos.find(p => p.id === heroId) : photos[0];
setHeroPhoto(hero || photos[0]);
}
}, [photos, gallerySettings.heroImageId]);
if (!heroPhoto) return null;
const remainingPhotos = photos.filter(p => p.id !== heroPhoto.id);
return (
<div className="relative">
{/* Hero Section */}
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
<AuthenticatedImage
src={heroPhoto.url}
alt={heroPhoto.filename}
className="w-full h-full object-cover"
isGallery={true}
/>
{/* Overlay */}
<div
className="absolute inset-0 bg-black"
style={{ opacity: overlayOpacity }}
/>
{/* Hero Content */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-white px-4">
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4 drop-shadow-lg">
{heroPhoto.category_name || 'Featured Photo'}
</h1>
<div className="flex items-center justify-center gap-4">
<Button
variant="primary"
size="lg"
leftIcon={<Maximize2 className="w-5 h-5" />}
onClick={() => onPhotoClick(0)}
className="bg-white/20 backdrop-blur-sm hover:bg-white/30"
>
View Gallery
</Button>
<Button
variant="outline"
size="lg"
leftIcon={<Download className="w-5 h-5" />}
onClick={(e) => onDownload(heroPhoto, e)}
className="border-white text-white hover:bg-white/20"
>
Download
</Button>
</div>
</div>
</div>
{/* Scroll Indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</div>
</div>
{/* Grid Section */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{remainingPhotos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="relative group cursor-pointer aspect-square"
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(actualIndex);
}
}}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onPhotoClick(actualIndex);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onDownload(photo, e);
}}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
</>
)}
</div>
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,167 @@
import React, { useEffect, useRef, useState } from 'react';
import { Download, Maximize2, Check } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
interface MasonryPhotoProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
photo,
isSelected,
isSelectionMode,
onClick,
onDownload,
style
}) => {
const [imageHeight, setImageHeight] = useState<number>(200);
// Generate random heights for masonry effect
useEffect(() => {
const heights = [200, 250, 300, 350, 400];
const randomHeight = heights[Math.floor(Math.random() * heights.length)];
setImageHeight(randomHeight);
}, [photo.id]);
return (
<div
className="relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
onClick={onClick}
style={{
...style,
height: `${imageHeight}px`,
breakInside: 'avoid'
}}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
isGallery={true}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
</>
)}
</div>
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</div>
)}
</div>
);
};
export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
const { theme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(3);
const gallerySettings = theme.gallerySettings || {};
const gutter = gallerySettings.masonryGutter || 16;
// Calculate number of columns based on container width
useEffect(() => {
const updateColumns = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
if (width < 640) setColumns(2);
else if (width < 1024) setColumns(3);
else if (width < 1280) setColumns(4);
else setColumns(5);
}
};
updateColumns();
window.addEventListener('resize', updateColumns);
return () => window.removeEventListener('resize', updateColumns);
}, []);
// Distribute photos across columns
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
photos.forEach((photo, index) => {
photoColumns[index % columns].push(photo);
});
return (
<div
ref={containerRef}
className="flex gap-4"
style={{ gap: `${gutter}px` }}
>
{photoColumns.map((column, columnIndex) => (
<div
key={columnIndex}
className="flex-1 flex flex-col"
style={{ gap: `${gutter}px` }}
>
{column.map((photo) => {
const originalIndex = photos.findIndex(p => p.id === photo.id);
return (
<MasonryPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(originalIndex);
}
}}
onDownload={(e) => onDownload(photo, e)}
/>
);
})}
</div>
))}
</div>
);
};
@@ -0,0 +1,222 @@
import React from 'react';
import { Download, Maximize2, Check } from 'lucide-react';
// import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
interface MosaicPhotoProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
className?: string;
}
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
photo,
isSelected,
isSelectionMode,
onClick,
onDownload,
className = ''
}) => {
return (
<div
className={`relative group cursor-pointer overflow-hidden ${className}`}
onClick={onClick}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
isGallery={true}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
</>
)}
</div>
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</div>
)}
</div>
);
};
export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
// const { theme } = useTheme();
// const gallerySettings = theme.gallerySettings || {};
// const pattern = gallerySettings.mosaicPattern || 'structured';
// Create mosaic patterns
const renderStructuredPattern = () => {
const patterns = [
// Pattern 1: Large left, 2 small right
<div key="pattern1" className="grid grid-cols-2 gap-2 h-96">
{photos[0] && (
<MosaicPhoto
photo={photos[0]}
isSelected={selectedPhotos.has(photos[0].id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(0, photos[0].id)}
onDownload={(e) => onDownload(photos[0], e)}
className="col-span-1 row-span-2"
/>
)}
<div className="grid grid-rows-2 gap-2">
{photos[1] && (
<MosaicPhoto
photo={photos[1]}
isSelected={selectedPhotos.has(photos[1].id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(1, photos[1].id)}
onDownload={(e) => onDownload(photos[1], e)}
/>
)}
{photos[2] && (
<MosaicPhoto
photo={photos[2]}
isSelected={selectedPhotos.has(photos[2].id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(2, photos[2].id)}
onDownload={(e) => onDownload(photos[2], e)}
/>
)}
</div>
</div>,
// Pattern 2: 3 equal columns
<div key="pattern2" className="grid grid-cols-3 gap-2 h-64">
{photos.slice(3, 6).map((photo, idx) => {
const index = idx + 3;
return photo ? (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(index, photo.id)}
onDownload={(e) => onDownload(photo, e)}
/>
) : null;
})}
</div>,
// Pattern 3: Large center with sides
<div key="pattern3" className="grid grid-cols-3 gap-2 h-96">
{photos[6] && (
<MosaicPhoto
photo={photos[6]}
isSelected={selectedPhotos.has(photos[6].id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(6, photos[6].id)}
onDownload={(e) => onDownload(photos[6], e)}
/>
)}
{photos[7] && (
<MosaicPhoto
photo={photos[7]}
isSelected={selectedPhotos.has(photos[7].id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(7, photos[7].id)}
onDownload={(e) => onDownload(photos[7], e)}
className="row-span-2"
/>
)}
{photos[8] && (
<MosaicPhoto
photo={photos[8]}
isSelected={selectedPhotos.has(photos[8].id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(8, photos[8].id)}
onDownload={(e) => onDownload(photos[8], e)}
/>
)}
</div>
];
return patterns;
};
const handlePhotoClick = (index: number, photoId: number) => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photoId);
} else {
onPhotoClick(index);
}
};
// For now, we'll use the structured pattern
// You can implement random and alternating patterns as needed
const mosaicElements = renderStructuredPattern();
// Add remaining photos in a regular grid
const remainingPhotos = photos.slice(9);
return (
<div className="space-y-2">
{mosaicElements}
{remainingPhotos.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{remainingPhotos.map((photo, idx) => {
const index = idx + 9;
return (
<MosaicPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(index, photo.id)}
onDownload={(e) => onDownload(photo, e)}
className="aspect-square"
/>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,156 @@
import React, { useMemo } from 'react';
import { Download, Maximize2, Check, Calendar } from 'lucide-react';
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect
}) => {
const { theme } = useTheme();
const gallerySettings = theme.gallerySettings || {};
const grouping = gallerySettings.timelineGrouping || 'day';
const showDates = gallerySettings.timelineShowDates !== false;
// Group photos by date
const groupedPhotos = useMemo(() => {
const groups = new Map<string, Photo[]>();
photos.forEach(photo => {
const date = parseISO(photo.uploaded_at);
let groupKey: string;
switch (grouping) {
case 'week':
const weekStart = startOfWeek(date);
groupKey = format(weekStart, 'yyyy-MM-dd');
// groupLabel = `Week of ${format(weekStart, 'MMM d, yyyy')}`;
break;
case 'month':
const monthStart = startOfMonth(date);
groupKey = format(monthStart, 'yyyy-MM');
// groupLabel = format(monthStart, 'MMMM yyyy');
break;
default: // day
const dayStart = startOfDay(date);
groupKey = format(dayStart, 'yyyy-MM-dd');
// groupLabel = format(dayStart, 'EEEE, MMMM d, yyyy');
}
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(photo);
});
// Convert to array and sort by date
return Array.from(groups.entries())
.map(([date, photos]) => ({
date,
label: photos[0] ? format(parseISO(photos[0].uploaded_at), grouping === 'month' ? 'MMMM yyyy' : grouping === 'week' ? "'Week of' MMM d, yyyy" : 'EEEE, MMMM d, yyyy') : date,
photos: photos.sort((a, b) => new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime())
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [photos, grouping]);
return (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-neutral-300 hidden lg:block" />
{/* Timeline groups */}
<div className="space-y-12">
{groupedPhotos.map((group) => (
<div key={group.date} className="relative">
{/* Date marker */}
{showDates && (
<div className="flex items-center gap-4 mb-6">
<div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-primary-600 rounded-full z-10">
<Calendar className="w-6 h-6 text-primary-600" />
</div>
<h3 className="text-xl font-semibold text-neutral-800">
{group.label}
</h3>
</div>
)}
{/* Photos grid for this date */}
<div className="lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{group.photos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="relative group cursor-pointer aspect-square"
onClick={() => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(actualIndex);
}
}}
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
isGallery={true}
/>
{/* Time label */}
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
{format(parseISO(photo.uploaded_at), 'h:mm a')}
</div>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onPhotoClick(actualIndex);
}}
aria-label="View full size"
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onDownload(photo, e);
}}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
</>
)}
</div>
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,7 @@
export { GridGalleryLayout } from './GridGalleryLayout';
export { MasonryGalleryLayout } from './MasonryGalleryLayout';
export { CarouselGalleryLayout } from './CarouselGalleryLayout';
export { TimelineGalleryLayout } from './TimelineGalleryLayout';
export { HeroGalleryLayout } from './HeroGalleryLayout';
export { MosaicGalleryLayout } from './MosaicGalleryLayout';
export type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
+47 -76
View File
@@ -1,78 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
export interface ThemeConfig {
primaryColor?: string;
accentColor?: string;
backgroundColor?: string;
textColor?: string;
fontFamily?: string;
borderRadius?: 'none' | 'sm' | 'md' | 'lg';
logoUrl?: string;
customCss?: string;
}
export interface EventTheme {
name: string;
config: ThemeConfig;
}
// Predefined themes
export const PRESET_THEMES: Record<string, EventTheme> = {
default: {
name: 'Default',
config: {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717',
borderRadius: 'md',
}
},
wedding: {
name: 'Wedding',
config: {
primaryColor: '#c9a961',
accentColor: '#e6ddd4',
backgroundColor: '#fdfcfb',
textColor: '#3f3f3f',
borderRadius: 'lg',
fontFamily: 'Georgia, serif',
}
},
birthday: {
name: 'Birthday',
config: {
primaryColor: '#ec4899',
accentColor: '#fbbf24',
backgroundColor: '#fef3c7',
textColor: '#451a03',
borderRadius: 'lg',
}
},
corporate: {
name: 'Corporate',
config: {
primaryColor: '#3b82f6',
accentColor: '#1e40af',
backgroundColor: '#f8fafc',
textColor: '#0f172a',
borderRadius: 'sm',
fontFamily: 'Inter, sans-serif',
}
},
minimal: {
name: 'Minimal',
config: {
primaryColor: '#000000',
accentColor: '#666666',
backgroundColor: '#ffffff',
textColor: '#000000',
borderRadius: 'none',
fontFamily: 'Helvetica, Arial, sans-serif',
}
}
};
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
interface ThemeContextType {
theme: ThemeConfig;
@@ -101,7 +29,7 @@ interface ThemeProviderProps {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
children,
initialTheme = PRESET_THEMES.default.config,
initialTheme = GALLERY_THEME_PRESETS.default.config,
initialThemeName = 'default'
}) => {
const [theme, setTheme] = useState<ThemeConfig>(initialTheme);
@@ -134,6 +62,10 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
root.style.setProperty('--font-family', themeConfig.fontFamily);
}
if (themeConfig.headingFontFamily) {
root.style.setProperty('--heading-font-family', themeConfig.headingFontFamily);
}
if (themeConfig.borderRadius) {
const radiusMap = {
none: '0',
@@ -144,6 +76,41 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
root.style.setProperty('--border-radius', radiusMap[themeConfig.borderRadius]);
}
// Apply font size
if (themeConfig.fontSize) {
const sizeMap = {
small: '14px',
normal: '16px',
large: '18px',
};
root.style.setProperty('--font-size-base', sizeMap[themeConfig.fontSize]);
}
// Apply shadow style
if (themeConfig.shadowStyle) {
const shadowMap = {
none: 'none',
subtle: '0 1px 3px rgba(0,0,0,0.12)',
normal: '0 4px 6px rgba(0,0,0,0.1)',
dramatic: '0 10px 25px rgba(0,0,0,0.15)',
};
root.style.setProperty('--shadow-default', shadowMap[themeConfig.shadowStyle]);
}
// Apply background pattern
if (themeConfig.backgroundPattern && themeConfig.backgroundPattern !== 'none') {
const patternMap = {
dots: `radial-gradient(circle, ${themeConfig.textColor}20 1px, transparent 1px)`,
grid: `linear-gradient(${themeConfig.textColor}10 1px, transparent 1px), linear-gradient(90deg, ${themeConfig.textColor}10 1px, transparent 1px)`,
waves: `url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21.184 20c.357-.13.72-.264 1.088-.402l1.768-.661C33.64 15.347 39.647 14 50 14c10.271 0 15.362 1.222 24.629 4.928.955.383 1.869.74 2.75 1.072h6.225c-2.51-.73-5.139-1.691-8.233-2.928C65.888 13.278 60.562 12 50 12c-10.626 0-16.855 1.397-26.66 5.063l-1.767.662c-2.475.923-4.66 1.674-6.724 2.275h6.335zm0-20C13.258 2.892 8.077 4 0 4V2c5.744 0 9.951-.574 14.85-2h6.334zM77.38 0C85.239 2.966 90.502 4 100 4V2c-6.842 0-11.386-.542-16.396-2h-6.225zM0 14c8.44 0 13.718-1.21 22.272-4.402l1.768-.661C33.64 5.347 39.647 4 50 4c10.271 0 15.362 1.222 24.629 4.928C84.112 12.722 89.438 14 100 14v-2c-10.271 0-15.362-1.222-24.629-4.928C65.888 3.278 60.562 2 50 2 39.374 2 33.145 3.397 23.34 7.063l-1.767.662C13.223 10.84 8.163 12 0 12v2z' fill='${themeConfig.textColor}' fill-opacity='0.05'/%3E%3C/svg%3E")`,
};
root.style.setProperty('--background-pattern', patternMap[themeConfig.backgroundPattern]);
root.style.setProperty('--background-pattern-size', themeConfig.backgroundPattern === 'dots' ? '20px 20px' : themeConfig.backgroundPattern === 'grid' ? '20px 20px' : '100px 20px');
} else {
root.style.removeProperty('--background-pattern');
root.style.removeProperty('--background-pattern-size');
}
// Apply custom CSS if provided
if (themeConfig.customCss) {
let styleElement = document.getElementById('custom-theme-styles');
@@ -162,7 +129,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
}, [applyTheme]);
const setThemeByName = useCallback((name: string) => {
const presetTheme = PRESET_THEMES[name];
const presetTheme = GALLERY_THEME_PRESETS[name];
if (presetTheme) {
setThemeName(name);
setTheme(presetTheme.config);
@@ -250,4 +217,8 @@ function darkenColor(color: string, percent: number): string {
return '#' + (0x1000000 + (R > 0 ? R : 0) * 0x10000 +
(G > 0 ? G : 0) * 0x100 +
(B > 0 ? B : 0)).toString(16).slice(1);
}
}
// Re-export types
export type { ThemeConfig, EventTheme };
export { GALLERY_THEME_PRESETS };
+2 -1
View File
@@ -1,5 +1,6 @@
export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext';
export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
export { ThemeProvider, useTheme, PRESET_THEMES } from './ThemeContext';
export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
+13 -2
View File
@@ -29,7 +29,9 @@
"preview": "Vorschau",
"processing": "Wird verarbeitet...",
"upload": "Hochladen",
"days": "Tage"
"days": "Tage",
"customize": "Anpassen",
"hide": "Ausblenden"
},
"upload": {
"photoCategory": "Fotokategorie",
@@ -163,6 +165,7 @@
},
"events": {
"title": "Veranstaltungen",
"create": "Veranstaltung erstellen",
"createEvent": "Veranstaltung erstellen",
"eventDetails": "Veranstaltungsdetails",
"eventName": "Veranstaltungsname",
@@ -221,16 +224,21 @@
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
"securityAccess": "Sicherheit & Zugriff",
"galleryPassword": "Galerie-Passwort",
"passwordPlaceholder": "Sicheres Passwort eingeben",
"confirmPassword": "Passwort bestätigen",
"showPasswords": "Passwörter anzeigen",
"gallerySettings": "Galerie-Einstellungen",
"colorTheme": "Farbthema",
"galleryExpiration": "Galerie-Ablauf",
"galleryExpiresIn": "Galerie läuft ab in",
"daysAfterEvent": "Tage nach der Veranstaltung",
"themeAndStyle": "Design & Stil",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"userUploads": "Benutzer-Upload-Einstellungen",
"allowUserUploads": "Gästen erlauben, Fotos hochzuladen",
"allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
"allowUserUploadsDescription": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
"uploadCategory": "Upload-Kategorie",
"selectCategory": "Wählen Sie eine Kategorie für Benutzer-Uploads",
"uploadCategoryHelp": "Alle von Benutzern hochgeladenen Fotos werden dieser Kategorie hinzugefügt",
@@ -253,6 +261,7 @@
"hostEmailPlaceholder": "gastgeber@beispiel.de",
"adminEmailPlaceholder": "admin@beispiel.de",
"securityAndAccess": "Sicherheit & Zugriff",
"accessAndSecurity": "Zugriff & Sicherheit",
"enterPassword": "Passwort eingeben",
"confirmPasswordPlaceholder": "Passwort bestätigen",
"galleryExpiresOn": "Galerie läuft ab am {{date}}",
@@ -512,7 +521,9 @@
"oopsSomethingWentWrong": "Ups! Etwas ist schiefgelaufen",
"unexpectedError": "Es ist ein unerwarteter Fehler aufgetreten. Keine Sorge, Ihre Daten sind sicher.",
"goToHomepage": "Zur Startseite",
"errorDetails": "Fehlerdetails"
"errorDetails": "Fehlerdetails",
"failedToCreateEvent": "Fehler beim Erstellen der Veranstaltung",
"eventCreationFailed": "Fehler beim Erstellen der Veranstaltung"
},
"legal": {
"impressum": "Impressum",
+4 -1
View File
@@ -29,7 +29,9 @@
"preview": "Preview",
"processing": "Processing...",
"upload": "Upload",
"days": "days"
"days": "days",
"customize": "Customize",
"hide": "Hide"
},
"upload": {
"photoCategory": "Photo Category",
@@ -575,6 +577,7 @@
"requiredFields": "Please fill in all required fields",
"enterTestEmail": "Please enter a test email address",
"failedToCreateEvent": "Failed to create event",
"eventCreationFailed": "Failed to create event",
"networkError": "Network error. Please check your connection and try again.",
"sessionExpired": "Session expired. Please login again."
},
+28 -3
View File
@@ -14,7 +14,10 @@
--color-background: #fafafa;
--color-text: #171717;
--font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif;
--heading-font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif;
--border-radius: 0.5rem;
--font-size-base: 16px;
--shadow-default: 0 4px 6px rgba(0,0,0,0.1);
/* Tailwind RGB values for primary color */
--tw-color-primary: 92 135 98;
@@ -28,7 +31,23 @@
body {
background-color: var(--color-background);
color: var(--color-text);
font-size: var(--font-size-base);
@apply antialiased;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: var(--background-pattern);
background-size: var(--background-pattern-size);
opacity: 1;
pointer-events: none;
z-index: -1;
}
/* Custom scrollbar */
@@ -94,16 +113,22 @@
/* Card styles */
.card {
@apply rounded-xl border border-neutral-200 bg-white shadow-soft;
@apply rounded-xl border border-neutral-200 bg-white;
box-shadow: var(--shadow-default);
}
.card-hover {
@apply card transition-all duration-200 hover:shadow-medium hover:translate-y-[-2px];
@apply card transition-all duration-200 hover:translate-y-[-2px];
}
/* Headings with custom font */
h1, h2, h3, h4, h5, h6 {
font-family: var(--heading-font-family);
}
/* Container */
.container {
@apply mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl;
@apply mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl w-full;
}
/* Image loading skeleton */
+11 -11
View File
@@ -227,22 +227,22 @@ export const GalleryPage: React.FC = () => {
// Show login form
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center p-4 sm:p-6 lg:p-8">
<div className="w-full max-w-md">
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-lg">
{/* Logo/Header */}
<div className="text-center mb-6 sm:mb-8">
<div className="text-center mb-4 sm:mb-6">
{settingsData?.branding_logo_url ? (
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 sm:h-20 w-auto object-contain mx-auto mb-4"
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-2xl mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Camera className="w-8 h-8 sm:w-10 sm:h-10 text-white" />
<div className="inline-flex items-center justify-center w-12 h-12 sm:w-16 sm:h-16 lg:w-20 lg:h-20 rounded-2xl mb-3 sm:mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Camera className="w-6 h-6 sm:w-8 sm:h-8 lg:w-10 lg:h-10 text-white" />
</div>
)}
<h1 className="text-2xl sm:text-3xl font-bold mb-2 px-4" style={{ color: 'var(--color-text, #171717)' }}>
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
@@ -253,7 +253,7 @@ export const GalleryPage: React.FC = () => {
{/* Expiration Warning */}
{daysUntilExpiration !== null && daysUntilExpiration <= 7 && (
<div className="mb-4 sm:mb-6 p-3 sm:p-4 bg-amber-50 border border-amber-200 rounded-lg mx-4 sm:mx-0">
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 mt-0.5 mr-2 flex-shrink-0" />
<div>
@@ -269,9 +269,9 @@ export const GalleryPage: React.FC = () => {
)}
{/* Login Card */}
<Card className="mx-4 sm:mx-0">
<CardContent className="p-5 sm:p-6">
<h2 className="text-lg sm:text-xl font-semibold mb-4 sm:mb-6">{t('auth.enterPassword')}</h2>
<Card>
<CardContent className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
+3 -3
View File
@@ -3,7 +3,7 @@ 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 { useTheme, type ThemeConfig, PRESET_THEMES } from '../../contexts/ThemeContext';
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
import { useQuery, useMutation } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
@@ -84,7 +84,7 @@ export const BrandingPage: React.FC = () => {
setTheme(themeWithLogo);
// Try to identify which preset this matches
for (const [key, preset] of Object.entries(PRESET_THEMES)) {
for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) {
if (JSON.stringify(preset.config) === JSON.stringify(formatted)) {
setCurrentThemeName(key);
break;
@@ -112,7 +112,7 @@ export const BrandingPage: React.FC = () => {
const handlePresetChange = (presetName: string) => {
setCurrentThemeName(presetName);
// Get the preset theme config
const preset = PRESET_THEMES[presetName];
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setCurrentTheme(preset.config);
if (isPreviewMode) {
@@ -0,0 +1,503 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Calendar,
Mail,
Lock,
Clock,
ArrowLeft,
Palette,
Eye,
EyeOff
} from 'lucide-react';
import { format, addDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card } from '../../components/common';
import { ThemeCustomizerEnhanced } from '../../components/admin/ThemeCustomizerEnhanced';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { categoriesService } from '../../services/categories.service';
import { useTranslation } from 'react-i18next';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
interface FormData {
event_type: string;
event_name: string;
event_date: string;
host_email: string;
admin_email: string;
password: string;
confirm_password: string;
welcome_message: string;
theme_preset: string;
theme_config: ThemeConfig;
expires_in_days: number;
allow_user_uploads: boolean;
upload_category_id: number | null;
}
const EVENT_TYPE_PRESETS: Record<string, string> = {
wedding: 'elegantWedding',
birthday: 'birthdayFun',
corporate: 'corporateTimeline',
other: 'default'
};
const EVENT_TYPES = [
{ value: 'wedding', labelKey: 'events.types.wedding', emoji: '💒' },
{ value: 'birthday', labelKey: 'events.types.birthday', emoji: '🎂' },
{ value: 'corporate', labelKey: 'events.types.corporate', emoji: '🏢' },
{ value: 'other', labelKey: 'events.types.other', emoji: '📸' },
];
export const CreateEventPageEnhanced: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const isMountedRef = useRef(true);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
// const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const [formData, setFormData] = useState<FormData>({
event_type: 'wedding',
event_name: '',
event_date: format(new Date(), 'yyyy-MM-dd'),
host_email: '',
admin_email: '',
password: '',
confirm_password: '',
welcome_message: '',
theme_preset: 'elegantWedding',
theme_config: GALLERY_THEME_PRESETS.elegantWedding.config,
expires_in_days: 30,
allow_user_uploads: false,
upload_category_id: null,
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const [showPassword, setShowPassword] = useState(false);
// Fetch categories for user upload selection
const { data: categories } = useQuery({
queryKey: ['categories', 'global'],
queryFn: () => categoriesService.getGlobalCategories()
});
// Update theme when event type changes
useEffect(() => {
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) {
setFormData(prev => ({
...prev,
theme_preset: recommendedPreset,
theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config
}));
}
}, [formData.event_type]);
const createMutation = useMutation({
mutationFn: eventsService.createEvent,
onSuccess: (data) => {
if (isMountedRef.current) {
toast.success(t('toast.eventCreated'));
navigate(`/admin/events/${data.id}`);
}
},
onError: (error: any) => {
console.error('Create event error:', error);
const errorMessage = error.response?.data?.error || error.message || t('errors.eventCreationFailed');
toast.error(errorMessage);
},
});
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof FormData, string>> = {};
if (!formData.event_name) {
newErrors.event_name = t('validation.eventNameRequired');
}
if (!formData.event_date) {
newErrors.event_date = t('validation.eventDateRequired');
}
if (!formData.host_email) {
newErrors.host_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
newErrors.host_email = t('validation.invalidEmailFormat');
}
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
if (!formData.password) {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
}
if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
newErrors.expires_in_days = t('validation.expirationRange');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
createMutation.mutate({
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date,
host_email: formData.host_email,
admin_email: formData.admin_email,
password: formData.password,
welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
});
};
const handleInputChange = (field: keyof FormData) => (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
setFormData({ ...formData, [field]: e.target.value });
setErrors({ ...errors, [field]: undefined });
};
const handleThemeChange = (newTheme: ThemeConfig) => {
setFormData(prev => ({
...prev,
theme_config: newTheme
}));
};
const handlePresetChange = (presetName: string) => {
setFormData(prev => ({
...prev,
theme_preset: presetName
}));
};
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowLeft className="w-4 h-4" />}
onClick={() => navigate('/admin/events')}
>
{t('common.back')}
</Button>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.create')}</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Event Details */}
<Card>
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Calendar className="w-5 h-5" />
{t('events.eventDetails')}
</h2>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.eventType')}
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{EVENT_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setFormData({ ...formData, event_type: type.value })}
className={`p-4 rounded-lg border-2 transition-all ${
formData.event_type === type.value
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="text-2xl mb-1">{type.emoji}</div>
<div className="text-sm font-medium">{t(type.labelKey)}</div>
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('events.eventName')}
placeholder={t('events.eventNamePlaceholder')}
value={formData.event_name}
onChange={handleInputChange('event_name')}
error={errors.event_name}
leftIcon={<Calendar className="w-5 h-5" />}
/>
<Input
type="date"
label={t('events.eventDate')}
value={formData.event_date}
onChange={handleInputChange('event_date')}
error={errors.event_date}
min={format(new Date(), 'yyyy-MM-dd')}
leftIcon={<Calendar className="w-5 h-5" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.welcomeMessage')}
</label>
<textarea
value={formData.welcome_message}
onChange={handleInputChange('welcome_message')}
placeholder={t('events.welcomeMessagePlaceholder')}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
rows={3}
/>
</div>
</div>
</Card>
{/* Theme Selection */}
<Card>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('events.themeAndStyle')}
</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowThemeCustomizer(!showThemeCustomizer)}
leftIcon={showThemeCustomizer ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
>
{showThemeCustomizer ? t('common.hide') : t('common.customize')}
</Button>
</div>
{/* Quick Theme Preview */}
{!showThemeCustomizer && (
<div className="p-4 rounded-lg border border-neutral-200"
style={{
backgroundColor: formData.theme_config.backgroundColor,
color: formData.theme_config.textColor
}}
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold" style={{ fontFamily: formData.theme_config.fontFamily }}>
{GALLERY_THEME_PRESETS[formData.theme_preset]?.name || 'Custom Theme'}
</h3>
<div className="flex gap-2">
<div
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: formData.theme_config.primaryColor }}
/>
<div
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: formData.theme_config.accentColor }}
/>
</div>
</div>
<p className="text-sm opacity-80">
Gallery Layout: <span className="font-medium capitalize">{formData.theme_config.galleryLayout || 'grid'}</span>
</p>
</div>
)}
{/* Theme Customizer */}
{showThemeCustomizer && (
<ThemeCustomizerEnhanced
value={formData.theme_config}
onChange={handleThemeChange}
presetName={formData.theme_preset}
onPresetChange={handlePresetChange}
isPreviewMode={false}
showGalleryLayouts={true}
/>
)}
</div>
</Card>
{/* Access & Security */}
<Card>
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Lock className="w-5 h-5" />
{t('events.accessAndSecurity')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="email"
label={t('events.hostEmail')}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.host_email}
onChange={handleInputChange('host_email')}
error={errors.host_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
<Input
type="email"
label={t('events.adminEmail')}
placeholder={t('events.adminEmailPlaceholder')}
value={formData.admin_email}
onChange={handleInputChange('admin_email')}
error={errors.admin_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.galleryPassword')}
placeholder={t('events.passwordPlaceholder')}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.confirmPassword')}
placeholder={t('events.confirmPasswordPlaceholder')}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
leftIcon={<Lock className="w-5 h-5" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-4">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
className="w-32"
/>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP')}
</p>
)}
</div>
{/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.allow_user_uploads}
onChange={(e) => setFormData({ ...formData, allow_user_uploads: e.target.checked })}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('events.allowUserUploads')}
</span>
<p className="text-xs text-neutral-500 mt-0.5">
{t('events.allowUserUploadsDescription')}
</p>
</div>
</label>
{formData.allow_user_uploads && categories && categories.length > 0 && (
<div className="mt-4 ml-7">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.uploadCategory')}
</label>
<select
value={formData.upload_category_id || ''}
onChange={(e) => setFormData({
...formData,
upload_category_id: e.target.value ? Number(e.target.value) : null
})}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">{t('events.selectCategory')}</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-neutral-500">
{t('events.uploadCategoryHelp')}
</p>
</div>
)}
</div>
</div>
</Card>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate('/admin/events')}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={createMutation.isPending}
disabled={createMutation.isPending}
>
{t('events.createEvent')}
</Button>
</div>
</form>
</div>
);
};
+1 -1
View File
@@ -1,7 +1,7 @@
export { AdminLoginPage } from './AdminLoginPage';
export { AdminDashboard } from './AdminDashboard';
export { EventsListPage } from './EventsListPage';
export { CreateEventPage } from './CreateEventPage';
export { CreateEventPageEnhanced as CreateEventPage } from './CreateEventPageEnhanced';
export { EventDetailsPage } from './EventDetailsPage';
export { EmailConfigPage } from './EmailConfigPage';
export { ArchivesPage } from './ArchivesPage';
+222
View File
@@ -0,0 +1,222 @@
// Gallery Layout Types
export type GalleryLayoutType = 'grid' | 'masonry' | 'carousel' | 'timeline' | 'hero' | 'mosaic';
export interface GalleryLayoutSettings {
// Common settings
spacing?: 'tight' | 'normal' | 'relaxed';
photoAnimation?: 'none' | 'fade' | 'scale' | 'slide';
photoShape?: 'square' | 'rounded' | 'circle';
// Grid specific
gridColumns?: {
mobile: number;
tablet: number;
desktop: number;
};
// Masonry specific
masonryGutter?: number;
// Carousel specific
carouselAutoplay?: boolean;
carouselInterval?: number;
carouselShowThumbnails?: boolean;
// Timeline specific
timelineGrouping?: 'day' | 'week' | 'month';
timelineShowDates?: boolean;
// Hero specific
heroImageId?: number;
heroImagePosition?: 'top' | 'center' | 'bottom';
heroOverlayOpacity?: number;
// Mosaic specific
mosaicPattern?: 'random' | 'structured' | 'alternating';
}
export interface ThemeConfig {
// Colors
primaryColor?: string;
accentColor?: string;
backgroundColor?: string;
textColor?: string;
// 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;
// Header/Footer
headerStyle?: 'minimal' | 'standard' | 'full';
footerStyle?: 'minimal' | 'standard' | 'full';
showEventInfo?: boolean;
showBranding?: boolean;
// Advanced
logoUrl?: string;
customCss?: string;
backgroundPattern?: 'none' | 'dots' | 'grid' | 'waves';
}
export interface EventTheme {
id?: string;
name: string;
description?: string;
thumbnail?: string;
config: ThemeConfig;
isPreset?: boolean;
}
// Preset theme definitions
export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
default: {
name: 'Classic Grid',
description: 'Clean and organized grid layout',
config: {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717',
borderRadius: 'md',
galleryLayout: 'grid',
gallerySettings: {
spacing: 'normal',
photoAnimation: 'fade',
gridColumns: { mobile: 2, tablet: 3, desktop: 4 }
},
headerStyle: 'standard',
footerStyle: 'standard'
},
isPreset: true
},
elegantWedding: {
name: 'Elegant Wedding',
description: 'Sophisticated layout with hero image and timeline',
config: {
primaryColor: '#c9a961',
accentColor: '#e6ddd4',
backgroundColor: '#fdfcfb',
textColor: '#3f3f3f',
fontFamily: 'Playfair Display, serif',
headingFontFamily: 'Playfair Display, serif',
borderRadius: 'lg',
shadowStyle: 'subtle',
galleryLayout: 'hero',
gallerySettings: {
spacing: 'relaxed',
photoAnimation: 'scale',
photoShape: 'rounded',
heroOverlayOpacity: 0.3
},
headerStyle: 'full',
footerStyle: 'minimal'
},
isPreset: true
},
modernMasonry: {
name: 'Modern Masonry',
description: 'Pinterest-style dynamic layout',
config: {
primaryColor: '#3b82f6',
accentColor: '#1e40af',
backgroundColor: '#ffffff',
textColor: '#0f172a',
fontFamily: 'Inter, sans-serif',
borderRadius: 'sm',
galleryLayout: 'masonry',
gallerySettings: {
spacing: 'tight',
photoAnimation: 'fade',
masonryGutter: 16
},
headerStyle: 'minimal',
footerStyle: 'minimal',
shadowStyle: 'normal'
},
isPreset: true
},
birthdayFun: {
name: 'Birthday Celebration',
description: 'Vibrant carousel with playful animations',
config: {
primaryColor: '#ec4899',
accentColor: '#fbbf24',
backgroundColor: '#fef3c7',
textColor: '#451a03',
fontFamily: 'Comic Neue, cursive',
borderRadius: 'lg',
galleryLayout: 'carousel',
gallerySettings: {
spacing: 'normal',
photoAnimation: 'slide',
carouselAutoplay: true,
carouselInterval: 5000,
carouselShowThumbnails: true
},
headerStyle: 'full',
footerStyle: 'standard',
backgroundPattern: 'dots'
},
isPreset: true
},
corporateTimeline: {
name: 'Corporate Timeline',
description: 'Professional chronological layout',
config: {
primaryColor: '#1f2937',
accentColor: '#059669',
backgroundColor: '#f9fafb',
textColor: '#111827',
fontFamily: 'IBM Plex Sans, sans-serif',
borderRadius: 'sm',
galleryLayout: 'timeline',
gallerySettings: {
spacing: 'normal',
photoAnimation: 'none',
timelineGrouping: 'day',
timelineShowDates: true
},
headerStyle: 'standard',
footerStyle: 'full',
buttonStyle: 'outline'
},
isPreset: true
},
artisticMosaic: {
name: 'Artistic Mosaic',
description: 'Creative layout with varied photo sizes',
config: {
primaryColor: '#7c3aed',
accentColor: '#f59e0b',
backgroundColor: '#faf5ff',
textColor: '#1e1b4b',
fontFamily: 'Montserrat, sans-serif',
borderRadius: 'none',
galleryLayout: 'mosaic',
gallerySettings: {
spacing: 'tight',
photoAnimation: 'scale',
mosaicPattern: 'structured'
},
headerStyle: 'minimal',
footerStyle: 'minimal',
shadowStyle: 'dramatic'
},
isPreset: true
}
};