Enhance email templates with clickable links, branding, and improved styling
- Add clickable gallery links in all email templates - Include application logo in email header and footer (custom or PicPeak default) - Redesign emails with professional styling matching gallery login page - Gray background with white content box - PicPeak green header with centered logo - Clean typography and proper spacing - Responsive design for mobile devices - Styled call-to-action buttons - Footer with branding and copyright - Update email processor to fetch branding settings dynamically - Use proper API URLs for logo images in emails 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,12 @@ export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ childr
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Apply global theme when settings are loaded
|
||||
// Apply global theme when settings are loaded (but not on gallery pages)
|
||||
useEffect(() => {
|
||||
if (!themeAppliedRef.current && settingsData?.theme_config) {
|
||||
// Skip if we're on a gallery page - gallery pages handle their own themes
|
||||
const isGalleryPage = window.location.pathname.includes('/gallery/');
|
||||
|
||||
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
||||
themeAppliedRef.current = true;
|
||||
setTheme(settingsData.theme_config);
|
||||
}
|
||||
|
||||
@@ -42,32 +42,31 @@ export const MaintenanceMode: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex flex-col">
|
||||
{/* Header with branding */}
|
||||
{(settings?.branding_logo_url || settings?.branding_company_name) && (
|
||||
<div className="bg-white border-b border-neutral-200 py-4">
|
||||
<div className="container">
|
||||
<div className="flex items-center justify-center">
|
||||
{settings.branding_logo_url ? (
|
||||
<img
|
||||
src={settings.branding_logo_url.startsWith('http')
|
||||
? settings.branding_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`
|
||||
}
|
||||
alt={settings.branding_company_name || 'Company Logo'}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
|
||||
{settings.branding_company_tagline && (
|
||||
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Header with branding - Always show, with PicPeak logo as fallback */}
|
||||
<div className="bg-white border-b border-neutral-200 py-4">
|
||||
<div className="container">
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={settings?.branding_logo_url ?
|
||||
(settings.branding_logo_url.startsWith('http')
|
||||
? settings.branding_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`)
|
||||
: '/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={settings?.branding_company_name || 'PicPeak'}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
{settings?.branding_company_name && settings.branding_company_name !== 'PicPeak' && (
|
||||
<div className="ml-4 text-center">
|
||||
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
|
||||
{settings.branding_company_tagline && (
|
||||
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
@@ -20,7 +21,8 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAdminAuth();
|
||||
const { t } = useTranslation();
|
||||
const { format, formatDistanceToNow } = useLocalizedDate();
|
||||
const { format } = useLocalizedDate();
|
||||
const { formatTimeAgo } = useLocalizedTimeAgo();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
@@ -49,7 +51,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
mutationFn: notificationsService.markAllAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
toast.success('All notifications marked as read');
|
||||
toast.success(t('admin.notificationToasts.markedAllRead'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,7 +60,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
mutationFn: notificationsService.clearOldNotifications,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
toast.success(`Cleared ${data.deletedCount} old notifications`);
|
||||
toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,19 +71,26 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden text-neutral-500 hover:text-neutral-700"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
{/* Left side - Menu button and Date */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden text-neutral-500 hover:text-neutral-700"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Date display */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-base text-neutral-700">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop breadcrumb or page title could go here */}
|
||||
<div className="hidden lg:block">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</h2>
|
||||
{/* Center - PicPeak branding */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2">
|
||||
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||
</div>
|
||||
|
||||
{/* Right side actions */}
|
||||
@@ -111,26 +120,26 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<button
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
title="Mark all as read"
|
||||
title={t('admin.markAllRead')}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Mark all read
|
||||
{t('admin.markAllRead')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => clearOldMutation.mutate()}
|
||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||
title="Clear old notifications"
|
||||
title={t('admin.clearOld')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Clear old
|
||||
{t('admin.clearOld')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
No notifications
|
||||
{t('admin.noNotificationsMessage')}
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => {
|
||||
@@ -151,7 +160,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
{notificationsService.formatNotificationMessage(notification)}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +175,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
onClick={() => setShowNotifications(false)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Close
|
||||
{t('admin.close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Archive,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Camera,
|
||||
X,
|
||||
Palette,
|
||||
FileText
|
||||
@@ -53,7 +52,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
{/* Logo/Brand */}
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<Camera className="w-8 h-8 text-primary-600" />
|
||||
<img src="/picpeak-kamera-transparent.png" alt="Camera" className="w-8 h-8 object-contain" />
|
||||
<span className="ml-2 text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Plus, X, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EventCategoryManagerProps {
|
||||
eventId: number;
|
||||
@@ -11,6 +12,7 @@ interface EventCategoryManagerProps {
|
||||
|
||||
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
|
||||
@@ -33,12 +35,12 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
toast.success('Category created successfully');
|
||||
toast.success(t('categories.categoryCreatedSuccess'));
|
||||
setNewCategoryName('');
|
||||
setIsAdding(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create category');
|
||||
toast.error(error.response?.data?.error || t('categories.failedToCreateCategory'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,10 +49,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
mutationFn: categoriesService.deleteCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
toast.success('Category deleted successfully');
|
||||
toast.success(t('categories.categoryDeletedSuccess'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete category');
|
||||
toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,7 +63,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
};
|
||||
|
||||
const handleDelete = (category: PhotoCategory) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
|
||||
if (window.confirm(t('categories.deleteConfirm', { name: category.name }))) {
|
||||
deleteMutation.mutate(category.id);
|
||||
}
|
||||
};
|
||||
@@ -77,7 +79,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-700">Event-Specific Categories</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-700">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -85,7 +87,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
onClick={() => setIsAdding(true)}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
Add
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -98,7 +100,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
placeholder="Category name"
|
||||
placeholder={t('categories.categoryName')}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -111,7 +113,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
'Add'
|
||||
t('common.add')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -122,7 +124,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
setNewCategoryName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -130,7 +132,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
No event-specific categories. Global categories are available by default.
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
@@ -143,7 +145,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
|
||||
title="Delete category"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
@@ -159,7 +161,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-2">Global Categories (always available):</p>
|
||||
<p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
|
||||
interface HeroPhotoSelectorProps {
|
||||
photos: AdminPhoto[];
|
||||
currentHeroPhotoId?: number | null;
|
||||
onSelect: (photoId: number | null) => void;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
photos,
|
||||
currentHeroPhotoId,
|
||||
onSelect,
|
||||
isEditing
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedPhotoId, setSelectedPhotoId] = useState<number | null>(currentHeroPhotoId || null);
|
||||
|
||||
const currentHeroPhoto = photos.find(p => p.id === currentHeroPhotoId);
|
||||
|
||||
const handleSelect = (photoId: number) => {
|
||||
setSelectedPhotoId(photoId);
|
||||
onSelect(photoId);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setSelectedPhotoId(null);
|
||||
onSelect(null);
|
||||
};
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
{currentHeroPhoto ? (
|
||||
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100">
|
||||
<AuthenticatedImage
|
||||
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
|
||||
alt={currentHeroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">{t('events.noHeroPhotoSelected')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 mb-2">
|
||||
{t('events.heroPhotoHelp')}
|
||||
</p>
|
||||
|
||||
{currentHeroPhoto ? (
|
||||
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100 mb-2">
|
||||
<AuthenticatedImage
|
||||
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
|
||||
alt={currentHeroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-white/90 hover:bg-white"
|
||||
>
|
||||
{t('common.change')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
leftIcon={<X className="w-4 h-4" />}
|
||||
className="bg-white/90 hover:bg-white"
|
||||
>
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<ImageIcon className="w-4 h-4" />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
{t('events.selectHeroPhoto')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Photo Selection Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('events.selectHeroPhoto')}</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-center text-neutral-500 py-8">
|
||||
{t('events.noPhotosAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => handleSelect(photo.id)}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
|
||||
photo.id === selectedPhotoId
|
||||
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
|
||||
: 'border-transparent hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-square bg-neutral-100">
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{photo.id === selectedPhotoId && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
||||
<p className="text-white text-xs truncate">{photo.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,15 +10,13 @@ interface ThemeCustomizerProps {
|
||||
onChange: (theme: ThemeConfig) => void;
|
||||
presetName?: string;
|
||||
onPresetChange?: (presetName: string) => void;
|
||||
isPreviewMode?: boolean;
|
||||
}
|
||||
|
||||
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
presetName = 'default',
|
||||
onPresetChange,
|
||||
isPreviewMode = false
|
||||
onPresetChange
|
||||
}) => {
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
||||
@@ -37,29 +35,27 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
|
||||
const updated = { ...localTheme, [key]: newValue };
|
||||
setLocalTheme(updated);
|
||||
if (isPreviewMode) {
|
||||
onChange(updated);
|
||||
}
|
||||
// Always propagate changes to parent, not just in preview mode
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handlePresetSelect = (presetKey: string) => {
|
||||
const preset = GALLERY_THEME_PRESETS[presetKey];
|
||||
console.log('Selecting preset:', presetKey, preset); // Debug log
|
||||
if (preset) {
|
||||
setSelectedPreset(presetKey);
|
||||
setLocalTheme(preset.config);
|
||||
if (onPresetChange) {
|
||||
onPresetChange(presetKey);
|
||||
}
|
||||
if (isPreviewMode) {
|
||||
onChange(preset.config);
|
||||
}
|
||||
// Always propagate preset changes
|
||||
onChange(preset.config);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
console.log('Applying theme:', { ...localTheme, customCss }); // Debug log
|
||||
onChange({ ...localTheme, customCss });
|
||||
const themeWithCss = { ...localTheme, customCss };
|
||||
setLocalTheme(themeWithCss);
|
||||
onChange(themeWithCss);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ThemeCustomizerEnhancedProps {
|
||||
onPresetChange?: (presetName: string) => void;
|
||||
isPreviewMode?: boolean;
|
||||
showGalleryLayouts?: boolean;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
@@ -24,14 +25,7 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
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'
|
||||
};
|
||||
// Layout descriptions will use translation keys
|
||||
|
||||
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
|
||||
value,
|
||||
@@ -39,10 +33,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
presetName = 'default',
|
||||
onPresetChange,
|
||||
isPreviewMode = false,
|
||||
showGalleryLayouts = true
|
||||
showGalleryLayouts = true,
|
||||
hideActions = false
|
||||
}) => {
|
||||
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 || '');
|
||||
@@ -60,6 +54,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
|
||||
const updated = { ...localTheme, [key]: newValue };
|
||||
setLocalTheme(updated);
|
||||
|
||||
// When any change is made, mark it as custom
|
||||
if (selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
|
||||
if (isPreviewMode) {
|
||||
onChange(updated);
|
||||
}
|
||||
@@ -124,7 +125,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<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
|
||||
{t('branding.themePresets')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
|
||||
@@ -179,7 +180,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<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
|
||||
{t('branding.galleryLayout')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
|
||||
@@ -198,7 +199,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
<span className="font-medium text-sm capitalize">{layout}</span>
|
||||
<span className="text-xs text-neutral-600 mt-1">
|
||||
{layoutDescriptions[layout]}
|
||||
{t(`branding.layoutDescriptions.${layout}`)}
|
||||
</span>
|
||||
</div>
|
||||
{localTheme.galleryLayout === layout && (
|
||||
@@ -211,38 +212,38 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{/* 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>
|
||||
<h4 className="font-medium text-sm text-neutral-700">{t('branding.layoutSettings')}</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
|
||||
{t('branding.photoSpacing')}
|
||||
</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>
|
||||
<option value="tight">{t('branding.spacing.tight')}</option>
|
||||
<option value="normal">{t('branding.spacing.normal')}</option>
|
||||
<option value="relaxed">{t('branding.spacing.relaxed')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Photo Animation
|
||||
{t('branding.photoAnimation')}
|
||||
</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>
|
||||
<option value="none">{t('branding.animation.none')}</option>
|
||||
<option value="fade">{t('branding.animation.fade')}</option>
|
||||
<option value="scale">{t('branding.animation.scale')}</option>
|
||||
<option value="slide">{t('branding.animation.slide')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,11 +252,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{localTheme.galleryLayout === 'grid' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Columns
|
||||
{t('branding.columns')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">Mobile</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.mobile')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -268,7 +269,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">Tablet</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.tablet')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
@@ -281,7 +282,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">Desktop</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.desktop')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
@@ -308,13 +309,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700">Enable Autoplay</span>
|
||||
<span className="text-sm font-medium text-neutral-700">{t('branding.enableAutoplay')}</span>
|
||||
</label>
|
||||
</div>
|
||||
{localTheme.gallerySettings?.carouselAutoplay && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Autoplay Interval (seconds)
|
||||
{t('branding.autoplayInterval')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -332,16 +333,16 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{localTheme.galleryLayout === 'timeline' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Group Photos By
|
||||
{t('branding.groupPhotosBy')}
|
||||
</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>
|
||||
<option value="day">{t('branding.grouping.day')}</option>
|
||||
<option value="week">{t('branding.grouping.week')}</option>
|
||||
<option value="month">{t('branding.grouping.month')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
@@ -354,12 +355,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<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
|
||||
{t('branding.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
|
||||
{t('branding.primaryColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -379,7 +380,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Accent Color
|
||||
{t('branding.accentColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -399,7 +400,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Background Color
|
||||
{t('branding.backgroundColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -419,7 +420,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Text Color
|
||||
{t('branding.textColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -443,13 +444,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<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
|
||||
{t('branding.typographyAndStyle')}
|
||||
</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
|
||||
{t('branding.bodyFont')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.fontFamily || 'Inter, sans-serif'}
|
||||
@@ -468,14 +469,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Heading Font
|
||||
{t('branding.headingFont')}
|
||||
</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="">{t('branding.sameAsBody')}</option>
|
||||
<option value="'Playfair Display', serif">Playfair Display</option>
|
||||
<option value="'Montserrat', sans-serif">Montserrat</option>
|
||||
<option value="Georgia, serif">Georgia</option>
|
||||
@@ -487,64 +488,64 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<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
|
||||
{t('branding.fontSize')}
|
||||
</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>
|
||||
<option value="small">{t('branding.fontSizes.small')}</option>
|
||||
<option value="normal">{t('branding.fontSizes.normal')}</option>
|
||||
<option value="large">{t('branding.fontSizes.large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Border Radius
|
||||
{t('branding.borderRadius')}
|
||||
</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>
|
||||
<option value="none">{t('branding.borderRadiusOptions.none')}</option>
|
||||
<option value="sm">{t('branding.borderRadiusOptions.small')}</option>
|
||||
<option value="md">{t('branding.borderRadiusOptions.medium')}</option>
|
||||
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Shadow Style
|
||||
{t('branding.shadowStyle')}
|
||||
</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>
|
||||
<option value="none">{t('branding.shadowOptions.none')}</option>
|
||||
<option value="subtle">{t('branding.shadowOptions.subtle')}</option>
|
||||
<option value="normal">{t('branding.shadowOptions.normal')}</option>
|
||||
<option value="dramatic">{t('branding.shadowOptions.dramatic')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Background
|
||||
{t('branding.backgroundPattern')}
|
||||
</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>
|
||||
<option value="none">{t('branding.backgroundOptions.none')}</option>
|
||||
<option value="dots">{t('branding.backgroundOptions.dots')}</option>
|
||||
<option value="grid">{t('branding.backgroundOptions.grid')}</option>
|
||||
<option value="waves">{t('branding.backgroundOptions.waves')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,35 +554,44 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Custom CSS */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Custom CSS</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.customCSS')}</h3>
|
||||
<textarea
|
||||
value={customCss}
|
||||
onChange={(e) => setCustomCss(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setCustomCss(e.target.value);
|
||||
// Mark as custom when CSS is added
|
||||
if (e.target.value && selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
}}
|
||||
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
|
||||
{t('branding.customCSSHelp')}
|
||||
</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>
|
||||
{!hideActions && (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('branding.resetToDefault')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{t('branding.applyTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Palette,
|
||||
Type,
|
||||
Grid3X3,
|
||||
Layers,
|
||||
Play,
|
||||
Clock,
|
||||
Image,
|
||||
LayoutGrid,
|
||||
Layout
|
||||
} from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemeDisplayProps {
|
||||
theme: ThemeConfig | string;
|
||||
presetName?: string;
|
||||
className?: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
grid: <Grid3X3 className="w-4 h-4" />,
|
||||
masonry: <Layers className="w-4 h-4" />,
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
theme,
|
||||
presetName,
|
||||
className = '',
|
||||
showDetails = true
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Parse theme if it's a string
|
||||
let themeConfig: ThemeConfig | null = null;
|
||||
let themeName = t('branding.theme');
|
||||
|
||||
if (typeof theme === 'string') {
|
||||
try {
|
||||
if (theme.startsWith('{')) {
|
||||
themeConfig = JSON.parse(theme);
|
||||
} else {
|
||||
// Legacy theme name - find matching preset
|
||||
const preset = Object.entries(GALLERY_THEME_PRESETS).find(([key]) => key === theme);
|
||||
if (preset) {
|
||||
themeConfig = preset[1].config;
|
||||
themeName = preset[1].name;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse theme:', e);
|
||||
}
|
||||
} else {
|
||||
themeConfig = theme;
|
||||
}
|
||||
|
||||
// If we have a preset name, use its display name
|
||||
if (presetName && GALLERY_THEME_PRESETS[presetName]) {
|
||||
themeName = GALLERY_THEME_PRESETS[presetName].name;
|
||||
}
|
||||
|
||||
if (!themeConfig) {
|
||||
return (
|
||||
<div className={`text-sm text-neutral-500 ${className}`}>
|
||||
{t('events.noThemeSet')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const galleryLayout = themeConfig.galleryLayout || 'grid';
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{/* Theme Name & Layout */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm font-medium text-neutral-700">{themeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
{layoutIcons[galleryLayout]}
|
||||
<span className="capitalize">{t(`branding.layoutDescriptions.${galleryLayout}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* Color Palette */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">{t('branding.colors')}:</span>
|
||||
<div className="flex gap-1">
|
||||
{themeConfig.primaryColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.primaryColor }}
|
||||
title={t('branding.primaryColor')}
|
||||
/>
|
||||
)}
|
||||
{themeConfig.accentColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.accentColor }}
|
||||
title={t('branding.accentColor')}
|
||||
/>
|
||||
)}
|
||||
{themeConfig.backgroundColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.backgroundColor }}
|
||||
title={t('branding.backgroundColor')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Typography */}
|
||||
{themeConfig.fontFamily && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">{t('branding.bodyFont')}:</span>
|
||||
<span className="text-sm font-medium" style={{ fontFamily: themeConfig.fontFamily }}>
|
||||
{themeConfig.fontFamily}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layout Settings */}
|
||||
{themeConfig.gallerySettings && (
|
||||
<div className="text-sm text-neutral-600">
|
||||
{themeConfig.gallerySettings.spacing && (
|
||||
<span className="inline-flex items-center gap-1 mr-3">
|
||||
<span>{t('branding.photoSpacing')}:</span>
|
||||
<span className="font-medium capitalize">
|
||||
{t(`branding.spacing.${themeConfig.gallerySettings.spacing}`)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{themeConfig.gallerySettings.photoAnimation && themeConfig.gallerySettings.photoAnimation !== 'none' && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>{t('branding.photoAnimation')}:</span>
|
||||
<span className="font-medium capitalize">
|
||||
{t(`branding.animation.${themeConfig.gallerySettings.photoAnimation}`)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ThemeDisplay.displayName = 'ThemeDisplay';
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemeEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (theme: ThemeConfig, presetName: string) => void;
|
||||
currentTheme: ThemeConfig | string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
currentTheme,
|
||||
eventName
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
|
||||
const [presetName, setPresetName] = useState<string>('default');
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTheme) {
|
||||
if (typeof currentTheme === 'string') {
|
||||
try {
|
||||
if (currentTheme.startsWith('{')) {
|
||||
const parsedTheme = JSON.parse(currentTheme);
|
||||
setTheme(parsedTheme);
|
||||
// Try to find matching preset
|
||||
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
|
||||
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
|
||||
);
|
||||
setPresetName(matchingPreset ? matchingPreset[0] : 'custom');
|
||||
} else {
|
||||
// Legacy theme name
|
||||
const preset = GALLERY_THEME_PRESETS[currentTheme];
|
||||
if (preset) {
|
||||
setTheme(preset.config);
|
||||
setPresetName(currentTheme);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse theme:', e);
|
||||
setTheme(GALLERY_THEME_PRESETS.default.config);
|
||||
setPresetName('default');
|
||||
}
|
||||
} else {
|
||||
setTheme(currentTheme);
|
||||
setPresetName('custom');
|
||||
}
|
||||
}
|
||||
}, [currentTheme]);
|
||||
|
||||
const handleThemeChange = (newTheme: ThemeConfig) => {
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
const handlePresetChange = (newPresetName: string) => {
|
||||
setPresetName(newPresetName);
|
||||
if (newPresetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[newPresetName];
|
||||
if (preset) {
|
||||
setTheme(preset.config);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(theme, presetName);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultPreset = GALLERY_THEME_PRESETS.default;
|
||||
setTheme(defaultPreset.config);
|
||||
setPresetName('default');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{t('events.galleryTheme')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 mt-1">
|
||||
{t('events.customizingThemeFor', { event: eventName })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<ThemeCustomizerEnhanced
|
||||
value={theme}
|
||||
onChange={handleThemeChange}
|
||||
presetName={presetName}
|
||||
onPresetChange={handlePresetChange}
|
||||
isPreviewMode={true}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-neutral-200 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('branding.resetToDefault')}
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('branding.saveTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ThemeEditorModal.displayName = 'ThemeEditorModal';
|
||||
@@ -15,4 +15,8 @@ export { AdminPhotoGrid } from './AdminPhotoGrid';
|
||||
export { AdminPhotoViewer } from './AdminPhotoViewer';
|
||||
export { PhotoFilters } from './PhotoFilters';
|
||||
export { PasswordResetModal } from './PasswordResetModal';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
export { ThemeDisplay } from './ThemeDisplay';
|
||||
export { ThemeEditorModal } from './ThemeEditorModal';
|
||||
export { HeroPhotoSelector } from './HeroPhotoSelector';
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface GalleryLayoutProps {
|
||||
event: {
|
||||
@@ -44,45 +45,104 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid' && theme.galleryLayout !== 'hero';
|
||||
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Dynamic Favicon */}
|
||||
<DynamicFavicon />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
||||
<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>
|
||||
)}
|
||||
{/* Header structure */}
|
||||
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
||||
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
||||
{isNonGridLayout && (
|
||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button and other header extras */}
|
||||
<div className="flex items-center gap-3">
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Right side - Download and Logout */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For grid and hero layouts - everything in one bar */}
|
||||
{(!isNonGridLayout || theme.galleryLayout === 'hero') && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left side - Logo, Title, and Dates */}
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* Menu button */}
|
||||
<div className="flex-shrink-0">
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-10 sm: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">
|
||||
<h1
|
||||
className="text-lg sm:text-xl lg:text-2xl font-bold text-neutral-900 leading-tight truncate"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{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">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-neutral-600">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 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" />
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||
<span>{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -90,57 +150,102 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header extra content (upload button, countdown) */}
|
||||
{headerExtra && headerExtra}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
||||
{isNonGridLayout && (
|
||||
<div
|
||||
className="relative text-white overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.accentColor || '#22c55e',
|
||||
backgroundImage: theme.backgroundPattern !== 'none'
|
||||
? `url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E")`
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<div className="container py-12 sm:py-16 lg:py-20 relative z-10">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto filter brightness-0 invert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Event Name */}
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
|
||||
{/* Desktop logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
{/* Event Details */}
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
{format(parseISO(event.event_date), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative bottom wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z"
|
||||
fill="var(--color-background, #fafafa)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container">{children}</main>
|
||||
@@ -160,7 +265,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs sm:text-sm text-neutral-500">
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'} | Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { PhotoCategory } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GallerySidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: PhotoCategory[];
|
||||
selectedCategoryId: number | null;
|
||||
onCategoryChange: (categoryId: number | null) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
sortBy: 'date' | 'name' | 'size';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size') => void;
|
||||
isSelectionMode: boolean;
|
||||
onToggleSelectionMode: () => void;
|
||||
selectedCount: number;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
isDownloading: boolean;
|
||||
photoCounts?: Record<number, number>;
|
||||
totalPhotos: number;
|
||||
isMobile: boolean;
|
||||
galleryLayout?: string;
|
||||
}
|
||||
|
||||
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
selectedCategoryId,
|
||||
onCategoryChange,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
isSelectionMode,
|
||||
onToggleSelectionMode,
|
||||
selectedCount,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
isDownloading,
|
||||
photoCounts = {},
|
||||
totalPhotos,
|
||||
isMobile,
|
||||
galleryLayout
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isMobile, isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when sidebar is open on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}
|
||||
}, [isMobile, isOpen]);
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
|
||||
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
|
||||
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobile && isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`
|
||||
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out
|
||||
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Search Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t('gallery.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Section */}
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
{t('gallery.download')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading || totalPhotos === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{t('gallery.downloadAll')} ({totalPhotos})
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isSelectionMode ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onToggleSelectionMode}
|
||||
className="w-full"
|
||||
>
|
||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||
</Button>
|
||||
|
||||
{isSelectionMode && selectedCount > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadSelected}
|
||||
disabled={isDownloading}
|
||||
className="w-full"
|
||||
>
|
||||
{t('gallery.downloadSelected')} ({selectedCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('gallery.categories')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onCategoryChange(null);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${selectedCategoryId === null
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{t('gallery.allCategories')}</span>
|
||||
<span className="text-sm text-neutral-500">{totalPhotos}</span>
|
||||
</button>
|
||||
|
||||
{categories.map((category) => {
|
||||
const count = photoCounts[category.id] || 0;
|
||||
const isSelected = selectedCategoryId === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
onCategoryChange(category.id);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{isSelected && <Check className="w-4 h-4" />}
|
||||
{category.name}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
{t('gallery.sortBy')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
{sortOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = sortBy === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onSortChange(option.value as 'date' | 'name' | 'size');
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{option.label}</span>
|
||||
{isSelected && <Check className="w-4 h-4 ml-auto" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -10,11 +10,14 @@ import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
|
||||
import { ExpirationBanner } from './ExpirationBanner';
|
||||
import { CountdownTimer } from './CountdownTimer';
|
||||
import { GalleryLayout } from './GalleryLayout';
|
||||
import { GallerySidebar } from './GallerySidebar';
|
||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
@@ -28,24 +31,37 @@ interface GalleryViewProps {
|
||||
expires_at: string;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { setTheme } = useTheme();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const themeAppliedRef = useRef(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
// Fetch photos
|
||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
||||
const downloadAllMutation = useDownloadAllPhotos();
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['gallery-settings'],
|
||||
@@ -70,9 +86,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
// Apply theme only once when component mounts and settings are loaded
|
||||
// Apply theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (!themeAppliedRef.current && settingsData) {
|
||||
if (settingsData && event) {
|
||||
let themeToApply = null;
|
||||
|
||||
if (event.color_theme) {
|
||||
@@ -82,9 +98,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const eventTheme = JSON.parse(event.color_theme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// Handle legacy theme names - use global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
// Handle legacy theme names - check if it's a preset
|
||||
const preset = GALLERY_THEME_PRESETS[event.color_theme];
|
||||
if (preset) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
// Unknown theme name, fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -99,13 +121,23 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
// Apply theme only once
|
||||
// Apply theme with a small delay to ensure it overrides any global theme
|
||||
if (themeToApply) {
|
||||
themeAppliedRef.current = true;
|
||||
setTheme(themeToApply);
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (event.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = event.hero_photo_id;
|
||||
} else if (event.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: event.hero_photo_id };
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData]); // Only depend on settingsData, not setTheme or event
|
||||
}, [settingsData, event, setTheme]); // Include all dependencies
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
@@ -157,6 +189,39 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
|
||||
|
||||
// Track bulk download
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: selectedPhotos.size
|
||||
});
|
||||
|
||||
// Download each selected photo
|
||||
for (const photo of selectedPhotosList) {
|
||||
await galleryService.downloadPhoto(slug, photo.id, photo.filename);
|
||||
}
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
};
|
||||
|
||||
// Calculate photo counts per category
|
||||
const photoCounts = useMemo(() => {
|
||||
if (!data?.photos) return {};
|
||||
const counts: Record<number, number> = {};
|
||||
data.photos.forEach(photo => {
|
||||
if (photo.category_id) {
|
||||
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [data?.photos]);
|
||||
|
||||
// Track search usage with debouncing
|
||||
useEffect(() => {
|
||||
if (searchTerm.length > 0) {
|
||||
@@ -216,23 +281,71 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const showSidebar = theme.galleryLayout !== 'grid';
|
||||
|
||||
return (
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={true}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
headerExtra={
|
||||
(daysUntilExpiration <= 1 && daysUntilExpiration > 0) || event.allow_user_uploads ? (
|
||||
<>
|
||||
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
|
||||
<CountdownTimer expiresAt={event.expires_at} className="mr-2" />
|
||||
)}
|
||||
{event.allow_user_uploads && (
|
||||
<>
|
||||
{/* Sidebar for non-grid layouts */}
|
||||
{showSidebar ? (
|
||||
<GallerySidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(!sidebarOpen)}
|
||||
categories={(data?.categories || []).filter(cat => photoCounts[cat.id] > 0)}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
selectedCount={selectedPhotos.size}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
photoCounts={photoCounts}
|
||||
totalPhotos={data?.photos.length || 0}
|
||||
isMobile={isMobile}
|
||||
galleryLayout={theme.galleryLayout}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={!showSidebar}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
headerExtra={(() => {
|
||||
const items = [];
|
||||
|
||||
if (showSidebar) {
|
||||
items.push(
|
||||
<Button
|
||||
key="menu-button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<Menu className="w-4 h-4" />}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label={t('gallery.toggleMenu')}
|
||||
>
|
||||
{t('common.menu')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
items.push(
|
||||
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
||||
);
|
||||
}
|
||||
|
||||
if (event.allow_user_uploads) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
@@ -242,50 +355,64 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
|
||||
<span className="sm:hidden">{t('common.upload')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
return <>{items}</>;
|
||||
})()}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="mt-6">
|
||||
<PhotoFilterBar
|
||||
categories={data.categories}
|
||||
photos={data.photos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
{/* Search and Filters - Only for grid layout */}
|
||||
{!showSidebar ? (
|
||||
<div className="mt-6">
|
||||
<PhotoFilterBar
|
||||
categories={data.categories}
|
||||
photos={data.photos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className="mt-6">
|
||||
<PhotoGridWithLayouts photos={filteredPhotos} slug={slug} categoryId={selectedCategoryId} />
|
||||
<div className={showSidebar ? "mt-6" : "mt-6"}>
|
||||
<PhotoGridWithLayouts
|
||||
photos={filteredPhotos}
|
||||
slug={slug}
|
||||
categoryId={selectedCategoryId}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectionChange={setSelectedPhotos}
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
showSelectionControls={!showSidebar}
|
||||
eventName={event.event_name}
|
||||
eventLogo={brandingSettings?.logo_url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<UserPhotoUpload
|
||||
eventId={event.id}
|
||||
categoryId={event.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
window.location.reload(); // Simple reload for now
|
||||
}}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</GalleryLayout>
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<UserPhotoUpload
|
||||
eventId={event.id}
|
||||
categoryId={event.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
window.location.reload(); // Simple reload for now
|
||||
}}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</GalleryLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -25,20 +25,40 @@ interface PhotoGridWithLayoutsProps {
|
||||
photos: Photo[];
|
||||
slug: string;
|
||||
categoryId?: number | null;
|
||||
isSelectionMode?: boolean;
|
||||
selectedPhotos?: Set<number>;
|
||||
onSelectionChange?: (photos: Set<number>) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
showSelectionControls?: boolean;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
categoryId
|
||||
categoryId,
|
||||
isSelectionMode: parentSelectionMode,
|
||||
selectedPhotos: parentSelectedPhotos,
|
||||
onSelectionChange,
|
||||
onToggleSelectionMode: parentToggleSelectionMode,
|
||||
showSelectionControls = true,
|
||||
eventName,
|
||||
eventLogo
|
||||
}) => {
|
||||
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 [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [localSelectionMode, setLocalSelectionMode] = useState(false);
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
|
||||
// Use parent state if provided, otherwise use local state
|
||||
const selectedPhotos = parentSelectedPhotos ?? localSelectedPhotos;
|
||||
const isSelectionMode = parentSelectionMode ?? localSelectionMode;
|
||||
const setSelectedPhotos = onSelectionChange ?? setLocalSelectedPhotos;
|
||||
const toggleSelectionMode = parentToggleSelectionMode ?? (() => setLocalSelectionMode(!localSelectionMode));
|
||||
|
||||
// Clear selection when category changes
|
||||
useEffect(() => {
|
||||
setSelectedPhotos(new Set());
|
||||
@@ -71,10 +91,6 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
setSelectedPhotos(new Set());
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedPhotos(new Set(photos.map(p => p.id)));
|
||||
@@ -112,7 +128,11 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
if (parentToggleSelectionMode) {
|
||||
parentToggleSelectionMode();
|
||||
} else {
|
||||
setLocalSelectionMode(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
}
|
||||
@@ -138,6 +158,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
selectedPhotos,
|
||||
isSelectionMode,
|
||||
onPhotoSelect: handlePhotoSelect,
|
||||
eventName,
|
||||
eventLogo,
|
||||
};
|
||||
|
||||
let LayoutComponent;
|
||||
@@ -163,8 +185,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Selection Mode Controls - Not shown for carousel layout */}
|
||||
{photos.length > 1 && galleryLayout !== 'carousel' && (
|
||||
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
|
||||
{showSelectionControls && 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
|
||||
@@ -181,7 +203,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsSelectionMode(true);
|
||||
toggleSelectionMode();
|
||||
selectAll();
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface BaseGalleryLayoutProps {
|
||||
selectedPhotos?: Set<number>;
|
||||
isSelectionMode?: boolean;
|
||||
onPhotoSelect?: (photoId: number) => void;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
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 { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
export const HeroGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect
|
||||
onPhotoSelect,
|
||||
eventName,
|
||||
eventLogo
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||
@@ -50,30 +57,25 @@ export const HeroGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
|
||||
{/* 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 className="text-center px-4">
|
||||
{/* Logo */}
|
||||
{eventLogo && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo}
|
||||
alt="Event logo"
|
||||
className="h-20 sm:h-24 lg:h-32 mx-auto drop-shadow-lg filter brightness-0 invert"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Title */}
|
||||
{eventName && (
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg">
|
||||
{eventName}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,16 +24,21 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative group cursor-pointer overflow-hidden ${className}`}
|
||||
onClick={onClick}
|
||||
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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 && (
|
||||
@@ -90,96 +95,6 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
// 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);
|
||||
@@ -188,21 +103,147 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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}
|
||||
// Create a more structured mosaic layout
|
||||
const renderMosaicLayout = () => {
|
||||
const elements = [];
|
||||
let photoIndex = 0;
|
||||
let patternIndex = 0;
|
||||
|
||||
while (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.length - photoIndex;
|
||||
|
||||
{remainingPhotos.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
// Choose pattern based on rotation and remaining photos
|
||||
if (patternIndex % 3 === 0 && remainingPhotos >= 3) {
|
||||
// Pattern 1: Large left, 2 small right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
className="col-span-1"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
photo={photo1}
|
||||
isSelected={selectedPhotos.has(photo1.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
||||
onDownload={(e) => onDownload(photo1, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
photo={photo2}
|
||||
isSelected={selectedPhotos.has(photo2.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
||||
onDownload={(e) => onDownload(photo2, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 1 && remainingPhotos >= 3) {
|
||||
// Pattern 2: 3 equal columns
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[250px]">
|
||||
{[0, 1, 2].map(offset => {
|
||||
const currentIndex = photoIndex + offset;
|
||||
const photo = photos[currentIndex];
|
||||
return photo ? (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
className=""
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 2 && remainingPhotos >= 3) {
|
||||
// Pattern 3: Large span-2 with 2 small on right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
photo={photo1}
|
||||
isSelected={selectedPhotos.has(photo1.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
||||
onDownload={(e) => onDownload(photo1, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
photo={photo2}
|
||||
isSelected={selectedPhotos.has(photo2.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
||||
onDownload={(e) => onDownload(photo2, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else {
|
||||
// Handle remaining photos that don't fit patterns
|
||||
break;
|
||||
}
|
||||
|
||||
patternIndex++;
|
||||
}
|
||||
|
||||
// Add remaining photos in a regular grid
|
||||
if (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.slice(photoIndex);
|
||||
elements.push(
|
||||
<div key={`remaining-${photoIndex}`} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{remainingPhotos.map((photo, idx) => {
|
||||
const index = idx + 9;
|
||||
const index = photoIndex + idx;
|
||||
return (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
@@ -216,7 +257,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto">
|
||||
{renderMosaicLayout()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user