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