Fix language setting not being saved to database on admin settings page

- Added default_language field to general settings state in SettingsPage
- Replaced LanguageSelector component with simple select dropdown on settings page
- Fixed public settings endpoint to read general_default_language from database
- Language setting now properly saved when clicking Save Settings button
- Setting is correctly used by gallery login page and legal pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-08 09:49:45 +02:00
parent cfa0b0da69
commit 2012b0bab9
91 changed files with 6183 additions and 577 deletions
+11 -5
View File
@@ -2,10 +2,12 @@ import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react';
import { format } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useAdminAuth } from '../../contexts';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -14,6 +16,7 @@ interface AdminHeaderProps {
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate();
const { user, logout } = useAdminAuth();
const { t } = useTranslation();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
@@ -66,6 +69,9 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Language Selector */}
<LanguageSelector />
{/* Notifications */}
<div className="relative" ref={notificationRef}>
<button
@@ -82,7 +88,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-neutral-200 py-2">
<div className="px-4 py-2 border-b border-neutral-100">
<h3 className="text-sm font-semibold text-neutral-900">Notifications</h3>
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.map((notification) => (
@@ -99,7 +105,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</div>
<div className="px-4 py-2 border-t border-neutral-100">
<button className="text-sm text-primary-600 hover:text-primary-700">
View all notifications
{t('admin.viewAllNotifications')}
</button>
</div>
</div>
@@ -136,7 +142,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<Settings className="w-4 h-4" />
Settings
{t('navigation.settings')}
</button>
<button
onClick={() => {
@@ -146,14 +152,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<Lock className="w-4 h-4" />
Change Password
{t('admin.changePassword')}
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<LogOut className="w-4 h-4" />
Sign Out
{t('common.logout')}
</button>
</div>
)}
+19 -14
View File
@@ -9,9 +9,11 @@ import {
Settings,
Camera,
X,
Palette
Palette,
FileText
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
interface AdminSidebarProps {
@@ -20,23 +22,25 @@ interface AdminSidebarProps {
}
interface NavItem {
name: string;
nameKey: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ name: 'Events', href: '/admin/events', icon: Calendar },
{ name: 'Archives', href: '/admin/archives', icon: Archive },
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
{ name: 'Email Settings', href: '/admin/email', icon: Mail },
{ name: 'Branding', href: '/admin/branding', icon: Palette },
{ name: 'Settings', href: '/admin/settings', icon: Settings },
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 },
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText },
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
const location = useLocation();
const { t } = useTranslation();
return (
<div
@@ -49,7 +53,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200">
<div className="flex items-center">
<Camera className="w-8 h-8 text-primary-600" />
<span className="ml-2 text-xl font-bold text-neutral-900">Photo Admin</span>
<span className="ml-2 text-xl font-bold text-neutral-900">{t('admin.title')}</span>
</div>
<button
onClick={onClose}
@@ -67,7 +71,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
return (
<NavLink
key={item.name}
key={item.nameKey}
to={item.href}
onClick={() => onClose()}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
@@ -79,7 +83,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
<item.icon className={`w-5 h-5 mr-3 ${
isActive ? 'text-primary-600' : 'text-neutral-400'
}`} />
{item.name}
{t(item.nameKey)}
</NavLink>
);
})}
@@ -93,6 +97,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
};
const StorageInfo: React.FC = () => {
const { t } = useTranslation();
const { data: storageInfo } = useQuery({
queryKey: ['storage-info'],
queryFn: () => settingsService.getStorageInfo(),
@@ -115,7 +120,7 @@ const StorageInfo: React.FC = () => {
<div className="p-4 border-t border-neutral-200">
<div className="bg-neutral-100 rounded-lg p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-700">Storage Used</span>
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
<span className="font-medium text-neutral-900">
{settingsService.formatBytes(storageInfo.total_used)}
</span>
@@ -127,7 +132,7 @@ const StorageInfo: React.FC = () => {
/>
</div>
<p className="text-xs text-neutral-600 mt-1">
{usagePercent}% of {settingsService.formatBytes(storageInfo.storage_limit)}
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
</p>
</div>
</div>
+191
View File
@@ -0,0 +1,191 @@
import React, { useState } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
Heading1,
Heading2,
Undo,
Redo
} from 'lucide-react';
import { Button } from '../common';
interface CMSEditorProps {
content: string;
onChange: (content: string) => void;
}
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
// Update editor content when prop changes
React.useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
const addLink = () => {
if (linkUrl) {
editor.chain().focus().setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkDialog(false);
}
};
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
}> = ({ onClick, active, children, title }) => (
<button
onClick={onClick}
className={`p-2 rounded hover:bg-neutral-100 ${
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
}`}
title={title}
type="button"
>
{children}
</button>
);
return (
<div className="border border-neutral-300 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 p-2 border-b border-neutral-200 bg-neutral-50 flex-wrap">
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
<Heading1 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold"
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic"
>
<Italic className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet List"
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title="Add Link"
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
title="Undo"
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
title="Redo"
>
<Redo className="w-4 h-4" />
</MenuButton>
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..."
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>Add Link</Button>
<Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false);
setLinkUrl('');
}}>
Cancel
</Button>
</div>
)}
{/* Editor */}
<EditorContent
editor={editor}
className="min-h-[300px] p-4 prose prose-neutral max-w-none focus:outline-none"
/>
</div>
);
};
CMSEditor.displayName = 'CMSEditor';
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
export const CategoryManager: React.FC = () => {
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [newCategoryName, setNewCategoryName] = useState('');
const [editingName, setEditingName] = useState('');
// Fetch global categories
const { data: categories = [], isLoading } = useQuery({
queryKey: ['global-categories'],
queryFn: categoriesService.getGlobalCategories,
});
// Create category mutation
const createMutation = useMutation({
mutationFn: (name: string) =>
categoriesService.createCategory({ name, is_global: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category created successfully');
setNewCategoryName('');
setIsAdding(false);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to create category');
},
});
// Update category mutation
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) =>
categoriesService.updateCategory(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category updated successfully');
setEditingId(null);
setEditingName('');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to update category');
},
});
// Delete category mutation
const deleteMutation = useMutation({
mutationFn: categoriesService.deleteCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category deleted successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to delete category');
},
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
}
};
const handleUpdate = (id: number) => {
if (editingName.trim()) {
updateMutation.mutate({ id, name: editingName.trim() });
}
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
deleteMutation.mutate(category.id);
}
};
const startEdit = (category: PhotoCategory) => {
setEditingId(category.id);
setEditingName(category.name);
};
const cancelEdit = () => {
setEditingId(null);
setEditingName('');
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-neutral-900">Photo Categories</h3>
{!isAdding && (
<Button
variant="primary"
size="sm"
onClick={() => setIsAdding(true)}
leftIcon={<Plus className="w-4 h-4" />}
>
Add Category
</Button>
)}
</div>
{/* Add new category form */}
{isAdding && (
<div className="flex gap-2 p-3 bg-neutral-50 rounded-lg">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Category name"
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Create'
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
setIsAdding(false);
setNewCategoryName('');
}}
>
Cancel
</Button>
</div>
)}
{/* Categories list */}
<div className="space-y-2">
{categories.length === 0 ? (
<p className="text-neutral-500 text-center py-8">
No categories yet. Create your first category to organize photos.
</p>
) : (
categories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between p-3 bg-white rounded-lg border border-neutral-200 hover:border-neutral-300 transition-colors"
>
{editingId === category.id ? (
<div className="flex gap-2 flex-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') handleUpdate(category.id);
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 px-3 py-1 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={() => handleUpdate(category.id)}
disabled={!editingName.trim() || updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Save'
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={cancelEdit}
>
Cancel
</Button>
</div>
) : (
<>
<div>
<p className="font-medium text-neutral-900">{category.name}</p>
<p className="text-sm text-neutral-500">/{category.slug}</p>
</div>
<div className="flex gap-1">
<button
onClick={() => startEdit(category)}
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
title="Edit category"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(category)}
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete category"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</>
)}
</div>
))
)}
</div>
</div>
);
};
CategoryManager.displayName = 'CategoryManager';
@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, X, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
interface EventCategoryManagerProps {
eventId: number;
}
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
// Fetch categories for this event
const { data: categories = [], isLoading } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
});
// Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global);
// Create category mutation
const createMutation = useMutation({
mutationFn: (name: string) =>
categoriesService.createCategory({
name,
is_global: false,
event_id: eventId
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
toast.success('Category created successfully');
setNewCategoryName('');
setIsAdding(false);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to create category');
},
});
// Delete category mutation
const deleteMutation = useMutation({
mutationFn: categoriesService.deleteCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
toast.success('Category deleted successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to delete category');
},
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
}
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
deleteMutation.mutate(category.id);
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-primary-600" />
</div>
);
}
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>
{!isAdding && (
<Button
variant="outline"
size="sm"
onClick={() => setIsAdding(true)}
leftIcon={<Plus className="w-3 h-3" />}
>
Add
</Button>
)}
</div>
{/* Add new category form */}
{isAdding && (
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Category name"
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
'Add'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsAdding(false);
setNewCategoryName('');
}}
>
Cancel
</Button>
</div>
)}
{/* Event categories list */}
{eventCategories.length === 0 ? (
<p className="text-sm text-neutral-500 italic">
No event-specific categories. Global categories are available by default.
</p>
) : (
<div className="space-y-1">
{eventCategories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
>
<span className="text-sm text-neutral-700">{category.name}</span>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
title="Delete category"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</div>
))}
</div>
)}
{/* 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>
<div className="flex flex-wrap gap-1">
{categories
.filter(cat => cat.is_global)
.map(cat => (
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded">
{cat.name}
</span>
))}
</div>
</div>
</div>
);
};
EventCategoryManager.displayName = 'EventCategoryManager';
+35 -27
View File
@@ -4,6 +4,8 @@ import { Button } from '../common';
import { clsx } from 'clsx';
import { api } from '../../config/api';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service';
interface PhotoUploadProps {
eventId: number;
@@ -14,8 +16,14 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [photoType, setPhotoType] = useState<'individual' | 'collage'>('individual');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch categories for this event
const { data: categories = [] } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
});
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
@@ -36,10 +44,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setUploadProgress(0);
const formData = new FormData();
selectedFiles.forEach(file => {
selectedFiles.forEach((file, index) => {
console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`);
formData.append('photos', file);
});
formData.append('type', photoType);
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
// Debug: Log FormData contents
console.log('FormData entries:');
for (let pair of formData.entries()) {
console.log(pair[0], pair[1]);
}
try {
const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, {
@@ -86,33 +104,23 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
return (
<div className="space-y-4">
{/* Photo Type Selection */}
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Photo Type
Photo Category
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
value="individual"
checked={photoType === 'individual'}
onChange={(e) => setPhotoType(e.target.value as 'individual')}
className="mr-2"
/>
<span>Individual Photos</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="collage"
checked={photoType === 'collage'}
onChange={(e) => setPhotoType(e.target.value as 'collage')}
className="mr-2"
/>
<span>Collages</span>
</label>
</div>
<select
value={selectedCategoryId || ''}
onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">No category</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name} {!category.is_global && '(Event specific)'}
</option>
))}
</select>
</div>
{/* File Input Area */}
@@ -1,31 +1,39 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Palette, RotateCcw, Check, Upload } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { PRESET_THEMES, type ThemeConfig } from '../../contexts/ThemeContext';
import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify';
interface ThemeCustomizerProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
presetName?: string;
onPresetChange?: (presetName: string) => void;
isPreviewMode?: boolean;
}
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
value,
onChange,
presetName = 'default',
onPresetChange
onPresetChange,
isPreviewMode = false
}) => {
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
const logoInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLocalTheme(value);
setCustomCss(value.customCss || '');
}, [value]);
useEffect(() => {
setSelectedPreset(presetName);
}, [presetName]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
setLocalTheme(updated);
@@ -55,18 +63,31 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
};
const handleReset = () => {
handlePresetSelect('default');
const defaultPreset = PRESET_THEMES['default'];
if (defaultPreset) {
setSelectedPreset('default');
setLocalTheme(defaultPreset.config);
setCustomCss('');
onChange(defaultPreset.config);
if (onPresetChange) {
onPresetChange('default');
}
}
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
handleChange('logoUrl', dataUrl);
};
reader.readAsDataURL(file);
try {
// Upload to server
const logoUrl = await settingsService.uploadLogo(file);
// Update theme with the server URL
handleChange('logoUrl', logoUrl);
toast.success('Logo uploaded successfully');
} catch (error) {
console.error('Failed to upload logo:', error);
toast.error('Failed to upload logo');
}
}
};
@@ -252,33 +273,33 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
<div className="flex items-center gap-4">
{localTheme.logoUrl && (
<img
src={localTheme.logoUrl}
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${localTheme.logoUrl}`}
alt="Custom logo"
className="h-16 w-auto object-contain"
/>
)}
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
>
Upload Logo
</Button>
</label>
<input
ref={logoInputRef}
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => logoInputRef.current?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
Upload Logo
</Button>
{localTheme.logoUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleChange('logoUrl', undefined)}
onClick={() => handleChange('logoUrl', '')}
>
Remove
</Button>
@@ -303,34 +324,21 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPreviewMode}
onChange={(e) => setIsPreviewMode(e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-neutral-700">Live Preview</span>
</label>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
Reset to Default
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
>
Apply Theme
</Button>
</div>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
Reset to Default
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
>
Apply Theme
</Button>
</div>
</div>
);
+4 -1
View File
@@ -4,4 +4,7 @@ export { AdminHeader } from './AdminHeader';
export { ThemeCustomizer } from './ThemeCustomizer';
export { PasswordChangeModal } from './PasswordChangeModal';
export { AdminAuthWrapper } from './AdminAuthWrapper';
export { PhotoUpload } from './PhotoUpload';
export { PhotoUpload } from './PhotoUpload';
export { CategoryManager } from './CategoryManager';
export { EventCategoryManager } from './EventCategoryManager';
export { CMSEditor } from './CMSEditor';
@@ -0,0 +1,107 @@
import React, { useState, useEffect } from 'react';
import { getAuthToken } from '../../config/api';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
fallbackSrc?: string;
useWatermark?: boolean;
}
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
src,
fallbackSrc,
alt,
useWatermark = false,
...props
}) => {
const [imageSrc, setImageSrc] = useState<string>('');
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let objectUrl: string | null = null;
const token = getAuthToken();
if (!src) {
setImageSrc(fallbackSrc || '');
setIsLoading(false);
return;
}
if (!token) {
console.warn('No auth token found for image:', src);
setImageSrc(fallbackSrc || '');
setIsLoading(false);
return;
}
setIsLoading(true);
setError(false);
// Create a new URL with auth header
const fetchImage = async () => {
try {
// If watermark is requested and this is a gallery photo, use the protected images endpoint
let imageUrl = src;
if (useWatermark && src.includes('/photos/')) {
// Extract gallery slug and photo ID from the URL
// URL format: /photos/events/active/{slug}/photos/{photoId}.jpg
const match = src.match(/\/photos\/events\/active\/([^\/]+)\/photos\/(\d+)\./);
if (match) {
const [, slug, photoId] = match;
imageUrl = `/api/images/${slug}/photo/${photoId}/view`;
}
}
console.log('Fetching authenticated image:', imageUrl);
const response = await fetch(imageUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setImageSrc(objectUrl);
setIsLoading(false);
} catch (err) {
console.error('Failed to load image:', src, err);
setError(true);
setImageSrc(fallbackSrc || '');
setIsLoading(false);
}
};
fetchImage();
// Cleanup function
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src, fallbackSrc, useWatermark]);
if (isLoading) {
return (
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
{/* Show a placeholder while loading */}
</div>
);
}
if (error && fallbackSrc) {
return <img src={fallbackSrc} alt={alt} {...props} />;
}
if (!imageSrc) {
return null;
}
return <img src={imageSrc} alt={alt} {...props} />;
};
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
useEffect(() => {
if (settings?.branding_favicon_url) {
// Remove existing favicon links
const existingFavicons = document.querySelectorAll("link[rel*='icon']");
existingFavicons.forEach(favicon => favicon.remove());
// Create new favicon link
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
link.href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_favicon_url}`;
document.head.appendChild(link);
}
}, [settings?.branding_favicon_url]);
return null;
};
@@ -2,6 +2,7 @@ import React, { Component } from 'react';
import type { ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from './Button';
import i18n from '../../i18n/config';
interface Props {
children: ReactNode;
@@ -46,16 +47,16 @@ export class ErrorBoundary extends Component<Props, State> {
<div className="text-center max-w-md">
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
Something went wrong
{i18n.t('errors.somethingWentWrong')}
</h2>
<p className="text-sm text-neutral-600 mb-6">
{this.state.error?.message || 'An unexpected error occurred. Please try refreshing the page.'}
{this.state.error?.message || i18n.t('errors.tryAgainLater')}
</p>
<Button
onClick={this.handleReset}
leftIcon={<RefreshCw className="w-4 h-4" />}
>
Refresh Page
{i18n.t('errors.refreshPage')}
</Button>
</div>
</div>
@@ -93,10 +94,10 @@ export class PageErrorBoundary extends Component<Props, State> {
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-6" />
<h1 className="text-2xl font-bold text-neutral-900 mb-4">
Oops! Something went wrong
{i18n.t('errors.oopsSomethingWentWrong')}
</h1>
<p className="text-neutral-600 mb-8">
We encountered an unexpected error. Don't worry, your data is safe.
{i18n.t('errors.unexpectedError')}
</p>
<div className="space-y-3">
<Button
@@ -105,20 +106,20 @@ export class PageErrorBoundary extends Component<Props, State> {
leftIcon={<RefreshCw className="w-4 h-4" />}
className="w-full"
>
Go to Homepage
{i18n.t('errors.goToHomepage')}
</Button>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="w-full"
>
Try Again
{i18n.t('gallery.tryAgain')}
</Button>
</div>
{import.meta.env.DEV && this.state.error && (
<details className="mt-8 text-left">
<summary className="text-sm text-neutral-500 cursor-pointer hover:text-neutral-700">
Error Details
{i18n.t('errors.errorDetails')}
</summary>
<pre className="mt-2 text-xs bg-neutral-100 p-3 rounded overflow-auto">
{this.state.error.stack}
@@ -0,0 +1,54 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
const languages = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
];
export const LanguageSelector: React.FC = () => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = React.useState(false);
const currentLanguage = languages.find(lang => lang.code === i18n.language) || languages[0];
const handleLanguageChange = (languageCode: string) => {
i18n.changeLanguage(languageCode);
setIsOpen(false);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<Globe className="w-4 h-4" />
<span>{currentLanguage.flag}</span>
<span>{currentLanguage.name}</span>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-50">
{languages.map((language) => (
<button
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 flex items-center gap-3 ${
language.code === i18n.language
? 'text-primary-600 bg-primary-50'
: 'text-neutral-700'
}`}
>
<span className="text-lg">{language.flag}</span>
<span>{language.name}</span>
</button>
))}
</div>
)}
</div>
);
};
LanguageSelector.displayName = 'LanguageSelector';
+4 -1
View File
@@ -12,4 +12,7 @@ export {
SkeletonList
} from './Skeleton';
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
export { SkipLink } from './SkipLink';
export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon';
export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage';
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Clock, AlertCircle } from 'lucide-react';
import { differenceInSeconds } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface CountdownTimerProps {
expiresAt: string;
@@ -8,6 +9,7 @@ interface CountdownTimerProps {
}
export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, className = '' }) => {
const { t } = useTranslation();
const [timeLeft, setTimeLeft] = useState<{
hours: number;
minutes: number;
@@ -43,7 +45,7 @@ export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, class
return (
<div className={`flex items-center gap-2 text-red-600 ${className}`}>
<AlertCircle className="w-5 h-5" />
<span className="font-semibold">Gallery Expired</span>
<span className="font-semibold">{t('gallery.expired')}</span>
</div>
);
}
@@ -69,7 +71,7 @@ export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, class
{String(timeLeft.seconds).padStart(2, '0')}
</div>
</div>
<span className="text-sm text-orange-600 font-medium">remaining</span>
<span className="text-sm text-orange-600 font-medium">{t('gallery.remaining')}</span>
</div>
);
};
@@ -2,6 +2,7 @@ import React from 'react';
import { AlertTriangle, Download } from 'lucide-react';
import Countdown from 'react-countdown';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface ExpirationBannerProps {
daysRemaining: number;
@@ -12,11 +13,12 @@ export const ExpirationBanner: React.FC<ExpirationBannerProps> = ({
daysRemaining,
expiresAt
}) => {
const { t } = useTranslation();
const expirationDate = parseISO(expiresAt);
const countdownRenderer = ({ days, hours, minutes, completed }: any) => {
if (completed) {
return <span>Gallery has expired</span>;
return <span>{t('gallery.expired')}</span>;
} else {
return (
<span className="font-mono">
@@ -39,12 +41,12 @@ export const ExpirationBanner: React.FC<ExpirationBannerProps> = ({
<div className="flex items-center">
<AlertTriangle className="w-5 h-5 mr-2 animate-pulse" />
<span className="font-medium">
Gallery expires in <Countdown date={expirationDate} renderer={countdownRenderer} />
{t('gallery.expiresIn', { count: daysRemaining })} <Countdown date={expirationDate} renderer={countdownRenderer} />
</span>
</div>
<div className="flex items-center text-sm">
<Download className="w-4 h-4 mr-1" />
<span>Download your photos now!</span>
<span>{t('gallery.downloadBefore')}</span>
</div>
</div>
</div>
@@ -0,0 +1,171 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { Button, LanguageSelector } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
interface GalleryLayoutProps {
event: {
event_name: string;
event_type?: string;
event_date?: string;
expires_at?: string;
};
brandingSettings?: {
company_name?: string;
company_tagline?: string;
support_email?: string;
footer_text?: string;
favicon_url?: string;
logo_url?: string;
};
showLogout?: boolean;
onLogout?: () => void;
showDownloadAll?: boolean;
onDownloadAll?: () => void;
isDownloading?: boolean;
headerExtra?: React.ReactNode;
children: React.ReactNode;
}
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
event,
brandingSettings,
showLogout = false,
onLogout,
showDownloadAll = false,
onDownloadAll,
isDownloading = false,
headerExtra,
children,
}) => {
const { t } = useTranslation();
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-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Company logo */}
{brandingSettings?.logo_url && (
<div className="pr-4 border-r border-neutral-200">
<img
src={brandingSettings.logo_url}
alt={brandingSettings.company_name || 'Company Logo'}
className="h-12 w-auto object-contain"
/>
</div>
)}
{/* Company branding */}
{!brandingSettings?.logo_url && brandingSettings?.company_name && (
<div className="pr-4 border-r border-neutral-200">
<h2 className="text-lg font-semibold text-neutral-800">{brandingSettings.company_name}</h2>
{brandingSettings.company_tagline && (
<p className="text-xs text-neutral-600">{brandingSettings.company_tagline}</p>
)}
</div>
)}
<div>
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
{(event.event_date || event.expires_at) && (
<div className="flex items-center gap-4 mt-1 text-sm text-neutral-600">
{event.event_date && (
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
</span>
)}
{event.expires_at && (
<span className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{t('gallery.expires')} {format(parseISO(event.expires_at), 'MMM d')}
</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
{headerExtra}
<LanguageSelector />
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="md"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
>
{t('gallery.downloadAll')}
</Button>
)}
{showLogout && onLogout && (
<Button
variant="outline"
size="md"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
>
{t('common.logout')}
</Button>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="container">{children}</main>
{/* Footer */}
<footer className="mt-12 py-8 border-t border-neutral-200">
<div className="container text-center">
{brandingSettings?.support_email && (
<p className="text-sm text-neutral-600 mb-2">
{t('gallery.needHelp')}{' '}
<a
href={`mailto:${brandingSettings.support_email}`}
className="text-primary-600 hover:text-primary-700"
>
{brandingSettings.support_email}
</a>
</p>
)}
<p className="text-sm text-neutral-500">
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
</p>
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
<p className="text-xs text-neutral-400 mt-2">
{brandingSettings.company_name} - {brandingSettings.company_tagline}
</p>
)}
{/* Legal Links */}
<div className="mt-4 flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
</div>
</footer>
</div>
);
};
GalleryLayout.displayName = 'GalleryLayout';
+82 -209
View File
@@ -1,14 +1,16 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Download, Grid, Square, LogOut, Calendar, Clock, Search, SortAsc } from 'lucide-react';
import { format, differenceInDays, parseISO } from 'date-fns';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { differenceInDays, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, SkeletonGalleryGrid, Skeleton } from '../common';
import { Button, SkeletonGalleryGrid, Skeleton } from '../common';
import { useGalleryAuth, useTheme } from '../../contexts';
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
import { PhotoGrid } from './PhotoGrid';
import { ExpirationBanner } from './ExpirationBanner';
import { CountdownTimer } from './CountdownTimer';
import { GalleryLayout } from './GalleryLayout';
import { PhotoFilterBar } from './PhotoFilterBar';
import { analyticsService } from '../../services/analytics.service';
import { api } from '../../config/api';
@@ -26,13 +28,14 @@ interface GalleryViewProps {
}
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation();
const { logout } = useGalleryAuth();
const { setTheme } = useTheme();
const [viewMode, setViewMode] = useState<'all' | 'collages' | 'individual'>('all');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const [showSortMenu, setShowSortMenu] = useState(false);
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const themeAppliedRef = useRef(false);
// Fetch photos
const { data, isLoading, error } = useGalleryPhotos(slug);
@@ -48,40 +51,56 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Apply theme and branding settings
// Apply branding settings
useEffect(() => {
if (settingsData) {
// Apply branding settings
setBrandingSettings({
company_name: settingsData.branding_company_name || '',
company_tagline: settingsData.branding_company_tagline || '',
support_email: settingsData.branding_support_email || '',
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
});
// Apply theme settings
if (settingsData.theme_config) {
setTheme(settingsData.theme_config);
}
}
}, [settingsData, setTheme]);
}, [settingsData]);
// Apply event-specific theme if available
// Apply theme only once when component mounts and settings are loaded
useEffect(() => {
if (event.color_theme) {
try {
const eventTheme = JSON.parse(event.color_theme);
console.log('Applying event-specific theme:', eventTheme);
setTheme(eventTheme);
} catch (e) {
console.error('Failed to parse event theme:', e);
if (!themeAppliedRef.current && settingsData) {
let themeToApply = null;
if (event.color_theme) {
try {
// Check if it's a valid JSON string
if (event.color_theme.startsWith('{')) {
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;
}
}
} 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 only once
if (themeToApply) {
themeAppliedRef.current = true;
setTheme(themeToApply);
}
} else if (settingsData?.theme_config) {
// Fall back to global theme if no event-specific theme
console.log('No event theme, using global theme');
}
}, [event.color_theme, setTheme, settingsData]);
}, [settingsData]); // Only depend on settingsData, not setTheme or event
// Calculate days until expiration
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
@@ -93,11 +112,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
let photos = [...data.photos];
// Apply view mode filter
if (viewMode === 'collages') {
photos = photos.filter(photo => photo.type === 'collage');
} else if (viewMode === 'individual') {
photos = photos.filter(photo => photo.type === 'individual');
// Apply category filter
if (selectedCategoryId) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
@@ -122,7 +139,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
});
return photos;
}, [data?.photos, viewMode, searchTerm, sortBy]);
}, [data?.photos, selectedCategoryId, searchTerm, sortBy]);
const handleDownloadAll = () => {
downloadAllMutation.mutate(slug);
@@ -185,9 +202,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<div className="text-center">
<p className="text-lg text-neutral-600">Failed to load photos</p>
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
Try Again
{t('gallery.tryAgain')}
</Button>
</div>
</div>
@@ -195,71 +212,28 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return (
<div className="min-h-screen bg-neutral-50">
<GalleryLayout
event={event}
brandingSettings={brandingSettings}
showLogout={true}
onLogout={logout}
showDownloadAll={true}
onDownloadAll={handleDownloadAll}
isDownloading={downloadAllMutation.isPending}
headerExtra={
daysUntilExpiration <= 1 && daysUntilExpiration > 0 ? (
<CountdownTimer expiresAt={event.expires_at} className="mr-4" />
) : null
}
>
{/* Expiration Banner */}
{showUrgentWarning && (
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
{/* Header */}
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
<div className="container py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Company branding */}
{brandingSettings?.company_name && (
<div className="pr-4 border-r border-neutral-200">
<h2 className="text-lg font-semibold text-neutral-800">{brandingSettings.company_name}</h2>
{brandingSettings.company_tagline && (
<p className="text-xs text-neutral-600">{brandingSettings.company_tagline}</p>
)}
</div>
)}
<div>
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
<div className="flex items-center gap-4 mt-1 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')}
</span>
<span className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
Expires {format(parseISO(event.expires_at), 'MMM d')}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
<CountdownTimer expiresAt={event.expires_at} className="mr-4" />
)}
<Button
variant="primary"
size="md"
leftIcon={<Download className="w-4 h-4" />}
onClick={handleDownloadAll}
isLoading={downloadAllMutation.isPending}
className={showUrgentWarning ? 'animate-pulse' : ''}
>
Download All
</Button>
<Button
variant="outline"
size="md"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={logout}
>
Logout
</Button>
</div>
</div>
</div>
</header>
{/* Welcome Message */}
{event.welcome_message && (
<div className="container mt-6">
<div className="mt-6">
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<p className="text-primary-900">{event.welcome_message}</p>
</div>
@@ -267,125 +241,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
)}
{/* Search and Filters */}
<div className="container mt-6">
<div className="flex flex-col lg:flex-row gap-4 mb-6">
{/* Search Bar */}
<div className="flex-1">
<Input
type="text"
placeholder="Search photos by filename..."
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Sort Dropdown */}
<div className="relative">
<Button
variant="outline"
size="md"
leftIcon={<SortAsc className="w-4 h-4" />}
onClick={() => setShowSortMenu(!showSortMenu)}
>
Sort by {sortBy === 'date' ? 'Date' : sortBy === 'name' ? 'Name' : 'Size'}
</Button>
{showSortMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
<button
onClick={() => {
setSortBy('date');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
>
Sort by Date
</button>
<button
onClick={() => {
setSortBy('name');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
>
Sort by Name
</button>
<button
onClick={() => {
setSortBy('size');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
>
Sort by Size
</button>
</div>
)}
</div>
</div>
{/* View Mode Toggle */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('all')}
leftIcon={<Grid className="w-4 h-4" />}
>
All Photos ({data.photos.length})
</Button>
<Button
variant={viewMode === 'collages' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('collages')}
leftIcon={<Square className="w-4 h-4" />}
>
Collages ({data.photos.filter(p => p.type === 'collage').length})
</Button>
<Button
variant={viewMode === 'individual' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('individual')}
>
Individual ({data.photos.filter(p => p.type === 'individual').length})
</Button>
</div>
<p className="text-sm text-neutral-600">
{filteredPhotos.length} {filteredPhotos.length === 1 ? 'photo' : 'photos'}
</p>
</div>
<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}
/>
{/* Photo Grid */}
<PhotoGrid photos={filteredPhotos} slug={slug} />
</div>
{/* Footer */}
<footer className="mt-12 py-8 border-t border-neutral-200">
<div className="container text-center">
{brandingSettings?.support_email && (
<p className="text-sm text-neutral-600 mb-2">
Need help? Contact us at{' '}
<a
href={`mailto:${brandingSettings.support_email}`}
className="text-primary-600 hover:text-primary-700"
>
{brandingSettings.support_email}
</a>
</p>
)}
<p className="text-sm text-neutral-500">
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
</p>
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
<p className="text-xs text-neutral-400 mt-2">
{brandingSettings.company_name} - {brandingSettings.company_tagline}
</p>
)}
<div className="mt-6">
<PhotoGrid photos={filteredPhotos} slug={slug} categoryId={selectedCategoryId} />
</div>
</footer>
</div>
</div>
</GalleryLayout>
);
};
@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { Search, SortAsc, Grid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
interface PhotoCategory {
id: number;
name: string;
slug: string;
is_global: boolean;
}
interface Photo {
id: number;
category_id?: number;
}
interface PhotoFilterBarProps {
categories?: PhotoCategory[];
photos: Photo[];
selectedCategoryId: number | null;
onCategoryChange: (categoryId: number | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size';
onSortChange: (sort: 'date' | 'name' | 'size') => void;
photoCount: number;
}
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
categories = [],
photos,
selectedCategoryId,
onCategoryChange,
searchTerm,
onSearchChange,
sortBy,
onSortChange,
photoCount,
}) => {
const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false);
return (
<div className="space-y-4">
{/* Search and Sort */}
<div className="flex flex-col lg:flex-row gap-4">
{/* Search Bar */}
<div className="flex-1">
<Input
type="text"
placeholder={t('gallery.searchPhotos')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* Sort Dropdown */}
<div className="relative">
<Button
variant="outline"
size="md"
leftIcon={<SortAsc className="w-4 h-4" />}
onClick={() => setShowSortMenu(!showSortMenu)}
>
{t('common.sortBy')} {sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') : sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') : t('gallery.sortBySize').replace('Sort by ', '')}
</Button>
{showSortMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
<button
onClick={() => {
onSortChange('date');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
}`}
>
{t('gallery.sortByDate')}
</button>
<button
onClick={() => {
onSortChange('name');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
}`}
>
{t('gallery.sortByName')}
</button>
<button
onClick={() => {
onSortChange('size');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
}`}
>
{t('gallery.sortBySize')}
</button>
</div>
)}
</div>
</div>
{/* Category Filter */}
{categories && categories.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-4 h-4" />}
>
{t('gallery.allPhotos')} ({photos.length})
</Button>
{categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
if (categoryPhotoCount === 0) return null;
return (
<Button
key={category.id}
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
</div>
<p className="text-sm text-neutral-600">
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
</p>
</div>
)}
</div>
);
};
PhotoFilterBar.displayName = 'PhotoFilterBar';
+63 -28
View File
@@ -1,28 +1,48 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, Package } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { toast } from 'react-toastify';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { PhotoLightbox } from './PhotoLightbox';
import { Button } from '../common';
import { Button, AuthenticatedImage } from '../common';
import { galleryService } from '../../services/gallery.service';
import { analyticsService } from '../../services/analytics.service';
interface PhotoGridProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
}
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }) => {
const { t } = useTranslation();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
const handlePhotoClick = (index: number) => {
if (isSelectionMode) {
// Clear selection when category changes
useEffect(() => {
setSelectedPhotos(new Set());
}, [categoryId]);
const handlePhotoClick = (index: number, e?: React.MouseEvent) => {
// Check for ctrl/cmd+click for quick selection
if (e && (e.ctrlKey || e.metaKey)) {
if (!isSelectionMode) {
setIsSelectionMode(true);
}
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photos[index].id)) {
newSelected.delete(photos[index].id);
} else {
newSelected.add(photos[index].id);
}
setSelectedPhotos(newSelected);
} else if (isSelectionMode) {
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photos[index].id)) {
newSelected.delete(photos[index].id);
@@ -66,7 +86,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
toast.info(`Downloading ${selectedPhotos.size} photos...`);
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
// Download each selected photo
const downloadPromises = selectedPhotosList.map(photo =>
@@ -79,7 +99,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
try {
await Promise.all(downloadPromises);
toast.success(`Downloaded ${selectedPhotos.size} photos!`);
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
@@ -91,14 +111,14 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
setSelectedPhotos(new Set());
setIsSelectionMode(false);
} catch (error) {
toast.error('Some photos failed to download');
toastify.error(t('gallery.downloadError'));
}
};
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-600">No photos found</p>
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
</div>
);
}
@@ -108,24 +128,39 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
{/* Selection Mode Controls */}
{photos.length > 1 && (
<div className="mb-4 flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={toggleSelectionMode}
>
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectionMode}
title={t('gallery.selectPhotosHint')}
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{!isSelectionMode && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsSelectionMode(true);
selectAll();
}}
>
{t('gallery.selectAll')}
</Button>
)}
</div>
{isSelectionMode && (
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600">
{selectedPhotos.size} selected
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<Button variant="ghost" size="sm" onClick={selectAll}>
Select All
{t('gallery.selectAll')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll}>
Deselect All
{t('gallery.deselectAll')}
</Button>
{selectedPhotos.size > 0 && (
<Button
@@ -134,7 +169,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
leftIcon={<Package className="w-4 h-4" />}
onClick={handleDownloadSelected}
>
Download {selectedPhotos.size} Selected
{t('gallery.downloadSelected', { count: selectedPhotos.size })}
</Button>
)}
</div>
@@ -150,7 +185,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(index)}
onClick={(e) => handlePhotoClick(index, e)}
onDownload={(e) => handleDownload(photo, e)}
/>
))}
@@ -173,7 +208,7 @@ interface PhotoThumbnailProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: () => void;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
}
@@ -192,12 +227,12 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
return (
<div
ref={ref}
className="relative group cursor-pointer"
onClick={onClick}
className="relative group cursor-pointer aspect-square"
onClick={(e) => onClick(e)}
>
{inView ? (
<>
<img
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
@@ -212,7 +247,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick();
onClick(e);
}}
aria-label="View full size"
>
@@ -232,7 +267,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
{/* Selection checkbox */}
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
interface PhotoLightboxProps {
photos: Photo[];
@@ -241,7 +242,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onTouchEnd={handleTouchEnd}
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
>
<img
<AuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain select-none"
@@ -250,6 +251,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
transition: isDragging ? 'none' : 'transform 0.2s',
}}
draggable={false}
useWatermark={true}
/>
</div>
+3 -1
View File
@@ -2,4 +2,6 @@ export { GalleryView } from './GalleryView';
export { PhotoGrid } from './PhotoGrid';
export { PhotoLightbox } from './PhotoLightbox';
export { ExpirationBanner } from './ExpirationBanner';
export { CountdownTimer } from './CountdownTimer';
export { CountdownTimer } from './CountdownTimer';
export { GalleryLayout } from './GalleryLayout';
export { PhotoFilterBar } from './PhotoFilterBar';