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:
2025-07-10 22:19:15 +02:00
parent 6438374258
commit 5328b4f73a
52 changed files with 3237 additions and 683 deletions
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>PicPeak - Photo Sharing Platform</title>
</head>
<body>
<div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -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);
}
+23 -24
View File
@@ -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">
+31 -22
View File
@@ -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';
+5 -1
View File
@@ -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';
+171 -66
View File
@@ -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>
</>
);
};
+195 -68
View File
@@ -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>
);
};
+10 -6
View File
@@ -170,13 +170,17 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
}
}, []);
// Save theme to localStorage when it changes
// Save theme to localStorage when it changes (but not in gallery views)
useEffect(() => {
// Only save if theme has actually changed
const currentSaved = localStorage.getItem('gallery-theme');
const newValue = JSON.stringify({ name: themeName, config: theme });
if (currentSaved !== newValue) {
localStorage.setItem('gallery-theme', newValue);
// Don't save theme in gallery views to avoid conflicts
const isGalleryView = window.location.pathname.includes('/gallery/');
if (!isGalleryView) {
// Only save if theme has actually changed
const currentSaved = localStorage.getItem('gallery-theme');
const newValue = JSON.stringify({ name: themeName, config: theme });
if (currentSaved !== newValue) {
localStorage.setItem('gallery-theme', newValue);
}
}
}, [theme, themeName]);
+2 -1
View File
@@ -1,3 +1,4 @@
export * from './useSessionTimeout';
export * from './useOnClickOutside';
export * from './useLocalizedDate';
export * from './useLocalizedDate';
export * from './useLocalizedTimeAgo';
+72
View File
@@ -0,0 +1,72 @@
import { useTranslation } from 'react-i18next';
export const useLocalizedTimeAgo = () => {
const { i18n } = useTranslation();
const formatTimeAgo = (date: Date | string): string => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const seconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
const isGerman = i18n.language === 'de';
// Less than a minute
if (seconds < 60) {
return isGerman ? 'gerade eben' : 'just now';
}
// Minutes
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
if (minutes === 1) {
return isGerman ? 'vor 1 Minute' : '1 minute ago';
}
return isGerman ? `vor ${minutes} Minuten` : `${minutes} minutes ago`;
}
// Hours
const hours = Math.floor(minutes / 60);
if (hours < 24) {
if (hours === 1) {
return isGerman ? 'vor 1 Stunde' : '1 hour ago';
}
return isGerman ? `vor ${hours} Stunden` : `${hours} hours ago`;
}
// Days
const days = Math.floor(hours / 24);
if (days < 7) {
if (days === 1) {
return isGerman ? 'vor 1 Tag' : '1 day ago';
}
return isGerman ? `vor ${days} Tagen` : `${days} days ago`;
}
// Weeks
const weeks = Math.floor(days / 7);
if (weeks < 4) {
if (weeks === 1) {
return isGerman ? 'vor 1 Woche' : '1 week ago';
}
return isGerman ? `vor ${weeks} Wochen` : `${weeks} weeks ago`;
}
// Months
const months = Math.floor(days / 30);
if (months < 12) {
if (months === 1) {
return isGerman ? 'vor 1 Monat' : '1 month ago';
}
return isGerman ? `vor ${months} Monaten` : `${months} months ago`;
}
// Years
const years = Math.floor(days / 365);
if (years === 1) {
return isGerman ? 'vor 1 Jahr' : '1 year ago';
}
return isGerman ? `vor ${years} Jahren` : `${years} years ago`;
};
return { formatTimeAgo };
};
+158 -3
View File
@@ -17,6 +17,9 @@
"previous": "Zurück",
"close": "Schließen",
"logout": "Abmelden",
"menu": "Menü",
"change": "Ändern",
"remove": "Entfernen",
"download": "Herunterladen",
"downloadAll": "Alle herunterladen",
"uploading": "Wird hochgeladen...",
@@ -150,12 +153,30 @@
"deselectAll": "Auswahl aufheben",
"downloadSelected": "{{count}} ausgewählte herunterladen",
"remaining": "verbleibend",
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen"
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen",
"filters": "Filter",
"openFilters": "Filter öffnen",
"toggleSidebar": "Seitenleiste umschalten",
"toggleMenu": "Menü umschalten",
"allCategories": "Alle Kategorien",
"categories": "Kategorien",
"download": "Herunterladen",
"searchPlaceholder": "Fotos suchen...",
"sortBy": "Sortieren nach"
},
"categories": {
"title": "Fotokategorien",
"global": "Globale Kategorien",
"eventSpecific": "Veranstaltungsspezifische Kategorien",
"eventSpecificCategories": "Veranstaltungsspezifische Kategorien",
"organizationInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
"noEventSpecificCategories": "Keine veranstaltungsspezifischen Kategorien. Globale Kategorien sind standardmäßig verfügbar.",
"globalCategoriesAlwaysAvailable": "Globale Kategorien (immer verfügbar):",
"deleteCategoryTitle": "Kategorie löschen",
"categoryCreatedSuccess": "Kategorie erfolgreich erstellt",
"categoryDeletedSuccess": "Kategorie erfolgreich gelöscht",
"failedToCreateCategory": "Fehler beim Erstellen der Kategorie",
"failedToDeleteCategory": "Fehler beim Löschen der Kategorie",
"addCategory": "Kategorie hinzufügen",
"categoryName": "Kategoriename",
"noCategory": "Keine Kategorie",
@@ -164,6 +185,17 @@
"cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu."
},
"events": {
"noStatisticsAvailableYet": "Noch keine Statistiken verfügbar",
"totalPhotos": "Gesamtfotos",
"totalViews": "Gesamtaufrufe",
"totalDownloads": "Gesamte Downloads",
"uniqueVisitors": "Eindeutige Besucher",
"addPlus": "Hinzufügen+",
"galleryTheme": "Galerie-Design",
"customizeTheme": "Design anpassen",
"noThemeSet": "Kein Design konfiguriert",
"customizingTheme": "Galerie-Design anpassen",
"customizingThemeFor": "Design für {{event}} anpassen",
"title": "Veranstaltungen",
"create": "Veranstaltung erstellen",
"createEvent": "Veranstaltung erstellen",
@@ -172,6 +204,8 @@
"eventType": "Veranstaltungstyp",
"eventDate": "Veranstaltungsdatum",
"hostEmail": "Gastgeber-E-Mail",
"hostName": "Name des Gastgebers",
"hostNamePlaceholder": "Max Mustermann",
"adminEmail": "Admin-E-Mail",
"expirationDate": "Ablaufdatum",
"active": "Aktiv",
@@ -199,6 +233,7 @@
"eventInformation": "Veranstaltungsinformationen",
"welcomeMessage": "Willkommensnachricht",
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
"noWelcomeMessageSet": "Keine Willkommensnachricht festgelegt",
"created": "Erstellt",
"expires": "Läuft ab",
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
@@ -232,6 +267,7 @@
"galleryExpiration": "Galerie-Ablauf",
"galleryExpiresIn": "Galerie läuft ab in",
"daysAfterEvent": "Tage nach der Veranstaltung",
"expiresOn": "Läuft ab am",
"themeAndStyle": "Design & Stil",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
@@ -340,7 +376,10 @@
"defaultLanguage": "Standardsprache",
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
"saveSettings": "Allgemeine Einstellungen speichern",
"saveGeneralSettings": "Allgemeine Einstellungen speichern"
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
"dateTimeFormat": "Datums- & Zeitformat",
"dateFormat": "Datumsformat",
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
},
"storage": {
"title": "Speicher",
@@ -467,7 +506,80 @@
"saveChanges": "Änderungen speichern",
"applyLivePreview": "Änderungen sofort anwenden (Live-Vorschau)",
"eventSpecificThemes": "Veranstaltungsspezifische Themen",
"eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben."
"eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben.",
"themePresets": "Theme-Vorlagen",
"galleryLayout": "Galerie-Layout",
"layoutDescriptions": {
"grid": "Klassisches Rasterlayout mit einheitlichen Fotogrößen",
"masonry": "Pinterest-ähnliches Layout mit variablen Höhen",
"carousel": "Vollbild-Diashow mit Navigation",
"timeline": "Nach Datum organisierte Fotos",
"hero": "Hervorgehobenes Bild mit Raster darunter",
"mosaic": "Künstlerisches Layout mit gemischten Größen"
},
"layoutSettings": "Layout-Einstellungen",
"photoSpacing": "Foto-Abstand",
"spacing": {
"tight": "Eng",
"normal": "Normal",
"relaxed": "Locker"
},
"photoAnimation": "Foto-Animation",
"animation": {
"none": "Keine",
"fade": "Einblenden",
"scale": "Skalieren",
"slide": "Gleiten"
},
"columns": "Spalten",
"mobile": "Mobil",
"tablet": "Tablet",
"desktop": "Desktop",
"enableAutoplay": "Autoplay aktivieren",
"autoplayInterval": "Autoplay-Intervall (Sekunden)",
"groupPhotosBy": "Fotos gruppieren nach",
"grouping": {
"day": "Tag",
"week": "Woche",
"month": "Monat"
},
"typographyAndStyle": "Typografie & Stil",
"bodyFont": "Fließtext-Schriftart",
"headingFont": "Überschriften-Schriftart",
"sameAsBody": "Wie Fließtext",
"fontSize": "Schriftgröße",
"fontSizes": {
"small": "Klein",
"normal": "Normal",
"large": "Groß"
},
"borderRadius": "Eckenradius",
"borderRadiusOptions": {
"none": "Keine",
"small": "Klein",
"medium": "Mittel",
"large": "Groß"
},
"shadowStyle": "Schattenstil",
"shadowOptions": {
"none": "Kein",
"subtle": "Dezent",
"normal": "Normal",
"dramatic": "Dramatisch"
},
"backgroundPattern": "Hintergrund",
"backgroundOptions": {
"none": "Keiner",
"dots": "Punkte",
"grid": "Raster",
"waves": "Wellen"
},
"customCSSHelp": "Erweitert: Fügen Sie benutzerdefiniertes CSS hinzu, um das Erscheinungsbild weiter anzupassen",
"resetToDefault": "Auf Standard zurücksetzen",
"applyTheme": "Theme anwenden",
"customTheme": "Benutzerdefiniertes Design",
"customizeTheme": "Design anpassen",
"saveTheme": "Design speichern"
},
"admin": {
"title": "Admin-Panel",
@@ -483,6 +595,48 @@
"notifications": "Benachrichtigungen",
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
"markAllRead": "Alle als gelesen markieren",
"clearOld": "Alte löschen",
"close": "Schließen",
"noNotificationsMessage": "Keine Benachrichtigungen",
"notificationMessages": {
"eventCreated": "Neues Event \"{{eventName}}\" wurde erstellt",
"eventArchived": "Event \"{{eventName}}\" wurde archiviert",
"eventUpdated": "Event \"{{eventName}}\" wurde aktualisiert",
"eventDeleted": "Event \"{{eventName}}\" wurde gelöscht",
"photosUploaded": "{{count}} Fotos zu \"{{eventName}}\" hochgeladen",
"photoDeleted": "Foto aus \"{{eventName}}\" gelöscht",
"photosBulkDeleted": "{{count}} Fotos aus \"{{eventName}}\" gelöscht",
"eventExpiring": "Event \"{{eventName}}\" läuft in {{days}} Tagen ab",
"eventExpired": "Event \"{{eventName}}\" ist abgelaufen",
"passwordChanged": "Passwort geändert von {{actorName}}",
"passwordReset": "Passwort zurückgesetzt für \"{{eventName}}\"",
"settingsUpdated": "{{type}}-Einstellungen aktualisiert",
"emailTemplateUpdated": "E-Mail-Vorlage \"{{template}}\" aktualisiert",
"bulkDownload": "{{count}} Fotos von \"{{eventName}}\" heruntergeladen",
"storageWarning": "Speichernutzung bei {{percentage}}%",
"adminLogout": "Admin {{actorName}} hat sich abgemeldet",
"categoryCreated": "Kategorie \"{{name}}\" für \"{{eventName}}\" erstellt",
"categoryUpdated": "Kategorie \"{{name}}\" für \"{{eventName}}\" aktualisiert",
"categoryDeleted": "Kategorie \"{{name}}\" aus \"{{eventName}}\" gelöscht",
"cmsPageUpdated": "CMS-Seite \"{{slug}}\" aktualisiert",
"emailConfigUpdated": "E-Mail-Konfiguration aktualisiert",
"faviconUploaded": "Favicon hochgeladen",
"brandingUpdated": "Branding-Einstellungen aktualisiert",
"generalSettingsUpdated": "Allgemeine Einstellungen aktualisiert",
"securitySettingsUpdated": "Sicherheitseinstellungen aktualisiert",
"themeUpdated": "Theme-Einstellungen aktualisiert",
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
"systemActivity": "Systemaktivität: {{type}}"
},
"notificationToasts": {
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
"clearedOld": "{{count}} alte Benachrichtigungen gelöscht"
},
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
"markAsRead": "Als gelesen markieren",
"markAllAsRead": "Alle als gelesen markieren",
"notificationSettings": "Benachrichtigungseinstellungen",
@@ -663,6 +817,7 @@
"validation": {
"eventNameRequired": "Veranstaltungsname ist erforderlich",
"hostEmailRequired": "Gastgeber-E-Mail ist erforderlich",
"hostNameRequired": "Der Name des Gastgebers ist erforderlich",
"adminEmailRequired": "Admin-E-Mail ist erforderlich",
"invalidEmailFormat": "Ungültiges E-Mail-Format",
"passwordRequired": "Passwort ist erforderlich",
+162 -6
View File
@@ -17,6 +17,9 @@
"previous": "Previous",
"close": "Close",
"logout": "Logout",
"menu": "Menu",
"change": "Change",
"remove": "Remove",
"download": "Download",
"downloadAll": "Download All",
"uploading": "Uploading...",
@@ -150,13 +153,30 @@
"deselectAll": "Deselect All",
"downloadSelected": "Download {{count}} Selected",
"remaining": "remaining",
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos"
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos",
"filters": "Filters",
"openFilters": "Open filters",
"toggleSidebar": "Toggle sidebar",
"toggleMenu": "Toggle menu",
"allCategories": "All Categories",
"categories": "Categories",
"download": "Download",
"searchPlaceholder": "Search photos..."
},
"categories": {
"title": "Photo Categories",
"global": "Global Categories",
"eventSpecific": "Event-Specific Categories",
"addCategory": "Add Category",
"organizationInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
"eventSpecificCategories": "Event-Specific Categories",
"noEventSpecificCategories": "No event-specific categories. Global categories are available by default.",
"globalCategoriesAlwaysAvailable": "Global Categories (always available):",
"deleteCategoryTitle": "Delete category",
"categoryCreatedSuccess": "Category created successfully",
"categoryDeletedSuccess": "Category deleted successfully",
"failedToCreateCategory": "Failed to create category",
"failedToDeleteCategory": "Failed to delete category",
"categoryName": "Category name",
"noCategory": "No category",
"noCategoriesYet": "No categories yet. Create your first category to organize photos.",
@@ -166,6 +186,9 @@
"events": {
"title": "Events",
"createEvent": "Create Event",
"totalViews": "Total Views",
"totalDownloads": "Total Downloads",
"uniqueVisitors": "Unique Visitors",
"createNewEvent": "Create New Event",
"setupNewGallery": "Set up a new photo gallery for your event",
"createNewEventSubtitle": "Set up a new photo gallery for your event",
@@ -197,6 +220,8 @@
"eventType": "Event Type",
"eventDate": "Event Date",
"hostEmail": "Host Email",
"hostName": "Host Name",
"hostNamePlaceholder": "John Smith",
"adminEmail": "Admin Email",
"adminNotificationEmail": "Admin Notification Email",
"expirationDate": "Expiration Date",
@@ -230,6 +255,7 @@
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
"resetGalleryPassword": "Reset Gallery Password",
"photoStatistics": "Photo Statistics",
"totalPhotos": "Total Photos",
"managePhotos": "Manage Photos",
"actions": "Actions",
"archivingInfo": "Archiving will create a ZIP file of all photos and remove the gallery from public access.",
@@ -264,6 +290,12 @@
"selectCategory": "Select a category for user uploads",
"uploadCategoryHelp": "All user-uploaded photos will be added to this category",
"userUploadWarning": "User uploads will be moderated and can be removed by admins at any time.",
"heroPhoto": "Hero Photo",
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
"selectHeroPhoto": "Select Hero Photo",
"noHeroPhotoSelected": "No hero photo selected",
"heroPhotoSelected": "Hero photo selected",
"noPhotosAvailable": "No photos available",
"processingRequest": "Processing your request...",
"eventTypeWedding": "Wedding",
"eventTypeBirthday": "Birthday",
@@ -317,8 +349,8 @@
"adminEmail": "Admin Email",
"createdOn": "Created",
"expires": "Expires",
"daysLeft": "({{days}} days left)",
"daysLeft_plural": "({{days}} days left)",
"daysLeft": "({{count}} day left)",
"daysLeft_plural": "({{count}} days left)",
"shareLink": "Share Link",
"copy": "Copy",
"copied": "Copied!",
@@ -332,7 +364,14 @@
"downloadStarted": "Download started",
"failedToDownloadArchive": "Failed to download archive",
"statisticsNotAvailable": "No statistics available yet",
"photoFilters": "Photo Filters"
"photoFilters": "Photo Filters",
"noStatisticsAvailableYet": "No statistics available yet",
"addPlus": "Add+",
"galleryTheme": "Gallery Theme",
"customizeTheme": "Customize Theme",
"noThemeSet": "No theme configured",
"customizingTheme": "Customizing gallery theme",
"customizingThemeFor": "Customizing theme for {{event}}"
},
"settings": {
"title": "System Settings",
@@ -358,7 +397,10 @@
"defaultLanguage": "Default Language",
"defaultLanguageHelp": "Language shown to guests before login",
"saveSettings": "Save General Settings",
"saveGeneralSettings": "Save General Settings"
"saveGeneralSettings": "Save General Settings",
"dateTimeFormat": "Date & Time Format",
"dateFormat": "Date Format",
"dateFormatHelp": "How dates are displayed in emails and throughout the application"
},
"storage": {
"title": "Storage",
@@ -519,7 +561,80 @@
"saveChanges": "Save Changes",
"applyLivePreview": "Apply changes immediately (Live Preview)",
"eventSpecificThemes": "Event-Specific Themes",
"eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them."
"eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them.",
"themePresets": "Theme Presets",
"galleryLayout": "Gallery Layout",
"layoutDescriptions": {
"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"
},
"layoutSettings": "Layout Settings",
"photoSpacing": "Photo Spacing",
"spacing": {
"tight": "Tight",
"normal": "Normal",
"relaxed": "Relaxed"
},
"photoAnimation": "Photo Animation",
"animation": {
"none": "None",
"fade": "Fade",
"scale": "Scale",
"slide": "Slide"
},
"columns": "Columns",
"mobile": "Mobile",
"tablet": "Tablet",
"desktop": "Desktop",
"enableAutoplay": "Enable Autoplay",
"autoplayInterval": "Autoplay Interval (seconds)",
"groupPhotosBy": "Group Photos By",
"grouping": {
"day": "Day",
"week": "Week",
"month": "Month"
},
"typographyAndStyle": "Typography & Style",
"bodyFont": "Body Font",
"headingFont": "Heading Font",
"sameAsBody": "Same as body",
"fontSize": "Font Size",
"fontSizes": {
"small": "Small",
"normal": "Normal",
"large": "Large"
},
"borderRadius": "Border Radius",
"borderRadiusOptions": {
"none": "None",
"small": "Small",
"medium": "Medium",
"large": "Large"
},
"shadowStyle": "Shadow Style",
"shadowOptions": {
"none": "None",
"subtle": "Subtle",
"normal": "Normal",
"dramatic": "Dramatic"
},
"backgroundPattern": "Background",
"backgroundOptions": {
"none": "None",
"dots": "Dots",
"grid": "Grid",
"waves": "Waves"
},
"customCSSHelp": "Advanced: Add custom CSS to further customize the appearance",
"resetToDefault": "Reset to Default",
"applyTheme": "Apply Theme",
"customTheme": "Custom Theme",
"customizeTheme": "Customize Theme",
"saveTheme": "Save Theme"
},
"admin": {
"title": "Admin Panel",
@@ -535,6 +650,46 @@
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
"noNotifications": "No new notifications",
"markAllRead": "Mark all read",
"clearOld": "Clear old",
"close": "Close",
"noNotificationsMessage": "No notifications",
"notificationMessages": {
"eventCreated": "New event \"{{eventName}}\" was created",
"eventArchived": "Event \"{{eventName}}\" was archived",
"eventUpdated": "Event \"{{eventName}}\" was updated",
"eventDeleted": "Event \"{{eventName}}\" was deleted",
"photosUploaded": "{{count}} photos uploaded to \"{{eventName}}\"",
"photoDeleted": "Photo deleted from \"{{eventName}}\"",
"photosBulkDeleted": "{{count}} photos deleted from \"{{eventName}}\"",
"eventExpiring": "Event \"{{eventName}}\" expires in {{days}} days",
"eventExpired": "Event \"{{eventName}}\" has expired",
"passwordChanged": "Password changed by {{actorName}}",
"passwordReset": "Password reset for \"{{eventName}}\"",
"settingsUpdated": "{{type}} settings updated",
"emailTemplateUpdated": "Email template \"{{template}}\" updated",
"bulkDownload": "{{count}} photos downloaded from \"{{eventName}}\"",
"storageWarning": "Storage usage at {{percentage}}%",
"adminLogout": "Admin {{actorName}} logged out",
"categoryCreated": "Category \"{{name}}\" created for \"{{eventName}}\"",
"categoryUpdated": "Category \"{{name}}\" updated for \"{{eventName}}\"",
"categoryDeleted": "Category \"{{name}}\" deleted from \"{{eventName}}\"",
"cmsPageUpdated": "CMS page \"{{slug}}\" updated",
"emailConfigUpdated": "Email configuration updated",
"faviconUploaded": "Favicon uploaded",
"brandingUpdated": "Branding settings updated",
"generalSettingsUpdated": "General settings updated",
"securitySettingsUpdated": "Security settings updated",
"themeUpdated": "Theme settings updated",
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
"archiveDeleted": "Archive deleted for \"{{eventName}}\"",
"archiveRestored": "Archive restored for \"{{eventName}}\"",
"systemActivity": "System activity: {{type}}"
},
"notificationToasts": {
"markedAllRead": "All notifications marked as read",
"clearedOld": "Cleared {{count}} old notifications"
},
"markAsRead": "Mark as read",
"markAllAsRead": "Mark all as read",
"notificationSettings": "Notification Settings",
@@ -584,6 +739,7 @@
"validation": {
"eventNameRequired": "Event name is required",
"hostEmailRequired": "Host email is required",
"hostNameRequired": "Host name is required",
"adminEmailRequired": "Admin email is required",
"invalidEmailFormat": "Invalid email format",
"passwordRequired": "Password is required",
+1 -1
View File
@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
+62 -13
View File
@@ -1,23 +1,25 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react';
import { Calendar, AlertCircle, Clock } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation();
const { format } = useLocalizedDate();
const { setTheme } = useTheme();
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
@@ -43,6 +45,47 @@ export const GalleryPage: React.FC = () => {
}
}, [settingsData, isAuthenticated, i18n]);
// Apply theme for login page
React.useEffect(() => {
if (!isAuthenticated && galleryInfo && settingsData) {
let themeToApply = null;
if (galleryInfo.color_theme) {
try {
// Check if it's a valid JSON string
if (galleryInfo.color_theme.startsWith('{')) {
themeToApply = JSON.parse(galleryInfo.color_theme);
} else {
// Handle legacy theme names - check if it's a preset
const preset = GALLERY_THEME_PRESETS[galleryInfo.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) {
console.error('Failed to parse event theme:', e);
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} else if (settingsData.theme_config) {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme
if (themeToApply) {
setTheme(themeToApply);
}
}
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
@@ -159,6 +202,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
@@ -213,6 +259,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
@@ -231,17 +280,14 @@ export const GalleryPage: React.FC = () => {
<div className="w-full max-w-lg">
{/* Logo/Header */}
<div className="text-center mb-4 sm:mb-6">
{settingsData?.branding_logo_url ? (
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-12 h-12 sm:w-16 sm:h-16 lg:w-20 lg:h-20 rounded-2xl mb-3 sm:mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Camera className="w-6 h-6 sm:w-8 sm:h-8 lg:w-10 lg:h-10 text-white" />
</div>
)}
<img
src={settingsData?.branding_logo_url ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}` :
'/picpeak-logo-transparent.png'
}
alt={settingsData?.branding_company_name || 'PicPeak'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
@@ -325,6 +371,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
+23 -19
View File
@@ -120,17 +120,16 @@ export const AdminLoginPage: React.FC = () => {
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
{settingsData?.branding_logo_url ? (
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto mb-4"
src="/picpeak-logo-transparent.png"
alt="PicPeak"
className="w-[180px] h-[130px] object-contain"
/>
) : (
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Lock className="w-8 h-8 text-white" />
</div>
)}
</div>
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>Admin Login</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>Sign in to manage your photo galleries</p>
</div>
@@ -229,16 +228,21 @@ export const AdminLoginPage: React.FC = () => {
</Card>
{/* Footer */}
<p className="text-center text-sm mt-8" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
Need help? Contact{' '}
<a
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{settingsData?.branding_support_email || 'support@example.com'}
</a>
</p>
<div className="text-center mt-8">
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
Need help? Contact{' '}
<a
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{settingsData?.branding_support_email || 'support@example.com'}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
{/* Development Hint */}
{import.meta.env.DEV && (
+31 -18
View File
@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
import { ThemeCustomizer } from '../../components/admin/ThemeCustomizer';
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
@@ -42,10 +42,15 @@ export const BrandingPage: React.FC = () => {
});
// Update branding mutation
const queryClient = useQueryClient();
const brandingMutation = useMutation({
mutationFn: settingsService.updateBranding,
onSuccess: () => {
toast.success(t('toast.brandingUpdated'));
// Invalidate all settings queries to refresh data
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
@@ -67,21 +72,24 @@ export const BrandingPage: React.FC = () => {
useEffect(() => {
if (settings) {
const formatted = settingsService.formatBrandingSettings(settings);
setBrandingSettings(formatted);
// Don't set logo_url here - it will be synced from theme
const { logo_url, ...brandingWithoutLogo } = formatted;
setBrandingSettings(prev => ({ ...prev, ...brandingWithoutLogo }));
}
}, [settings]);
// Initialize theme from database
useEffect(() => {
if (themeSettings) {
const formatted = settingsService.formatThemeSettings(themeSettings);
const formatted = settingsService.formatThemeSettings(themeSettings) as ThemeConfig;
if (formatted && Object.keys(formatted).length > 0) {
// Merge logo URL from branding settings if available
const logoUrl = settings?.branding_logo_url || brandingSettings.logo_url;
const themeWithLogo = logoUrl ? { ...formatted, logoUrl } : formatted;
// Use the theme's logo URL as stored in the theme config
setCurrentTheme(formatted);
setTheme(formatted);
setCurrentTheme(themeWithLogo);
setTheme(themeWithLogo);
// Always sync the logo URL from theme to branding settings - theme is source of truth
setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl || '' }));
// Try to identify which preset this matches
for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) {
@@ -92,7 +100,7 @@ export const BrandingPage: React.FC = () => {
}
}
}
}, [themeSettings, settings, brandingSettings.logo_url, setTheme]);
}, [themeSettings, setTheme]);
const handleBrandingChange = (key: string, value: any) => {
setBrandingSettings(prev => ({ ...prev, [key]: value }));
@@ -151,17 +159,23 @@ export const BrandingPage: React.FC = () => {
const handleSave = async () => {
try {
// Save branding settings to database
await brandingMutation.mutateAsync(brandingSettings);
// Sync logo URL from theme to branding settings
const updatedBrandingSettings = {
...brandingSettings,
logo_url: currentTheme.logoUrl || ''
};
// Save theme settings to database (including logo URL if present)
const themeToSave = brandingSettings.logo_url
? { ...currentTheme, logoUrl: brandingSettings.logo_url }
: currentTheme;
await themeMutation.mutateAsync(themeToSave);
// Save branding settings to database
await brandingMutation.mutateAsync(updatedBrandingSettings);
// Save theme settings to database
await themeMutation.mutateAsync(currentTheme);
// Apply theme globally
setTheme(themeToSave);
setTheme(currentTheme);
// Update local state to reflect saved values
setBrandingSettings(updatedBrandingSettings);
} catch (error) {
console.error('Failed to save settings:', error);
}
@@ -463,7 +477,6 @@ export const BrandingPage: React.FC = () => {
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
/>
</div>
@@ -11,6 +11,7 @@ import {
EyeOff
} from 'lucide-react';
import { format, addDays } from 'date-fns';
import { enUS, de } from 'date-fns/locale';
import { toast } from 'react-toastify';
import { Button, Input, Card } from '../../components/common';
@@ -18,6 +19,7 @@ import { ThemeCustomizerEnhanced } from '../../components/admin/ThemeCustomizerE
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
@@ -25,6 +27,7 @@ interface FormData {
event_type: string;
event_name: string;
event_date: string;
host_name: string;
host_email: string;
admin_email: string;
password: string;
@@ -53,7 +56,7 @@ const EVENT_TYPES = [
export const CreateEventPageEnhanced: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const isMountedRef = useRef(true);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
// const [showPreview, setShowPreview] = useState(false);
@@ -68,6 +71,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
event_type: 'wedding',
event_name: '',
event_date: format(new Date(), 'yyyy-MM-dd'),
host_name: '',
host_email: '',
admin_email: '',
password: '',
@@ -89,6 +93,22 @@ export const CreateEventPageEnhanced: React.FC = () => {
queryFn: () => categoriesService.getGlobalCategories()
});
// Fetch default settings
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings()
});
// Update default expiration days when settings are loaded
useEffect(() => {
if (settings?.general_default_expiration_days) {
setFormData(prev => ({
...prev,
expires_in_days: settings.general_default_expiration_days
}));
}
}, [settings]);
// Update theme when event type changes
useEffect(() => {
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
@@ -111,8 +131,21 @@ export const CreateEventPageEnhanced: React.FC = () => {
},
onError: (error: any) => {
console.error('Create event error:', error);
console.error('Error response:', error.response?.data);
console.error('Error status:', error.response?.status);
console.error('Full error object:', JSON.stringify(error.response, null, 2));
const errorMessage = error.response?.data?.error || error.message || t('errors.eventCreationFailed');
toast.error(errorMessage);
// If validation errors exist, show them
if (error.response?.data?.errors) {
const validationErrors = error.response.data.errors;
console.error('Validation errors:', validationErrors);
validationErrors.forEach((err: any) => {
toast.error(`${err.param}: ${err.msg}`);
});
} else {
toast.error(errorMessage);
}
},
});
@@ -127,6 +160,10 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.event_date = t('validation.eventDateRequired');
}
if (!formData.host_name) {
newErrors.host_name = t('validation.hostNameRequired');
}
if (!formData.host_email) {
newErrors.host_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
@@ -164,10 +201,11 @@ export const CreateEventPageEnhanced: React.FC = () => {
return;
}
createMutation.mutate({
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date,
host_name: formData.host_name,
host_email: formData.host_email,
admin_email: formData.admin_email,
password: formData.password,
@@ -176,7 +214,10 @@ export const CreateEventPageEnhanced: React.FC = () => {
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
});
};
console.log('Submitting payload:', payload);
createMutation.mutate(payload);
};
const handleInputChange = (field: keyof FormData) => (
@@ -354,16 +395,27 @@ export const CreateEventPageEnhanced: React.FC = () => {
{t('events.accessAndSecurity')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="email"
label={t('events.hostEmail')}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.host_email}
onChange={handleInputChange('host_email')}
error={errors.host_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('events.hostName')}
placeholder={t('events.hostNamePlaceholder')}
value={formData.host_name}
onChange={handleInputChange('host_name')}
error={errors.host_name}
leftIcon={<Calendar className="w-5 h-5" />}
/>
<Input
type="email"
label={t('events.hostEmail')}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.host_email}
onChange={handleInputChange('host_email')}
error={errors.host_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
</div>
<Input
type="email"
@@ -411,22 +463,23 @@ export const CreateEventPageEnhanced: React.FC = () => {
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-4">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
className="w-32"
/>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP')}
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP', { locale: i18n.language === 'de' ? de : enUS })}
</p>
)}
</div>
+335 -105
View File
@@ -5,8 +5,6 @@ import {
ArrowLeft,
ExternalLink,
Calendar,
Users,
Eye,
Download,
Archive,
Edit2,
@@ -17,24 +15,28 @@ import {
CheckCircle,
Upload,
Image,
Key
Key,
Palette,
Settings
} from 'lucide-react';
import { format, parseISO, differenceInDays } from 'date-fns';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal } from '../../components/admin';
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeDisplay, ThemeCustomizerEnhanced, ThemeEditorModal, HeroPhotoSelector } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
import { archiveService } from '../../services/archive.service';
import { photosService, AdminPhoto } from '../../services/photos.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
export const EventDetailsPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { format } = useLocalizedDate();
// Validate ID parameter
React.useEffect(() => {
@@ -50,12 +52,18 @@ export const EventDetailsPage: React.FC = () => {
expires_at: '',
allow_user_uploads: false,
upload_category_id: null as number | null,
hero_photo_id: null as number | null,
host_name: '',
});
const [copiedLink, setCopiedLink] = useState(false);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
const [showThemeEditorModal, setShowThemeEditorModal] = useState(false);
// Photo filters state
const [photoFilters, setPhotoFilters] = useState({
@@ -72,19 +80,13 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id,
});
// Fetch event statistics (skip if event doesn't exist or from admin context)
const { data: stats } = useQuery({
queryKey: ['admin-event-stats', event?.slug],
queryFn: () => galleryService.getGalleryStats(event!.slug),
enabled: false, // Disable stats from admin panel as it requires gallery auth
retry: false,
});
// Statistics are now fetched with the event details from the admin API
// Fetch photos when on photos tab
// Fetch photos (needed for both photos tab and hero photo selector)
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
queryKey: ['admin-event-photos', id, photoFilters],
queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters),
enabled: !!id && activeTab === 'photos',
enabled: !!id && (activeTab === 'photos' || isEditing),
});
// Fetch categories for the event
@@ -105,8 +107,15 @@ export const EventDetailsPage: React.FC = () => {
toast.success(t('toast.eventUpdated'));
setIsEditing(false);
},
onError: () => {
toast.error(t('toast.saveError'));
onError: (error: any) => {
console.error('Update event error:', error.response?.data || error);
if (error.response?.data?.errors) {
console.error('Validation errors:', error.response.data.errors);
const errorMessage = error.response.data.errors[0].msg + ' (field: ' + error.response.data.errors[0].path + ')';
toast.error(errorMessage);
} else {
toast.error(error.response?.data?.error || t('toast.saveError'));
}
},
});
@@ -155,18 +164,85 @@ export const EventDetailsPage: React.FC = () => {
expires_at: format(parseISO(event.expires_at), 'yyyy-MM-dd'),
allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null,
hero_photo_id: event.hero_photo_id || null,
host_name: event.host_name || '',
});
// Parse theme configuration
if (event.color_theme) {
try {
if (event.color_theme.startsWith('{')) {
const parsedTheme = JSON.parse(event.color_theme);
setCurrentTheme(parsedTheme);
// Try to find matching preset
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
);
setCurrentPresetName(matchingPreset ? matchingPreset[0] : 'custom');
} else {
// Legacy theme name
const preset = GALLERY_THEME_PRESETS[event.color_theme];
if (preset) {
setCurrentTheme(preset.config);
setCurrentPresetName(event.color_theme);
}
}
} catch (e) {
console.error('Failed to parse theme:', e);
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName('default');
}
} else {
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName('default');
}
setIsEditing(true);
};
const handleSaveEdit = () => {
updateMutation.mutate({
welcome_message: editForm.welcome_message || undefined,
color_theme: editForm.color_theme || undefined,
// Prepare color_theme - if we have a custom theme, serialize it
let themeToSave = editForm.color_theme;
if (currentTheme && (currentPresetName === 'custom' || showThemeCustomizer)) {
themeToSave = JSON.stringify(currentTheme);
} else if (currentPresetName && currentPresetName !== 'custom') {
// Use preset name for non-custom themes
themeToSave = currentPresetName;
}
// Clean up the data - remove undefined values
const updateData: any = {
expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads,
upload_category_id: editForm.upload_category_id,
};
// Only include fields that have defined values
if (editForm.welcome_message !== undefined && editForm.welcome_message !== null) {
updateData.welcome_message = editForm.welcome_message;
}
if (themeToSave) {
updateData.color_theme = themeToSave;
}
if (editForm.upload_category_id !== undefined) {
updateData.upload_category_id = editForm.upload_category_id;
}
if (editForm.hero_photo_id !== undefined) {
updateData.hero_photo_id = editForm.hero_photo_id;
}
if (editForm.host_name !== undefined && editForm.host_name !== null) {
updateData.host_name = editForm.host_name;
}
// Remove any keys with undefined values
Object.keys(updateData).forEach(key => {
if (updateData[key] === undefined) {
delete updateData[key];
}
});
console.log('Updating event with data:', updateData);
console.log('Theme length:', updateData.color_theme ? updateData.color_theme.length : 0);
updateMutation.mutate(updateData);
};
const handleCopyLink = async () => {
@@ -180,6 +256,35 @@ export const EventDetailsPage: React.FC = () => {
}
};
const handleThemeModalSave = async (theme: ThemeConfig, presetName: string) => {
// Prepare theme for saving
let themeToSave: string;
if (presetName !== 'custom' && GALLERY_THEME_PRESETS[presetName]) {
// Save preset name for standard presets
themeToSave = presetName;
} else {
// Save full theme config for custom themes
themeToSave = JSON.stringify(theme);
}
try {
// Update the event with new theme
await eventsService.updateEvent(parseInt(id!), {
color_theme: themeToSave
});
// Invalidate queries to refresh the data
await queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
// Close modal and show success
setShowThemeEditorModal(false);
toast.success(t('toast.themeUpdated'));
} catch (error) {
console.error('Failed to save theme:', error);
toast.error(t('toast.saveError'));
}
};
return (
<div>
{/* Page Header */}
@@ -200,15 +305,15 @@ export const EventDetailsPage: React.FC = () => {
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600">
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
{format(parseISO(event.event_date), 'PPP')}
</span>
<span className="capitalize">{event.event_type}</span>
{event.is_archived && (
{event.is_archived ? (
<span className="text-neutral-500 flex items-center">
<Archive className="w-4 h-4 mr-1" />
{t('events.archived')}
</span>
)}
) : null}
</div>
</div>
@@ -319,8 +424,8 @@ export const EventDetailsPage: React.FC = () => {
}`}
>
<Image className="w-4 h-4" />
{t('events.photos')}
{event.photo_count && event.photo_count > 0 && (
<span>{t('events.photos')}</span>
{event.photo_count !== undefined && event.photo_count > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 rounded-full">
{event.photo_count}
</span>
@@ -341,9 +446,9 @@ export const EventDetailsPage: React.FC = () => {
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Details */}
<div className="lg:col-span-2 space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left Column - Main Details */}
<div className="space-y-6">
{/* Event Information */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.eventInformation')}</h2>
@@ -363,6 +468,18 @@ export const EventDetailsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.hostName')}
</label>
<Input
type="text"
value={editForm.host_name}
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))}
placeholder={t('events.hostNamePlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.expirationDate')}
@@ -375,6 +492,81 @@ export const EventDetailsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.galleryTheme')}
</label>
{!showThemeCustomizer ? (
<div className="space-y-2">
<select
value={currentPresetName}
onChange={(e) => {
const presetName = e.target.value;
setCurrentPresetName(presetName);
if (presetName !== 'custom') {
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setCurrentTheme(preset.config);
setEditForm(prev => ({ ...prev, color_theme: presetName }));
}
}
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
<option key={key} value={key}>
{preset.name}
</option>
))}
<option value="custom">{t('branding.customTheme')}</option>
</select>
<Button
variant="outline"
size="sm"
leftIcon={<Settings className="w-4 h-4" />}
onClick={() => setShowThemeCustomizer(true)}
className="w-full"
>
{t('branding.customizeTheme')}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">{t('branding.customizingTheme')}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setShowThemeCustomizer(false)}
>
{t('common.hide')}
</Button>
</div>
<ThemeCustomizerEnhanced
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
onChange={setCurrentTheme}
presetName={currentPresetName}
onPresetChange={(presetName) => {
setCurrentPresetName(presetName);
if (presetName !== 'custom') {
setEditForm(prev => ({ ...prev, color_theme: presetName }));
}
}}
isPreviewMode={false}
showGalleryLayouts={true}
/>
</div>
)}
</div>
{/* Hero Photo Selection */}
<HeroPhotoSelector
photos={photos || []}
currentHeroPhotoId={editForm.hero_photo_id}
onSelect={(photoId) => setEditForm(prev => ({ ...prev, hero_photo_id: photoId }))}
isEditing={isEditing}
/>
<div>
<label className="flex items-center">
<input
@@ -419,12 +611,19 @@ export const EventDetailsPage: React.FC = () => {
) : (
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.welcomeMessageLabel')}</dt>
<dt className="text-sm font-medium text-neutral-500">{t('events.welcomeMessage')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.welcome_message || <span className="text-neutral-400">{t('events.noWelcomeMessageSet')}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
@@ -439,25 +638,36 @@ export const EventDetailsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.createdOn')}</dt>
<dt className="text-sm font-medium text-neutral-500">{t('events.created')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{format(parseISO(event.created_at), 'MMM d, yyyy')}
{format(parseISO(event.created_at), 'PP')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.expires')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{format(parseISO(event.expires_at), 'MMM d, yyyy')}
{format(parseISO(event.expires_at), 'PP')}
{!event.is_archived && daysUntilExpiration > 0 && (
<span className="text-neutral-500 ml-1">
{t('events.daysLeft', { days: daysUntilExpiration })}
{t('events.daysLeft', { count: daysUntilExpiration })}
</span>
)}
</dd>
</div>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.heroPhoto')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.hero_photo_id ? (
<span className="text-primary-600">{t('events.heroPhotoSelected')}</span>
) : (
<span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.userUploads')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
@@ -523,41 +733,7 @@ export const EventDetailsPage: React.FC = () => {
)}
</Card>
{/* Photo Statistics */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.photoStatistics')}</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalPhotos')}</span>
<span className="text-sm font-medium">{event.photo_count || 0}</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalSize')}</span>
<span className="text-sm font-medium">
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.categories')}</span>
<span className="text-sm font-medium">{categories.length}</span>
</div>
</div>
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
{t('events.managePhotos')}
</Button>
</div>
</Card>
{/* Actions */}
{!event.is_archived && (
@@ -587,47 +763,90 @@ export const EventDetailsPage: React.FC = () => {
)}
</div>
{/* Right Column - Statistics */}
{/* Right Column - Statistics, Theme, and Actions */}
<div className="space-y-6">
{/* Photo Statistics */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.statistics')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.photoStatistics')}</h2>
{stats ? (
<div className="space-y-4">
<div className="text-center p-4 bg-neutral-50 rounded-lg">
<p className="text-3xl font-bold text-neutral-900">{stats.total_photos}</p>
<p className="text-sm text-neutral-500">{t('events.totalPhotos')}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<Eye className="w-5 h-5 text-blue-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.total_views}</p>
<p className="text-xs text-neutral-500">{t('events.views')}</p>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<Download className="w-5 h-5 text-purple-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.total_downloads}</p>
<p className="text-xs text-neutral-500">{t('events.downloads')}</p>
</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<Users className="w-5 h-5 text-green-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.unique_visitors}</p>
<p className="text-xs text-neutral-500">{t('events.uniqueVisitors')}</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalPhotos')}</span>
<span className="text-sm font-medium">{event.photo_count || 0}</span>
</div>
) : (
<div className="text-center py-8 text-neutral-500">
<p>{t('events.statisticsNotAvailable')}</p>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalSize')}</span>
<span className="text-sm font-medium">
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
</span>
</div>
)}
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.categories')}</span>
<span className="text-sm font-medium">{categories.length}</span>
</div>
{event.total_views !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalViews')}</span>
<span className="text-sm font-medium">{event.total_views || 0}</span>
</div>
)}
{event.total_downloads !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalDownloads')}</span>
<span className="text-sm font-medium">{event.total_downloads || 0}</span>
</div>
)}
{event.unique_visitors !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.uniqueVisitors')}</span>
<span className="text-sm font-medium">{event.unique_visitors || 0}</span>
</div>
)}
</div>
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
{t('events.managePhotos')}
</Button>
</div>
</Card>
{/* Gallery Theme */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">{t('events.galleryTheme')}</h2>
{!event.is_archived && (
<Button
variant="outline"
size="sm"
leftIcon={<Palette className="w-4 h-4" />}
onClick={() => setShowThemeEditorModal(true)}
>
{t('events.customizeTheme')}
</Button>
)}
</div>
<ThemeDisplay
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
showDetails={true}
/>
</Card>
{/* Archive Status */}
{event.is_archived && (
{event.is_archived ? (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.archiveStatusTitle')}</h2>
@@ -635,7 +854,7 @@ export const EventDetailsPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-neutral-500">{t('events.archivedOn')}</p>
<p className="text-sm text-neutral-900">
{event.archived_at && format(parseISO(event.archived_at), 'MMM d, yyyy h:mm a')}
{event.archived_at && format(parseISO(event.archived_at), 'PPp')}
</p>
</div>
@@ -660,7 +879,7 @@ export const EventDetailsPage: React.FC = () => {
)}
</div>
</Card>
)}
) : null}
</div>
</div>
)}
@@ -762,7 +981,7 @@ export const EventDetailsPage: React.FC = () => {
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-2">{t('events.photoCategories')}</h2>
<p className="text-sm text-neutral-600">
{t('events.organizingPhotosInfo')}
{t('events.organizeCategoriesInfo')}
</p>
</div>
@@ -790,6 +1009,17 @@ export const EventDetailsPage: React.FC = () => {
onClose={() => setShowPasswordReset(false)}
/>
)}
{/* Theme Editor Modal */}
{showThemeEditorModal && (
<ThemeEditorModal
isOpen={showThemeEditorModal}
onClose={() => setShowThemeEditorModal(false)}
onSave={handleThemeModalSave}
currentTheme={event.color_theme || 'default'}
eventName={event.event_name}
/>
)}
</div>
);
};
+3 -1
View File
@@ -11,8 +11,9 @@ import {
Download,
Trash2
} from 'lucide-react';
import { format, parseISO, differenceInDays } from 'date-fns';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
import { BulkArchiveModal } from '../../components/admin';
@@ -23,6 +24,7 @@ import { useTranslation } from 'react-i18next';
export const EventsListPage: React.FC = () => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
+36 -2
View File
@@ -56,7 +56,8 @@ export const SettingsPage: React.FC = () => {
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
default_language: 'en'
default_language: 'en',
date_format: { format: 'DD/MM/YYYY', locale: 'en-GB' }
});
// Security settings state
@@ -88,7 +89,8 @@ export const SettingsPage: React.FC = () => {
enable_analytics: settings.general_enable_analytics || true,
enable_registration: settings.general_enable_registration || false,
maintenance_mode: settings.general_maintenance_mode || false,
default_language: settings.general_default_language || 'en'
default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format || { format: 'DD/MM/YYYY', locale: 'en-GB' }
});
// Extract security settings
@@ -337,6 +339,38 @@ export const SettingsPage: React.FC = () => {
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.dateTimeFormat')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.dateFormat')}
</label>
<select
value={generalSettings.date_format?.format || 'DD/MM/YYYY'}
onChange={(e) => {
const format = e.target.value;
const locale = format === 'MM/DD/YYYY' ? 'en-US' : 'en-GB';
setGeneralSettings(prev => ({
...prev,
date_format: { format, locale }
}));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="DD/MM/YYYY">DD/MM/YYYY (European)</option>
<option value="MM/DD/YYYY">MM/DD/YYYY (US)</option>
<option value="YYYY-MM-DD">YYYY-MM-DD (ISO)</option>
<option value="DD.MM.YYYY">DD.MM.YYYY (German)</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.dateFormatHelp')}
</p>
</div>
</div>
<div className="mt-6">
<Button
+3 -2
View File
@@ -27,6 +27,7 @@ interface UpdateEventData {
is_active?: boolean;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
}
interface EventsListResponse {
@@ -70,13 +71,13 @@ export const eventsService = {
// Update event (admin)
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
const response = await api.put<Event>(`/api/events/${id}`, data);
const response = await api.put<Event>(`/api/admin/events/${id}`, data);
return response.data;
},
// Delete/deactivate event (admin)
async deleteEvent(id: number): Promise<void> {
await api.delete(`/api/events/${id}`);
await api.delete(`/api/admin/events/${id}`);
},
// Force archive event (admin)
+122 -11
View File
@@ -1,4 +1,5 @@
import { api } from '../config/api';
import i18n from '../i18n/config';
export interface Notification {
id: number;
@@ -45,29 +46,113 @@ export const notificationsService = {
// Format notification message
formatNotificationMessage(notification: Notification): string {
const t = i18n.t;
switch (notification.type) {
case 'event_created':
return `New event "${notification.eventName}" was created`;
return t('admin.notificationMessages.eventCreated', { eventName: notification.eventName });
case 'event_archived':
return `Event "${notification.eventName}" was archived`;
return t('admin.notificationMessages.eventArchived', { eventName: notification.eventName });
case 'event_updated':
return t('admin.notificationMessages.eventUpdated', {
eventName: notification.eventName || notification.metadata.eventName
});
case 'event_deleted':
return t('admin.notificationMessages.eventDeleted', {
eventName: notification.metadata.event_name
});
case 'photos_uploaded':
return `${notification.metadata.count || 0} photos uploaded to "${notification.eventName}"`;
return t('admin.notificationMessages.photosUploaded', {
count: notification.metadata.count || 0,
eventName: notification.eventName
});
case 'photo_deleted':
return t('admin.notificationMessages.photoDeleted', { eventName: notification.eventName });
case 'photos_bulk_deleted':
return t('admin.notificationMessages.photosBulkDeleted', {
count: notification.metadata.count || 0,
eventName: notification.eventName
});
case 'event_expiring':
return `Event "${notification.eventName}" expires in ${notification.metadata.days || 0} days`;
return t('admin.notificationMessages.eventExpiring', {
eventName: notification.eventName,
days: notification.metadata.days || 0
});
case 'event_expired':
return `Event "${notification.eventName}" has expired`;
return t('admin.notificationMessages.eventExpired', { eventName: notification.eventName });
case 'password_changed':
return `Password changed by ${notification.actorName}`;
return t('admin.notificationMessages.passwordChanged', { actorName: notification.actorName });
case 'password_reset':
return t('admin.notificationMessages.passwordReset', {
eventName: notification.metadata.eventName
});
case 'settings_updated':
return `${notification.metadata.type || 'System'} settings updated`;
return t('admin.notificationMessages.settingsUpdated', {
type: notification.metadata.type || 'System'
});
case 'email_template_updated':
return `Email template "${notification.metadata.template}" updated`;
return t('admin.notificationMessages.emailTemplateUpdated', {
template: notification.metadata.template
});
case 'bulk_download':
return `${notification.metadata.count || 0} photos downloaded from "${notification.eventName}"`;
return t('admin.notificationMessages.bulkDownload', {
count: notification.metadata.count || 0,
eventName: notification.eventName
});
case 'storage_warning':
return `Storage usage at ${notification.metadata.percentage || 0}%`;
return t('admin.notificationMessages.storageWarning', {
percentage: notification.metadata.percentage || 0
});
case 'admin_logout':
return t('admin.notificationMessages.adminLogout', { actorName: notification.actorName });
case 'category_created':
return t('admin.notificationMessages.categoryCreated', {
name: notification.metadata.name,
eventName: notification.eventName
});
case 'category_updated':
return t('admin.notificationMessages.categoryUpdated', {
name: notification.metadata.name,
eventName: notification.eventName
});
case 'category_deleted':
return t('admin.notificationMessages.categoryDeleted', {
name: notification.metadata.name,
eventName: notification.eventName
});
case 'cms_page_updated':
return t('admin.notificationMessages.cmsPageUpdated', {
slug: notification.metadata.slug
});
case 'email_config_updated':
return t('admin.notificationMessages.emailConfigUpdated');
case 'favicon_uploaded':
return t('admin.notificationMessages.faviconUploaded');
case 'branding_updated':
return t('admin.notificationMessages.brandingUpdated');
case 'general_settings_updated':
return t('admin.notificationMessages.generalSettingsUpdated');
case 'security_settings_updated':
return t('admin.notificationMessages.securitySettingsUpdated');
case 'theme_updated':
return t('admin.notificationMessages.themeUpdated');
case 'archive_downloaded':
return t('admin.notificationMessages.archiveDownloaded', {
eventName: notification.metadata.event_name
});
case 'archive_deleted':
return t('admin.notificationMessages.archiveDeleted', {
eventName: notification.metadata.event_name
});
case 'archive_restored':
return t('admin.notificationMessages.archiveRestored', {
eventName: notification.metadata.event_name
});
default:
return notification.metadata.message || 'System notification';
// Log unknown notification types for debugging
console.warn('Unknown notification type:', notification.type, notification);
return notification.metadata.message || t('admin.notificationMessages.systemActivity', {
type: notification.type.replace(/_/g, ' ')
});
}
},
@@ -78,22 +163,48 @@ export const notificationsService = {
return { icon: 'Calendar', color: 'text-blue-600' };
case 'event_archived':
return { icon: 'Archive', color: 'text-green-600' };
case 'event_updated':
case 'event_deleted':
return { icon: 'Calendar', color: 'text-gray-600' };
case 'photos_uploaded':
return { icon: 'Image', color: 'text-purple-600' };
case 'photo_deleted':
case 'photos_bulk_deleted':
return { icon: 'Image', color: 'text-red-600' };
case 'event_expiring':
return { icon: 'AlertCircle', color: 'text-amber-600' };
case 'event_expired':
return { icon: 'Clock', color: 'text-red-600' };
case 'password_changed':
case 'password_reset':
return { icon: 'Lock', color: 'text-indigo-600' };
case 'settings_updated':
case 'branding_updated':
case 'general_settings_updated':
case 'security_settings_updated':
case 'theme_updated':
return { icon: 'Settings', color: 'text-gray-600' };
case 'email_template_updated':
case 'email_config_updated':
return { icon: 'Mail', color: 'text-teal-600' };
case 'bulk_download':
return { icon: 'Download', color: 'text-cyan-600' };
case 'storage_warning':
return { icon: 'Database', color: 'text-orange-600' };
case 'admin_logout':
return { icon: 'LogOut', color: 'text-gray-600' };
case 'category_created':
case 'category_updated':
case 'category_deleted':
return { icon: 'Folder', color: 'text-indigo-600' };
case 'cms_page_updated':
return { icon: 'FileText', color: 'text-green-600' };
case 'favicon_uploaded':
return { icon: 'Globe', color: 'text-purple-600' };
case 'archive_downloaded':
case 'archive_deleted':
case 'archive_restored':
return { icon: 'Archive', color: 'text-blue-600' };
default:
return { icon: 'Bell', color: 'text-gray-600' };
}
+7
View File
@@ -5,6 +5,7 @@ export interface Event {
event_type: string;
event_name: string;
event_date: string;
host_name?: string;
host_email: string;
admin_email: string;
welcome_message?: string;
@@ -26,6 +27,10 @@ export interface Event {
}>;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
total_views?: number;
total_downloads?: number;
unique_visitors?: number;
}
export interface GalleryInfo {
@@ -36,6 +41,7 @@ export interface GalleryInfo {
is_active: boolean;
is_expired: boolean;
requires_password?: boolean;
color_theme?: string;
}
export interface Photo {
@@ -69,6 +75,7 @@ export interface GalleryData {
expires_at: string;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
};
categories?: PhotoCategory[];
photos: Photo[];