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
+13 -2
View File
@@ -8,6 +8,8 @@ import { analyticsService } from './services/analytics.service';
import { GalleryAuthProvider } from './contexts';
import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
import { LegalPage } from './pages/public/LegalPage';
import {
AdminLoginPage,
AdminDashboard,
@@ -18,10 +20,11 @@ import {
ArchivesPage,
AnalyticsPage,
BrandingPage,
SettingsPage
SettingsPage,
CMSPage
} from './pages/admin';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink } from './components/common';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
// Create a client
const queryClient = new QueryClient({
@@ -53,10 +56,12 @@ function App() {
<PageErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<DynamicFavicon />
<Router>
<SkipLink />
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
@@ -76,10 +81,16 @@ function App() {
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cms" element={<CMSPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
<Route path="/:slug" element={<LegalPage />} />
{/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
</Routes>
+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';
+5
View File
@@ -26,6 +26,11 @@ api.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
// Don't set Content-Type for FormData - let browser set it with boundary
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
return config;
},
(error) => {
+50 -30
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
export interface ThemeConfig {
@@ -107,7 +107,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
const [theme, setTheme] = useState<ThemeConfig>(initialTheme);
const [themeName, setThemeName] = useState(initialThemeName);
const applyTheme = (themeConfig: ThemeConfig) => {
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
const root = document.documentElement;
// Apply CSS variables
@@ -154,56 +154,76 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
}
styleElement.textContent = themeConfig.customCss;
}
};
}, []);
const setThemeByName = (name: string) => {
const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
setTheme(newTheme);
applyTheme(newTheme);
}, [applyTheme]);
const setThemeByName = useCallback((name: string) => {
const presetTheme = PRESET_THEMES[name];
if (presetTheme) {
setThemeName(name);
setTheme(presetTheme.config);
applyTheme(presetTheme.config);
}
};
}, [applyTheme]);
const resetTheme = () => {
const resetTheme = useCallback(() => {
setThemeByName('default');
};
}, [setThemeByName]);
// Apply theme when it changes, but skip if it's the same
useEffect(() => {
applyTheme(theme);
}, [theme]);
const root = document.documentElement;
const currentPrimary = root.style.getPropertyValue('--color-primary');
// Only apply if the theme has actually changed
if (currentPrimary !== theme.primaryColor) {
applyTheme(theme);
}
}, [theme, applyTheme]);
// Load theme from localStorage on mount
// Load theme from localStorage on mount (skip if in gallery view)
useEffect(() => {
const savedTheme = localStorage.getItem('gallery-theme');
if (savedTheme) {
try {
const parsed = JSON.parse(savedTheme);
setTheme(parsed.config);
setThemeName(parsed.name);
} catch (e) {
console.error('Failed to load saved theme:', e);
// Check if we're in a gallery view by looking at the URL
const isGalleryView = window.location.pathname.includes('/gallery/');
if (!isGalleryView) {
const savedTheme = localStorage.getItem('gallery-theme');
if (savedTheme) {
try {
const parsed = JSON.parse(savedTheme);
setTheme(parsed.config);
setThemeName(parsed.name);
} catch (e) {
console.error('Failed to load saved theme:', e);
}
}
}
}, []);
// Save theme to localStorage when it changes
useEffect(() => {
localStorage.setItem('gallery-theme', JSON.stringify({ name: themeName, config: theme }));
// 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]);
const contextValue = useMemo(() => ({
theme,
themeName,
setTheme: setThemeConfig,
setThemeByName,
applyTheme,
resetTheme
}), [theme, themeName, setThemeConfig, setThemeByName, applyTheme, resetTheme]);
return (
<ThemeContext.Provider value={{
theme,
themeName,
setTheme: (newTheme) => {
setTheme(newTheme);
applyTheme(newTheme);
},
setThemeByName,
applyTheme,
resetTheme
}}>
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
+36
View File
@@ -0,0 +1,36 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';
import enTranslations from './locales/en.json';
import deTranslations from './locales/de.json';
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
debug: false,
resources: {
en: {
translation: enTranslations,
},
de: {
translation: deTranslations,
},
},
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
caches: ['localStorage', 'cookie'],
},
});
export default i18n;
+284
View File
@@ -0,0 +1,284 @@
{
"common": {
"loading": "Wird geladen...",
"error": "Fehler",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"search": "Suchen",
"filter": "Filtern",
"sortBy": "Sortieren nach",
"yes": "Ja",
"no": "Nein",
"back": "Zurück",
"next": "Weiter",
"previous": "Zurück",
"close": "Schließen",
"logout": "Abmelden",
"download": "Herunterladen",
"downloadAll": "Alle herunterladen",
"uploading": "Wird hochgeladen...",
"uploaded": "Hochgeladen",
"photo": "Foto",
"photos": "Fotos"
},
"navigation": {
"dashboard": "Dashboard",
"events": "Veranstaltungen",
"archives": "Archive",
"settings": "Einstellungen",
"branding": "Branding",
"analytics": "Analytik",
"emailSettings": "E-Mail-Einstellungen",
"cmsPages": "CMS-Seiten"
},
"auth": {
"login": "Anmelden",
"password": "Passwort",
"enterPassword": "Galerie-Passwort eingeben",
"passwordPlaceholder": "Geben Sie das Galerie-Passwort ein",
"invalidPassword": "Ungültiges Passwort",
"sessionExpired": "Sitzung abgelaufen",
"pleaseEnterPassword": "Bitte geben Sie ein Passwort ein",
"passwordHint": "Das Passwort wurde vom Veranstalter bereitgestellt. Kontaktieren Sie ihn, wenn Sie es nicht haben."
},
"gallery": {
"title": "Fotogalerie",
"welcomeMessage": "Willkommensnachricht",
"expiresOn": "Läuft ab am",
"expires": "Läuft ab",
"expired": "Abgelaufen",
"daysRemaining": "{{days}} Tage verbleibend",
"dayRemaining": "1 Tag verbleibend",
"hoursRemaining": "{{hours}} Stunden verbleibend",
"expiredMessage": "Diese Galerie ist am {{date}} abgelaufen",
"contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen",
"searchPhotos": "Fotos nach Dateiname suchen...",
"sortByDate": "Nach Datum sortieren",
"sortByName": "Nach Name sortieren",
"sortBySize": "Nach Größe sortieren",
"allPhotos": "Alle Fotos",
"downloadSelected": "Ausgewählte herunterladen",
"shareGallery": "Galerie teilen",
"needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter",
"noPhotosFound": "Keine Fotos gefunden",
"failedToLoad": "Fotos konnten nicht geladen werden",
"tryAgain": "Erneut versuchen",
"loading": "Galerie wird geladen...",
"expiredOn": "Diese Galerie ist am {{date}} abgelaufen.",
"contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
"expiresIn": "Galerie läuft in {{count}} Tag ab",
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
"viewGallery": "Galerie anzeigen",
"downloadAll": "Alle herunterladen",
"downloading": "Lade {{count}} Foto herunter...",
"downloading_plural": "Lade {{count}} Fotos herunter...",
"downloadedPhotos": "{{count}} Foto heruntergeladen!",
"downloadedPhotos_plural": "{{count}} Fotos heruntergeladen!",
"downloadError": "Einige Fotos konnten nicht heruntergeladen werden",
"selectPhotos": "Fotos auswählen",
"cancelSelection": "Auswahl abbrechen",
"photosSelected": "{{count}} ausgewählt",
"selectAll": "Alle auswählen",
"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"
},
"categories": {
"title": "Fotokategorien",
"global": "Globale Kategorien",
"eventSpecific": "Veranstaltungsspezifische Kategorien",
"addCategory": "Kategorie hinzufügen",
"categoryName": "Kategoriename",
"noCategory": "Keine Kategorie",
"noCategoriesYet": "Noch keine Kategorien. Erstellen Sie Ihre erste Kategorie, um Fotos zu organisieren.",
"deleteConfirm": "Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten?",
"cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu."
},
"events": {
"title": "Veranstaltungen",
"createEvent": "Veranstaltung erstellen",
"eventDetails": "Veranstaltungsdetails",
"eventName": "Veranstaltungsname",
"eventType": "Veranstaltungstyp",
"eventDate": "Veranstaltungsdatum",
"hostEmail": "Gastgeber-E-Mail",
"adminEmail": "Admin-E-Mail",
"expirationDate": "Ablaufdatum",
"active": "Aktiv",
"archived": "Archiviert",
"photoCount": "{{count}} Fotos",
"totalSize": "Gesamtgröße",
"shareLink": "Freigabelink",
"copyLink": "Link kopieren",
"linkCopied": "Link kopiert!",
"viewGallery": "Galerie ansehen",
"uploadPhotos": "Fotos hochladen",
"archiveEvent": "Veranstaltung archivieren",
"archiveConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung archivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"extendExpiration": "Um {{days}} Tage verlängern"
},
"settings": {
"title": "Systemeinstellungen",
"general": {
"title": "Allgemein",
"siteConfiguration": "Website-Konfiguration",
"siteUrl": "Website-URL",
"siteUrlHelp": "Wird für die Generierung von Galerielinks in E-Mails verwendet",
"defaultExpiration": "Standardablauf (Tage)",
"maxFileSize": "Max. Dateigröße (MB)",
"allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter",
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"enableAnalytics": "Analytics-Tracking aktivieren",
"enableRegistration": "Selbstregistrierung für Admins erlauben",
"maintenanceMode": "Wartungsmodus aktivieren",
"language": "Sprache",
"saveSettings": "Allgemeine Einstellungen speichern"
},
"storage": {
"title": "Speicher",
"overview": "Speicherübersicht",
"totalUsed": "Gesamt verwendet",
"archiveStorage": "Archivspeicher",
"storageLimit": "Speicherlimit",
"storageUsage": "Speichernutzung",
"storageByEvent": "Speicher nach Veranstaltung",
"storageManagement": "Speicherverwaltung",
"storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien."
},
"security": {
"title": "Sicherheit",
"passwordSettings": "Passworteinstellungen",
"requirePassword": "Passwort für alle Galerien erforderlich",
"minPasswordLength": "Minimale Passwortlänge",
"sessionAuth": "Sitzung & Authentifizierung",
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
"maxLoginAttempts": "Max. Anmeldeversuche",
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
"recaptchaSettings": "reCAPTCHA-Einstellungen",
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
"siteKey": "Site-Schlüssel",
"secretKey": "Geheimer Schlüssel",
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
"saveSettings": "Sicherheitseinstellungen speichern"
},
"categories": {
"title": "Kategorien",
"about": "Über Fotokategorien",
"aboutText": "Globale Kategorien sind für alle Veranstaltungen verfügbar. Sie können auch veranstaltungsspezifische Kategorien erstellen, wenn Sie einzelne Veranstaltungen bearbeiten. Kategorien helfen beim Organisieren von Fotos und ermöglichen es Gästen, Fotos nach Typ in der Galerieansicht zu filtern."
}
},
"branding": {
"title": "Branding & Anpassung",
"companyInfo": "Unternehmensinformationen",
"companyName": "Unternehmensname",
"companyTagline": "Unternehmens-Slogan",
"supportEmail": "Support-E-Mail",
"footerText": "Fußzeilentext",
"logo": "Logo",
"uploadLogo": "Logo hochladen",
"removeLogo": "Logo entfernen",
"favicon": "Favicon",
"uploadFavicon": "Favicon hochladen",
"removeFavicon": "Favicon entfernen",
"watermark": "Wasserzeichen",
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"theme": "Theme",
"themeCustomization": "Theme-Anpassung",
"selectPreset": "Vorgefertigtes Theme auswählen",
"colors": "Farben",
"primaryColor": "Primärfarbe",
"secondaryColor": "Sekundärfarbe",
"accentColor": "Akzentfarbe",
"customCSS": "Benutzerdefiniertes CSS",
"preview": "Vorschau",
"previewInNewTab": "Vorschau in neuem Tab",
"reset": "Zurücksetzen",
"saveChanges": "Änderungen speichern"
},
"admin": {
"title": "Admin-Panel",
"welcome": "Willkommen zurück, {{name}}",
"recentActivity": "Letzte Aktivitäten",
"systemStatus": "Systemstatus",
"totalEvents": "Gesamte Veranstaltungen",
"activeGalleries": "Aktive Galerien",
"storageUsed": "Speicher verwendet",
"totalPhotos": "Gesamte Fotos",
"storagePercent": "{{percent}}% von {{limit}}",
"notifications": "Benachrichtigungen",
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"changePassword": "Passwort ändern",
"loadingDashboard": "Dashboard wird geladen...",
"activeEvents": "Aktive Veranstaltungen",
"expiringSoon": "Demnächst ablaufend",
"next7Days": "Nächste 7 Tage",
"totalViews": "Gesamtaufrufe",
"downloads": "Downloads",
"percentFromLastWeek": "{{percent}}% gegenüber letzter Woche",
"dashboardSubtitle": "Willkommen zurück! Hier ist, was mit Ihren Galerien passiert.",
"eventsExpiringSoon": "Demnächst ablaufende Veranstaltungen",
"noEventsExpiring": "Keine Veranstaltungen laufen in den nächsten 7 Tagen ab",
"daysLeft": "{{count}} Tag verbleibend",
"daysLeft_plural": "{{count}} Tage verbleibend",
"viewAllExpiringEvents": "Alle {{count}} ablaufenden Veranstaltungen anzeigen",
"noRecentActivity": "Keine aktuellen Aktivitäten",
"viewAllActivity": "Alle Aktivitäten anzeigen",
"quickActions": "Schnellaktionen",
"viewArchives": "Archive anzeigen",
"analytics": "Analytik"
},
"errors": {
"notFound": "Nicht gefunden",
"galleryNotFound": "Galerie nicht gefunden",
"galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.",
"unauthorized": "Nicht autorisiert",
"forbidden": "Verboten",
"serverError": "Serverfehler",
"somethingWentWrong": "Etwas ist schiefgelaufen",
"tryAgainLater": "Bitte versuchen Sie es später erneut",
"refreshPage": "Seite neu laden",
"oopsSomethingWentWrong": "Ups! Etwas ist schiefgelaufen",
"unexpectedError": "Es ist ein unerwarteter Fehler aufgetreten. Keine Sorge, Ihre Daten sind sicher.",
"goToHomepage": "Zur Startseite",
"errorDetails": "Fehlerdetails"
},
"legal": {
"impressum": "Impressum",
"datenschutz": "Datenschutzerklärung",
"termsOfService": "Nutzungsbedingungen",
"cookiePolicy": "Cookie-Richtlinie"
},
"toast": {
"saveSuccess": "Änderungen erfolgreich gespeichert",
"saveError": "Fehler beim Speichern der Änderungen",
"deleteSuccess": "Erfolgreich gelöscht",
"deleteError": "Fehler beim Löschen",
"uploadSuccess": "Upload erfolgreich abgeschlossen",
"uploadError": "Upload fehlgeschlagen",
"loginSuccess": "Anmeldung erfolgreich",
"loginError": "Anmeldung fehlgeschlagen",
"passwordChanged": "Passwort erfolgreich geändert",
"linkCopied": "Link in Zwischenablage kopiert",
"eventCreated": "Veranstaltung erfolgreich erstellt",
"eventUpdated": "Veranstaltung erfolgreich aktualisiert",
"eventArchived": "Veranstaltung erfolgreich archiviert",
"settingsSaved": "Einstellungen erfolgreich gespeichert",
"themeUpdated": "Theme erfolgreich aktualisiert",
"brandingUpdated": "Branding erfolgreich aktualisiert",
"categoryAdded": "Kategorie erfolgreich hinzugefügt",
"categoryDeleted": "Kategorie erfolgreich gelöscht",
"categoryUpdated": "Kategorie erfolgreich aktualisiert",
"emailConfigSaved": "E-Mail-Konfiguration erfolgreich gespeichert",
"testEmailSent": "Test-E-Mail erfolgreich gesendet",
"pageUpdated": "Seite erfolgreich aktualisiert",
"archiveRestored": "Archiv erfolgreich wiederhergestellt",
"archiveDeleted": "Archiv dauerhaft gelöscht"
}
}
+284
View File
@@ -0,0 +1,284 @@
{
"common": {
"loading": "Loading...",
"error": "Error",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"search": "Search",
"filter": "Filter",
"sortBy": "Sort by",
"yes": "Yes",
"no": "No",
"back": "Back",
"next": "Next",
"previous": "Previous",
"close": "Close",
"logout": "Logout",
"download": "Download",
"downloadAll": "Download All",
"uploading": "Uploading...",
"uploaded": "Uploaded",
"photo": "photo",
"photos": "photos"
},
"navigation": {
"dashboard": "Dashboard",
"events": "Events",
"archives": "Archives",
"settings": "Settings",
"branding": "Branding",
"analytics": "Analytics",
"emailSettings": "Email Settings",
"cmsPages": "CMS Pages"
},
"auth": {
"login": "Login",
"password": "Password",
"enterPassword": "Enter Gallery Password",
"passwordPlaceholder": "Enter the gallery password",
"invalidPassword": "Invalid password",
"sessionExpired": "Session expired",
"pleaseEnterPassword": "Please enter a password",
"passwordHint": "The password was provided by the event organizer. Contact them if you don't have it."
},
"gallery": {
"title": "Photo Gallery",
"welcomeMessage": "Welcome Message",
"expiresOn": "Expires on",
"expires": "Expires",
"expired": "Expired",
"daysRemaining": "{{days}} days remaining",
"dayRemaining": "1 day remaining",
"hoursRemaining": "{{hours}} hours remaining",
"expiredMessage": "This gallery expired on {{date}}",
"contactOrganizer": "Please contact the event organizer if you need access to these photos",
"searchPhotos": "Search photos by filename...",
"sortByDate": "Sort by Date",
"sortByName": "Sort by Name",
"sortBySize": "Sort by Size",
"allPhotos": "All Photos",
"downloadSelected": "Download Selected",
"shareGallery": "Share Gallery",
"needHelp": "Need help? Contact us at",
"noPhotosFound": "No photos found",
"failedToLoad": "Failed to load photos",
"tryAgain": "Try Again",
"loading": "Loading gallery...",
"expiredOn": "This gallery expired on {{date}}.",
"contactOrganizer": "Please contact the event organizer if you need access to these photos.",
"expiresIn": "Gallery expires in {{count}} day",
"expiresIn_plural": "Gallery expires in {{count}} days",
"downloadBefore": "Download your photos before they're no longer available.",
"viewGallery": "View Gallery",
"downloadAll": "Download All",
"downloading": "Downloading {{count}} photo...",
"downloading_plural": "Downloading {{count}} photos...",
"downloadedPhotos": "Downloaded {{count}} photo!",
"downloadedPhotos_plural": "Downloaded {{count}} photos!",
"downloadError": "Some photos failed to download",
"selectPhotos": "Select Photos",
"cancelSelection": "Cancel Selection",
"photosSelected": "{{count}} selected",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"downloadSelected": "Download {{count}} Selected",
"remaining": "remaining",
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos"
},
"categories": {
"title": "Photo Categories",
"global": "Global Categories",
"eventSpecific": "Event-Specific Categories",
"addCategory": "Add Category",
"categoryName": "Category name",
"noCategory": "No category",
"noCategoriesYet": "No categories yet. Create your first category to organize photos.",
"deleteConfirm": "Are you sure you want to delete \"{{name}}\"?",
"cannotDelete": "Cannot delete category with photos. Please reassign photos first."
},
"events": {
"title": "Events",
"createEvent": "Create Event",
"eventDetails": "Event Details",
"eventName": "Event Name",
"eventType": "Event Type",
"eventDate": "Event Date",
"hostEmail": "Host Email",
"adminEmail": "Admin Email",
"expirationDate": "Expiration Date",
"active": "Active",
"archived": "Archived",
"photoCount": "{{count}} photos",
"totalSize": "Total Size",
"shareLink": "Share Link",
"copyLink": "Copy Link",
"linkCopied": "Link copied!",
"viewGallery": "View Gallery",
"uploadPhotos": "Upload Photos",
"archiveEvent": "Archive Event",
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
"extendExpiration": "Extend {{days}} Days"
},
"settings": {
"title": "System Settings",
"general": {
"title": "General",
"siteConfiguration": "Site Configuration",
"siteUrl": "Site URL",
"siteUrlHelp": "Used for generating gallery links in emails",
"defaultExpiration": "Default Expiration (days)",
"maxFileSize": "Max File Size (MB)",
"allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions",
"featureToggles": "Feature Toggles",
"enableWatermark": "Enable watermark on photos",
"enableAnalytics": "Enable analytics tracking",
"enableRegistration": "Allow self-registration for admins",
"maintenanceMode": "Enable maintenance mode",
"language": "Language",
"saveSettings": "Save General Settings"
},
"storage": {
"title": "Storage",
"overview": "Storage Overview",
"totalUsed": "Total Used",
"archiveStorage": "Archive Storage",
"storageLimit": "Storage Limit",
"storageUsage": "Storage Usage",
"storageByEvent": "Storage by Event",
"storageManagement": "Storage Management",
"storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries."
},
"security": {
"title": "Security",
"passwordSettings": "Password Settings",
"requirePassword": "Require password for all galleries",
"minPasswordLength": "Minimum Password Length",
"sessionAuth": "Session & Authentication",
"sessionTimeout": "Session Timeout (minutes)",
"maxLoginAttempts": "Max Login Attempts",
"enable2FA": "Enable two-factor authentication for admins",
"recaptchaSettings": "reCAPTCHA Settings",
"enableRecaptcha": "Enable reCAPTCHA for login forms",
"siteKey": "Site Key",
"secretKey": "Secret Key",
"recaptchaHelp": "Get your reCAPTCHA keys from",
"saveSettings": "Save Security Settings"
},
"categories": {
"title": "Categories",
"about": "About Photo Categories",
"aboutText": "Global categories are available for all events. You can also create event-specific categories when editing individual events. Categories help organize photos and allow guests to filter photos by type in the gallery view."
}
},
"branding": {
"title": "Branding & Customization",
"companyInfo": "Company Information",
"companyName": "Company Name",
"companyTagline": "Company Tagline",
"supportEmail": "Support Email",
"footerText": "Footer Text",
"logo": "Logo",
"uploadLogo": "Upload Logo",
"removeLogo": "Remove Logo",
"favicon": "Favicon",
"uploadFavicon": "Upload Favicon",
"removeFavicon": "Remove Favicon",
"watermark": "Watermark",
"enableWatermark": "Enable watermark on photos",
"theme": "Theme",
"themeCustomization": "Theme Customization",
"selectPreset": "Select a preset theme",
"colors": "Colors",
"primaryColor": "Primary Color",
"secondaryColor": "Secondary Color",
"accentColor": "Accent Color",
"customCSS": "Custom CSS",
"preview": "Preview",
"previewInNewTab": "Preview in New Tab",
"reset": "Reset",
"saveChanges": "Save Changes"
},
"admin": {
"title": "Admin Panel",
"welcome": "Welcome back, {{name}}",
"recentActivity": "Recent Activity",
"systemStatus": "System Status",
"totalEvents": "Total Events",
"activeGalleries": "Active Galleries",
"storageUsed": "Storage Used",
"totalPhotos": "Total Photos",
"storagePercent": "{{percent}}% of {{limit}}",
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
"changePassword": "Change Password",
"loadingDashboard": "Loading dashboard...",
"activeEvents": "Active Events",
"expiringSoon": "Expiring Soon",
"next7Days": "Next 7 days",
"totalViews": "Total Views",
"downloads": "Downloads",
"percentFromLastWeek": "{{percent}}% from last week",
"dashboardSubtitle": "Welcome back! Here's what's happening with your galleries.",
"eventsExpiringSoon": "Events Expiring Soon",
"noEventsExpiring": "No events expiring in the next 7 days",
"daysLeft": "{{count}} day left",
"daysLeft_plural": "{{count}} days left",
"viewAllExpiringEvents": "View all {{count}} expiring events",
"noRecentActivity": "No recent activity",
"viewAllActivity": "View all activity",
"quickActions": "Quick Actions",
"viewArchives": "View Archives",
"analytics": "Analytics"
},
"errors": {
"notFound": "Not Found",
"galleryNotFound": "Gallery Not Found",
"galleryNotFoundMessage": "This gallery does not exist or has been removed.",
"unauthorized": "Unauthorized",
"forbidden": "Forbidden",
"serverError": "Server Error",
"somethingWentWrong": "Something went wrong",
"tryAgainLater": "Please try again later",
"refreshPage": "Refresh Page",
"oopsSomethingWentWrong": "Oops! Something went wrong",
"unexpectedError": "We encountered an unexpected error. Don't worry, your data is safe.",
"goToHomepage": "Go to Homepage",
"errorDetails": "Error Details"
},
"legal": {
"impressum": "Legal Notice",
"datenschutz": "Privacy Policy",
"termsOfService": "Terms of Service",
"cookiePolicy": "Cookie Policy"
},
"toast": {
"saveSuccess": "Changes saved successfully",
"saveError": "Failed to save changes",
"deleteSuccess": "Deleted successfully",
"deleteError": "Failed to delete",
"uploadSuccess": "Upload completed successfully",
"uploadError": "Upload failed",
"loginSuccess": "Login successful",
"loginError": "Login failed",
"passwordChanged": "Password changed successfully",
"linkCopied": "Link copied to clipboard",
"eventCreated": "Event created successfully",
"eventUpdated": "Event updated successfully",
"eventArchived": "Event archived successfully",
"settingsSaved": "Settings saved successfully",
"themeUpdated": "Theme updated successfully",
"brandingUpdated": "Branding updated successfully",
"categoryAdded": "Category added successfully",
"categoryDeleted": "Category deleted successfully",
"categoryUpdated": "Category updated successfully",
"emailConfigSaved": "Email configuration saved successfully",
"testEmailSent": "Test email sent successfully",
"pageUpdated": "Page updated successfully",
"archiveRestored": "Archive restored successfully",
"archiveDeleted": "Archive deleted permanently"
}
}
+35
View File
@@ -160,4 +160,39 @@
.smooth-scroll {
scroll-behavior: smooth;
}
/* Custom range slider styles */
.slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
.slider::-webkit-slider-track {
@apply bg-neutral-200 h-2 rounded-lg;
}
.slider::-moz-range-track {
@apply bg-neutral-200 h-2 rounded-lg;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all;
margin-top: -6px;
}
.slider::-moz-range-thumb {
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all border-0;
}
.slider:hover::-webkit-slider-thumb {
@apply bg-primary-700 scale-110;
}
.slider:hover::-moz-range-thumb {
@apply bg-primary-700 scale-110;
}
}
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './i18n/config'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
+153 -43
View File
@@ -1,23 +1,44 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react';
import { format, differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation();
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
// Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Set language from admin settings when on login page
React.useEffect(() => {
if (!isAuthenticated && settingsData?.default_language) {
i18n.changeLanguage(settingsData.default_language);
}
}, [settingsData, isAuthenticated, i18n]);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
@@ -27,7 +48,7 @@ export const GalleryPage: React.FC = () => {
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setLoginError('Please enter a password');
setLoginError(t('auth.pleaseEnterPassword'));
return;
}
@@ -42,7 +63,7 @@ export const GalleryPage: React.FC = () => {
success: true
});
} catch (error: any) {
setLoginError(error.response?.data?.error || 'Invalid password');
setLoginError(error.response?.data?.error || t('auth.invalidPassword'));
// Track failed password entry
analyticsService.trackGalleryEvent('password_entry', {
@@ -57,8 +78,10 @@ export const GalleryPage: React.FC = () => {
// Show loading state
if (isLoadingInfo) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Loading size="lg" text="Loading gallery..." />
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
</div>
);
}
@@ -66,16 +89,50 @@ export const GalleryPage: React.FC = () => {
// Show error state
if (infoError) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Gallery Not Found</h2>
<p className="text-neutral-600">
This gallery does not exist or has been removed.
</p>
</CardContent>
</Card>
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
<p className="text-neutral-600">
{t('errors.galleryNotFoundMessage')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="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>
</div>
</div>
);
}
@@ -83,19 +140,53 @@ export const GalleryPage: React.FC = () => {
// Show expired state
if (galleryInfo?.is_expired) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Gallery Expired</h2>
<p className="text-neutral-600 mb-4">
This gallery expired on {format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy')}.
</p>
<p className="text-sm text-neutral-500">
Please contact the event organizer if you need access to these photos.
</p>
</CardContent>
</Card>
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('gallery.expired')}</h2>
<p className="text-neutral-600 mb-4">
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy') })}
</p>
<p className="text-sm text-neutral-500">
{t('gallery.contactOrganizer')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="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>
</div>
</div>
);
}
@@ -112,9 +203,17 @@ export const GalleryPage: React.FC = () => {
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
<Camera className="w-10 h-10 text-white" />
</div>
{settingsData?.branding_logo_url ? (
<img
src={settingsData.branding_logo_url}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-20 w-auto object-contain mx-auto mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
<Camera className="w-10 h-10 text-white" />
</div>
)}
<h1 className="text-3xl font-bold text-neutral-900 mb-2">
{galleryInfo?.event_name}
</h1>
@@ -131,10 +230,10 @@ export const GalleryPage: React.FC = () => {
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 mr-2 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-amber-800">
Gallery expires in {daysUntilExpiration} {daysUntilExpiration === 1 ? 'day' : 'days'}
{t('gallery.expiresIn', { count: daysUntilExpiration })}
</p>
<p className="text-xs text-amber-700 mt-1">
Download your photos before they're no longer available.
{t('gallery.downloadBefore')}
</p>
</div>
</div>
@@ -144,13 +243,13 @@ export const GalleryPage: React.FC = () => {
{/* Login Card */}
<Card>
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-6">Enter Gallery Password</h2>
<h2 className="text-xl font-semibold mb-6">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label="Password"
placeholder="Enter the gallery password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
@@ -165,22 +264,33 @@ export const GalleryPage: React.FC = () => {
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
View Gallery
{t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-6">
The password was provided by the event organizer.
Contact them if you don't have it.
{t('auth.passwordHint')}
</p>
</CardContent>
</Card>
{/* Event Type Badge */}
{/* Legal Links */}
<div className="text-center mt-6">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
{galleryInfo?.event_type}
</span>
<div className="flex items-center justify-center gap-4">
<a
href="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</a>
<span className="text-xs text-neutral-400">|</span>
<a
href="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</a>
</div>
</div>
</div>
</div>
+28 -26
View File
@@ -14,6 +14,7 @@ import {
Image
} from 'lucide-react';
import { format, differenceInDays, parseISO, formatDistanceToNow } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { Button, Card, Loading } from '../../components/common';
import { useQuery } from '@tanstack/react-query';
@@ -29,6 +30,7 @@ interface StatCard {
}
export const AdminDashboard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
// Fetch dashboard statistics
@@ -54,7 +56,7 @@ export const AdminDashboard: React.FC = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text="Loading dashboard..." />
<Loading size="lg" text={t('admin.loadingDashboard')} />
</div>
);
}
@@ -76,26 +78,26 @@ export const AdminDashboard: React.FC = () => {
// Build statistics cards
const stats: StatCard[] = [
{
title: 'Active Events',
title: t('admin.activeEvents'),
value: dashboardStats?.activeEvents || 0,
icon: Calendar,
color: 'text-green-600',
},
{
title: 'Expiring Soon',
title: t('admin.expiringSoon'),
value: dashboardStats?.expiringEvents || 0,
change: 'Next 7 days',
change: t('admin.next7Days'),
icon: AlertTriangle,
color: 'text-orange-600',
},
{
title: 'Total Photos',
title: t('admin.totalPhotos'),
value: formatNumber(dashboardStats?.totalPhotos || 0),
icon: Image,
color: 'text-blue-600',
},
{
title: 'Storage Used',
title: t('admin.storageUsed'),
value: adminService.formatBytes(dashboardStats?.storageUsed || 0),
icon: HardDrive,
color: 'text-purple-600',
@@ -106,16 +108,16 @@ export const AdminDashboard: React.FC = () => {
if (dashboardStats?.totalViews !== undefined) {
stats.push(
{
title: 'Total Views',
title: t('admin.totalViews'),
value: formatNumber(dashboardStats.totalViews),
change: dashboardStats.viewsTrend > 0 ? `+${dashboardStats.viewsTrend}% from last week` : undefined,
change: dashboardStats.viewsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.viewsTrend}` }) : undefined,
icon: Eye,
color: 'text-indigo-600',
},
{
title: 'Downloads',
title: t('admin.downloads'),
value: formatNumber(dashboardStats.totalDownloads),
change: dashboardStats.downloadsTrend > 0 ? `+${dashboardStats.downloadsTrend}% from last week` : undefined,
change: dashboardStats.downloadsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.downloadsTrend}` }) : undefined,
icon: Download,
color: 'text-pink-600',
}
@@ -127,15 +129,15 @@ export const AdminDashboard: React.FC = () => {
{/* Page Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-neutral-900">Dashboard</h1>
<p className="text-neutral-600 mt-1">Welcome back! Here's what's happening with your galleries.</p>
<h1 className="text-2xl font-bold text-neutral-900">{t('navigation.dashboard')}</h1>
<p className="text-neutral-600 mt-1">{t('admin.dashboardSubtitle')}</p>
</div>
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => navigate('/admin/events/new')}
>
Create Event
{t('events.createEvent')}
</Button>
</div>
@@ -165,12 +167,12 @@ export const AdminDashboard: React.FC = () => {
<div className="lg:col-span-2">
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Events Expiring Soon</h2>
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.eventsExpiringSoon')}</h2>
<AlertTriangle className="w-5 h-5 text-orange-600" />
</div>
{expiringEvents.length === 0 ? (
<p className="text-neutral-600 py-8 text-center">No events expiring in the next 7 days</p>
<p className="text-neutral-600 py-8 text-center">{t('admin.noEventsExpiring')}</p>
) : (
<div className="space-y-3">
{expiringEvents.slice(0, 5).map((event) => {
@@ -190,10 +192,10 @@ export const AdminDashboard: React.FC = () => {
</div>
<div className="text-right">
<p className="text-sm font-medium text-orange-600">
{daysLeft} {daysLeft === 1 ? 'day' : 'days'} left
{t('admin.daysLeft', { count: daysLeft })}
</p>
<p className="text-xs text-neutral-500">
Expires {format(parseISO(event.expires_at), 'MMM d')}
{t('gallery.expires')} {format(parseISO(event.expires_at), 'MMM d')}
</p>
</div>
</div>
@@ -207,7 +209,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/events?filter=expiring')}
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
View all {expiringEvents.length} expiring events
{t('admin.viewAllExpiringEvents', { count: expiringEvents.length })}
</button>
)}
</Card>
@@ -216,13 +218,13 @@ export const AdminDashboard: React.FC = () => {
{/* Recent Activity */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Recent Activity</h2>
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.recentActivity')}</h2>
<Clock className="w-5 h-5 text-neutral-500" />
</div>
<div className="space-y-4">
{!recentActivity || recentActivity.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-4">No recent activity</p>
<p className="text-sm text-neutral-500 text-center py-4">{t('admin.noRecentActivity')}</p>
) : (
recentActivity.slice(0, 5).map((activity) => {
// Get color based on activity type
@@ -265,7 +267,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/activity')}
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
View all activity
{t('admin.viewAllActivity')}
</button>
)}
</Card>
@@ -273,7 +275,7 @@ export const AdminDashboard: React.FC = () => {
{/* Quick Actions */}
<Card padding="md" className="mt-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Quick Actions</h2>
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('admin.quickActions')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Button
variant="outline"
@@ -281,7 +283,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/events/new')}
className="justify-center"
>
Create Event
{t('events.createEvent')}
</Button>
<Button
variant="outline"
@@ -289,7 +291,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/archives')}
className="justify-center"
>
View Archives
{t('admin.viewArchives')}
</Button>
<Button
variant="outline"
@@ -297,7 +299,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/analytics')}
className="justify-center"
>
Analytics
{t('admin.analytics')}
</Button>
<Button
variant="outline"
@@ -305,7 +307,7 @@ export const AdminDashboard: React.FC = () => {
onClick={() => navigate('/admin/settings')}
className="justify-center"
>
Settings
{t('navigation.settings')}
</Button>
</div>
</Card>
+225 -10
View File
@@ -1,25 +1,31 @@
import React, { useState, useEffect } from 'react';
import { Save, Eye, Palette } from 'lucide-react';
import React, { useState, useEffect, useRef } from 'react';
import { Save, Eye, Palette, Upload } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
import { ThemeCustomizer } from '../../components/admin/ThemeCustomizer';
import { useTheme, type ThemeConfig, PRESET_THEMES } from '../../contexts/ThemeContext';
import { useQuery, useMutation } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
export const BrandingPage: React.FC = () => {
const { theme, setTheme } = useTheme();
const [brandingSettings, setBrandingSettings] = useState({
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
company_name: '',
company_tagline: '',
footer_text: '© 2024 Your Company. All rights reserved.',
support_email: '',
watermark_enabled: false,
watermark_position: 'bottom-right',
watermark_opacity: 50,
watermark_size: 15,
watermark_logo_url: '',
favicon_url: '',
});
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
const [currentThemeName, setCurrentThemeName] = useState('default');
const [isPreviewMode, setIsPreviewMode] = useState(false);
const faviconInputRef = useRef<HTMLInputElement>(null);
// Fetch current settings
const { data: settings, isLoading } = useQuery({
@@ -68,8 +74,12 @@ export const BrandingPage: React.FC = () => {
if (themeSettings) {
const formatted = settingsService.formatThemeSettings(themeSettings);
if (formatted && Object.keys(formatted).length > 0) {
setCurrentTheme(formatted);
setTheme(formatted);
// Merge logo URL from branding settings if available
const logoUrl = settings?.branding_logo_url || brandingSettings.logo_url;
const themeWithLogo = logoUrl ? { ...formatted, logoUrl } : formatted;
setCurrentTheme(themeWithLogo);
setTheme(themeWithLogo);
// Try to identify which preset this matches
for (const [key, preset] of Object.entries(PRESET_THEMES)) {
@@ -80,7 +90,7 @@ export const BrandingPage: React.FC = () => {
}
}
}
}, [themeSettings, setTheme]);
}, [themeSettings, settings, brandingSettings.logo_url, setTheme]);
const handleBrandingChange = (key: string, value: any) => {
setBrandingSettings(prev => ({ ...prev, [key]: value }));
@@ -88,6 +98,10 @@ export const BrandingPage: React.FC = () => {
const handleThemeChange = (newTheme: ThemeConfig) => {
setCurrentTheme(newTheme);
// Also update logo URL in branding settings if it changed
if (newTheme.logoUrl !== currentTheme.logoUrl) {
setBrandingSettings(prev => ({ ...prev, logo_url: newTheme.logoUrl || '' }));
}
if (isPreviewMode) {
setTheme(newTheme);
}
@@ -105,16 +119,47 @@ export const BrandingPage: React.FC = () => {
}
};
const handleFaviconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const faviconUrl = await settingsService.uploadFavicon(file);
setBrandingSettings(prev => ({ ...prev, favicon_url: faviconUrl }));
toast.success('Favicon uploaded successfully');
} catch (error) {
console.error('Failed to upload favicon:', error);
toast.error('Failed to upload favicon. Please use PNG or ICO format.');
}
}
};
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
try {
const watermarkLogoUrl = await settingsService.uploadWatermarkLogo(file);
setBrandingSettings(prev => ({ ...prev, watermark_logo_url: watermarkLogoUrl }));
toast.success('Watermark logo uploaded successfully');
} catch (error) {
console.error('Failed to upload watermark logo:', error);
toast.error('Failed to upload watermark logo. Please use PNG format with transparency.');
}
}
};
const handleSave = async () => {
try {
// Save branding settings to database
await brandingMutation.mutateAsync(brandingSettings);
// Save theme settings to database
await themeMutation.mutateAsync(currentTheme);
// 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);
// Apply theme globally
setTheme(currentTheme);
setTheme(themeToSave);
} catch (error) {
console.error('Failed to save settings:', error);
}
@@ -223,6 +268,175 @@ export const BrandingPage: React.FC = () => {
</div>
</label>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Favicon
</label>
<div className="space-y-2">
{brandingSettings.favicon_url && (
<div className="flex items-center gap-2">
<img
src={brandingSettings.favicon_url.startsWith('http') ? brandingSettings.favicon_url : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.favicon_url}`}
alt="Current favicon"
className="w-8 h-8"
/>
<span className="text-sm text-neutral-600">Current favicon</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleBrandingChange('favicon_url', '')}
>
Remove
</Button>
</div>
)}
<div>
<input
ref={faviconInputRef}
type="file"
accept="image/png,image/x-icon"
onChange={handleFaviconUpload}
className="hidden"
/>
<Button
variant="secondary"
size="sm"
onClick={() => faviconInputRef.current?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
Upload Favicon
</Button>
<p className="text-xs text-neutral-600 mt-1">PNG or ICO format, recommended size: 32x32px</p>
</div>
</div>
</div>
</div>
{/* Watermark Settings */}
{brandingSettings.watermark_enabled && (
<div className="mt-6 space-y-6 border-t border-neutral-200 pt-6">
<h3 className="text-md font-semibold text-neutral-900">Watermark Settings</h3>
{/* Watermark Logo Upload */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Watermark Logo
</label>
<div className="space-y-2">
{brandingSettings.watermark_logo_url && (
<div className="flex items-center gap-2">
<img
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.watermark_logo_url}`}
alt="Current watermark"
className="h-16 w-auto object-contain bg-neutral-100 p-2 rounded"
/>
<span className="text-sm text-neutral-600">Current watermark</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleBrandingChange('watermark_logo_url', '')}
>
Remove
</Button>
</div>
)}
<div>
<input
type="file"
accept="image/png"
onChange={handleWatermarkLogoUpload}
className="hidden"
id="watermark-upload"
/>
<label htmlFor="watermark-upload">
<Button
variant="secondary"
size="sm"
onClick={() => document.getElementById('watermark-upload')?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
Upload Watermark Logo
</Button>
</label>
<p className="text-xs text-neutral-600 mt-1">PNG format with transparency recommended</p>
</div>
</div>
</div>
{/* Position Selector */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Watermark Position
</label>
<div className="grid grid-cols-3 gap-2 max-w-xs">
{[
{ value: 'top-left', label: 'Top Left' },
{ value: 'top-right', label: 'Top Right' },
{ value: 'center', label: 'Center' },
{ value: 'bottom-left', label: 'Bottom Left' },
{ value: 'bottom-right', label: 'Bottom Right' }
].map((position) => (
<button
key={position.value}
type="button"
onClick={() => handleBrandingChange('watermark_position', position.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
brandingSettings.watermark_position === position.value
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
}`}
>
{position.label}
</button>
))}
</div>
</div>
{/* Opacity Slider */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Watermark Opacity: {brandingSettings.watermark_opacity || 50}%
</label>
<input
type="range"
min="10"
max="100"
step="10"
value={brandingSettings.watermark_opacity || 50}
onChange={(e) => handleBrandingChange('watermark_opacity', parseInt(e.target.value))}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
/>
<div className="flex justify-between text-xs text-neutral-500 mt-1">
<span>10%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
{/* Size Slider */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Watermark Size: {brandingSettings.watermark_size || 15}%
</label>
<input
type="range"
min="5"
max="30"
step="5"
value={brandingSettings.watermark_size || 15}
onChange={(e) => handleBrandingChange('watermark_size', parseInt(e.target.value))}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
/>
<div className="flex justify-between text-xs text-neutral-500 mt-1">
<span>5%</span>
<span>15%</span>
<span>30%</span>
</div>
</div>
</div>
)}
</Card>
{/* Theme Customization */}
@@ -247,6 +461,7 @@ export const BrandingPage: React.FC = () => {
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
/>
</div>
+212
View File
@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Save, FileText, Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input, Loading } from '../../components/common';
import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
export const CMSPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
// Fetch CMS pages
const { data: pages, isLoading } = useQuery({
queryKey: ['cms-pages'],
queryFn: cmsService.getPages,
});
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
cmsService.updatePage(slug, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
toast.success('Page updated successfully');
},
onError: () => {
toast.error('Failed to update page');
},
});
// Load page data when selection changes
React.useEffect(() => {
if (pages) {
const page = pages.find(p => p.slug === selectedPage);
if (page) {
setEditForm(page);
}
}
}, [pages, selectedPage]);
const handleSave = () => {
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
};
const handleContentChange = (content: string) => {
const field = editingLang === 'de' ? 'content_de' : 'content_en';
setEditForm(prev => ({ ...prev, [field]: content }));
};
const handleTitleChange = (title: string) => {
const field = editingLang === 'de' ? 'title_de' : 'title_en';
setEditForm(prev => ({ ...prev, [field]: title }));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text="Loading pages..." />
</div>
);
}
const currentPage = pages?.find(p => p.slug === selectedPage);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">CMS Pages</h1>
<p className="text-neutral-600 mt-1">Manage legal and informational pages</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Page Selection */}
<div className="lg:col-span-1">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Pages</h2>
<div className="space-y-2">
{pages?.map((page) => (
<button
key={page.slug}
onClick={() => setSelectedPage(page.slug)}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
selectedPage === page.slug
? 'bg-primary-100 text-primary-700 border border-primary-300'
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
}`}
>
<FileText className="w-5 h-5" />
<div>
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
<p className="text-sm text-neutral-500">/{page.slug}</p>
</div>
</button>
))}
</div>
</Card>
<Card padding="md" className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Preview Links</h3>
<div className="space-y-2 text-sm">
<a
href={`${window.location.origin}/${selectedPage}?lang=en`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
English Version
</a>
<a
href={`${window.location.origin}/${selectedPage}?lang=de`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
German Version
</a>
</div>
</Card>
</div>
{/* Editor */}
<div className="lg:col-span-3">
<Card padding="md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900">
Edit {t(`legal.${selectedPage}`)}
</h2>
{/* Language Tabs */}
<div className="flex gap-2">
<button
onClick={() => setEditingLang('en')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇬🇧 English
</button>
<button
onClick={() => setEditingLang('de')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇩🇪 Deutsch
</button>
</div>
</div>
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Page Title ({editingLang === 'en' ? 'English' : 'German'})
</label>
<Input
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Enter page title..."
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Page Content ({editingLang === 'en' ? 'English' : 'German'})
</label>
<CMSEditor
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
onChange={handleContentChange}
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="primary"
onClick={handleSave}
isLoading={updateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
Save Changes
</Button>
</div>
{currentPage?.updated_at && (
<p className="text-xs text-neutral-500 mt-4">
Last updated: {new Date(currentPage.updated_at).toLocaleString()}
</p>
)}
</Card>
</div>
</div>
</div>
);
};
@@ -20,7 +20,7 @@ import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload } from '../../components/admin';
import { PhotoUpload, EventCategoryManager } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
@@ -408,10 +408,14 @@ export const EventDetailsPage: React.FC = () => {
<strong>Storage Location:</strong> /storage/events/active/{event.slug}/
</p>
<p className="text-xs text-blue-600 mt-1">
Photos can also be added by placing them in the 'individual' or 'collages' folders.
Photos are organized by categories you define.
</p>
</div>
</div>
<div className="mt-6 pt-4 border-t border-neutral-200">
<EventCategoryManager eventId={parseInt(id!)} />
</div>
</Card>
{/* Actions */}
+67 -5
View File
@@ -4,17 +4,21 @@ import {
Database,
Globe,
Key,
AlertCircle
AlertCircle,
Image
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, Loading } from '../../components/common';
import { CategoryManager } from '../../components/admin/CategoryManager';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general');
const queryClient = useQueryClient();
const { t } = useTranslation();
// Fetch settings
const { data: settings, isLoading } = useQuery({
@@ -38,7 +42,8 @@ export const SettingsPage: React.FC = () => {
enable_watermark: false,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false
maintenance_mode: false,
default_language: 'en'
});
// Security settings state
@@ -64,7 +69,8 @@ export const SettingsPage: React.FC = () => {
enable_watermark: settings.general_enable_watermark || false,
enable_analytics: settings.general_enable_analytics || true,
enable_registration: settings.general_enable_registration || false,
maintenance_mode: settings.general_maintenance_mode || false
maintenance_mode: settings.general_maintenance_mode || false,
default_language: settings.general_default_language || 'en'
});
// Extract security settings
@@ -166,6 +172,16 @@ export const SettingsPage: React.FC = () => {
>
Security
</button>
<button
onClick={() => setActiveTab('categories')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'categories'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
Categories
</button>
</nav>
</div>
@@ -280,6 +296,29 @@ export const SettingsPage: React.FC = () => {
<span className="ml-2 text-sm text-neutral-700">Enable maintenance mode</span>
</label>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.language')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.language')}
</label>
<select
value={generalSettings.default_language}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
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="en">English</option>
<option value="de">Deutsch</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
Sets the default language for all gallery pages and login screens
</p>
</div>
</div>
<div className="mt-6">
<Button
@@ -288,7 +327,7 @@ export const SettingsPage: React.FC = () => {
isLoading={saveGeneralMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
Save General Settings
{t('settings.general.saveSettings')}
</Button>
</div>
</Card>
@@ -510,6 +549,29 @@ export const SettingsPage: React.FC = () => {
</Card>
</div>
)}
{/* Categories Tab */}
{activeTab === 'categories' && (
<div className="space-y-6">
<Card padding="md">
<CategoryManager />
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<Image className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-blue-900">About Photo Categories</h3>
<p className="text-sm text-blue-700 mt-1">
Global categories are available for all events. You can also create event-specific
categories when editing individual events. Categories help organize photos and
allow guests to filter photos by type in the gallery view.
</p>
</div>
</div>
</Card>
</div>
)}
</div>
);
};
+2 -1
View File
@@ -7,4 +7,5 @@ export { EmailConfigPage } from './EmailConfigPage';
export { ArchivesPage } from './ArchivesPage';
export { AnalyticsPage } from './AnalyticsPage';
export { BrandingPage } from './BrandingPage';
export { SettingsPage } from './SettingsPage';
export { SettingsPage } from './SettingsPage';
export { CMSPage } from './CMSPage';
+143
View File
@@ -0,0 +1,143 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
import { GalleryLayout, PhotoFilterBar } from '../../components/gallery';
import { Card } from '../../components/common';
import { Camera } from 'lucide-react';
// Mock photo data for preview
const generateMockPhotos = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
filename: `photo-${i + 1}.jpg`,
url: '',
thumbnail_url: '',
type: i % 3 === 0 ? 'collage' : 'individual',
category_id: (i % 4) + 1,
category_name: ['Ceremony', 'Reception', 'Portraits', 'Party'][i % 4],
category_slug: ['ceremony', 'reception', 'portraits', 'party'][i % 4],
size: Math.floor(Math.random() * 5000000) + 1000000,
uploaded_at: new Date().toISOString(),
}));
};
const mockCategories = [
{ id: 1, name: 'Ceremony', slug: 'ceremony', is_global: true },
{ id: 2, name: 'Reception', slug: 'reception', is_global: true },
{ id: 3, name: 'Portraits', slug: 'portraits', is_global: true },
{ id: 4, name: 'Party', slug: 'party', is_global: true },
];
export const PreviewPage: React.FC = () => {
const { setTheme } = useTheme();
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
const mockEvent = {
event_name: 'Preview Wedding Gallery',
event_date: new Date().toISOString(),
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
};
useEffect(() => {
// Listen for theme preview messages from the branding page
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'THEME_PREVIEW') {
setTheme(event.data.theme);
setBrandingSettings(event.data.branding);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setTheme]);
// Filter photos
const filteredPhotos = useMemo(() => {
let photos = [...mockPhotos];
// Apply category filter
if (selectedCategoryId) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
if (searchTerm) {
photos = photos.filter(photo =>
photo.filename.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Apply sorting
photos.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.filename.localeCompare(b.filename);
case 'size':
return b.size - a.size;
case 'date':
default:
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
}
});
return photos;
}, [mockPhotos, selectedCategoryId, searchTerm, sortBy]);
// Custom photo renderer for preview
const PreviewPhotoGrid: React.FC<{ photos: any[] }> = ({ photos }) => (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo) => (
<Card key={photo.id} className="overflow-hidden group cursor-pointer">
<div className="aspect-[4/3] bg-gradient-to-br from-neutral-200 to-neutral-300 relative">
<div className="absolute inset-0 flex items-center justify-center">
<Camera className="w-12 h-12 text-neutral-400" />
</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>
{photo.category_name && (
<p className="text-white/70 text-xs">{photo.category_name}</p>
)}
</div>
</div>
</Card>
))}
</div>
);
return (
<GalleryLayout
event={mockEvent}
brandingSettings={brandingSettings}
showLogout={false}
showDownloadAll={false}
>
<div className="mt-8">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold text-neutral-900">Theme Preview</h2>
<p className="text-neutral-600">This is how your galleries will look with the current theme settings</p>
</div>
{/* Filters */}
<PhotoFilterBar
categories={mockCategories}
photos={mockPhotos}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
/>
{/* Photo Grid */}
<div className="mt-6">
<PreviewPhotoGrid photos={filteredPhotos} />
</div>
</div>
</GalleryLayout>
);
};
+138
View File
@@ -0,0 +1,138 @@
import React, { useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Home } from 'lucide-react';
import { Loading, Card } from '../../components/common';
import { cmsService } from '../../services/cms.service';
import { api } from '../../config/api';
export const LegalPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { i18n } = useTranslation();
const navigate = useNavigate();
// Extract page slug from pathname if not in params (for static routes like /impressum)
const pathname = window.location.pathname;
const pageSlug = slug || pathname.split('/').pop() || '';
// Fetch settings to get default language
const { data: settingsData } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Use admin settings language
const lang = settingsData?.default_language || 'en';
// Fetch page content
const { data: page, isLoading, error } = useQuery({
queryKey: ['legal-page', pageSlug, lang],
queryFn: () => cmsService.getPublicPage(pageSlug, lang),
enabled: !!pageSlug && pageSlug !== '' && !!settingsData,
});
// Set i18n language when settings are loaded
useEffect(() => {
if (settingsData?.default_language) {
i18n.changeLanguage(settingsData.default_language);
}
}, [settingsData, i18n]);
// Update page title
useEffect(() => {
if (page?.title) {
document.title = `${page.title} - Wedding Photo Sharing`;
}
}, [page?.title]);
if (isLoading) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Loading size="lg" text="Loading..." />
</div>
);
}
if (error || !page) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<div className="text-center py-12 px-6">
<h2 className="text-xl font-semibold mb-2">Page Not Found</h2>
<p className="text-neutral-600 mb-6">
The page you're looking for doesn't exist.
</p>
<Link
to="/"
className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Home className="w-4 h-4" />
Go to Homepage
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-neutral-50">
{/* Header */}
<header className="bg-white border-b border-neutral-200">
<div className="container py-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-neutral-600 hover:text-neutral-900 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{i18n.language === 'de' ? 'Zurück' : 'Back'}
</button>
</div>
</header>
{/* Content */}
<main className="container py-12">
<div className="max-w-4xl mx-auto">
<Card padding="lg">
<h1 className="text-3xl font-bold text-neutral-900 mb-8">{page.title}</h1>
<div
className="prose prose-neutral max-w-none"
dangerouslySetInnerHTML={{ __html: page.content }}
/>
</Card>
</div>
</main>
{/* Footer */}
<footer className="mt-auto py-8 border-t border-neutral-200">
<div className="container text-center">
<div className="flex justify-center gap-4 text-sm">
<Link
to="/impressum"
className="text-neutral-600 hover:text-neutral-900"
>
{lang === 'de' ? 'Impressum' : 'Legal Notice'}
</Link>
<span className="text-neutral-400"></span>
<Link
to="/datenschutz"
className="text-neutral-600 hover:text-neutral-900"
>
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
</Link>
</div>
<p className="text-sm text-neutral-500 mt-4">
© 2024 Wedding Photo Sharing. All rights reserved.
</p>
</div>
</footer>
</div>
);
};
@@ -0,0 +1,48 @@
import { api } from '../config/api';
export interface PhotoCategory {
id: number;
name: string;
slug: string;
is_global: boolean;
event_id: number | null;
created_at: string;
}
export interface CreateCategoryData {
name: string;
slug?: string;
is_global?: boolean;
event_id?: number;
}
export const categoriesService = {
// Get all global categories
async getGlobalCategories(): Promise<PhotoCategory[]> {
const response = await api.get<PhotoCategory[]>('/api/admin/categories/global');
return response.data;
},
// Get categories for a specific event (global + event-specific)
async getEventCategories(eventId: number): Promise<PhotoCategory[]> {
const response = await api.get<PhotoCategory[]>(`/api/admin/categories/event/${eventId}`);
return response.data;
},
// Create a new category
async createCategory(data: CreateCategoryData): Promise<PhotoCategory> {
const response = await api.post<PhotoCategory>('/api/admin/categories', data);
return response.data;
},
// Update a category
async updateCategory(id: number, name: string): Promise<PhotoCategory> {
const response = await api.put<PhotoCategory>(`/api/admin/categories/${id}`, { name });
return response.data;
},
// Delete a category
async deleteCategory(id: number): Promise<void> {
await api.delete(`/api/admin/categories/${id}`);
}
};
+39
View File
@@ -0,0 +1,39 @@
import { api } from '../config/api';
export interface CMSPage {
id: number;
slug: string;
title_en: string;
title_de: string;
content_en: string;
content_de: string;
updated_at: string;
}
export const cmsService = {
// Get all CMS pages
async getPages(): Promise<CMSPage[]> {
const response = await api.get<CMSPage[]>('/api/admin/cms/pages');
return response.data;
},
// Get a single CMS page
async getPage(slug: string): Promise<CMSPage> {
const response = await api.get<CMSPage>(`/api/admin/cms/pages/${slug}`);
return response.data;
},
// Update a CMS page
async updatePage(slug: string, data: Partial<CMSPage>): Promise<CMSPage> {
const response = await api.put<CMSPage>(`/api/admin/cms/pages/${slug}`, data);
return response.data;
},
// Get public CMS page (no auth required)
async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> {
const response = await api.get<{ title: string; content: string }>(`/api/public/pages/${slug}`, {
params: { lang }
});
return response.data;
}
};
+2 -1
View File
@@ -5,4 +5,5 @@ export { adminService } from './admin.service';
export { analyticsService } from './analytics.service';
export { archiveService } from './archive.service';
export { emailService } from './email.service';
export { settingsService } from './settings.service';
export { settingsService } from './settings.service';
export { cmsService } from './cms.service';
+50 -4
View File
@@ -6,7 +6,12 @@ export interface BrandingSettings {
support_email: string;
footer_text: string;
watermark_enabled: boolean;
watermark_position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'center';
watermark_opacity?: number;
watermark_size?: number;
watermark_logo_url?: string;
logo_url?: string;
favicon_url?: string;
}
export interface ThemeSettings {
@@ -50,11 +55,11 @@ export const settingsService = {
},
// Upload logo
async uploadLogo(file: File): Promise<{ logo_url: string }> {
async uploadLogo(file: File): Promise<string> {
const formData = new FormData();
formData.append('logo', file);
const response = await api.post<{ message: string; logo_url: string }>(
const response = await api.post<{ logoUrl: string }>(
'/api/admin/settings/logo',
formData,
{
@@ -64,7 +69,43 @@ export const settingsService = {
}
);
return { logo_url: response.data.logo_url };
return response.data.logoUrl;
},
// Upload favicon
async uploadFavicon(file: File): Promise<string> {
const formData = new FormData();
formData.append('favicon', file);
const response = await api.post<{ faviconUrl: string }>(
'/api/admin/settings/favicon',
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
return response.data.faviconUrl;
},
// Upload watermark logo
async uploadWatermarkLogo(file: File): Promise<string> {
const formData = new FormData();
formData.append('watermarkLogo', file);
const response = await api.post<{ watermarkLogoUrl: string }>(
'/api/admin/settings/branding/watermark-logo',
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
return response.data.watermarkLogoUrl;
},
// Update theme settings
@@ -91,7 +132,12 @@ export const settingsService = {
support_email: rawSettings.branding_support_email || '',
footer_text: rawSettings.branding_footer_text || '',
watermark_enabled: rawSettings.branding_watermark_enabled || false,
logo_url: rawSettings.branding_logo_url || undefined
watermark_position: rawSettings.branding_watermark_position || 'bottom-right',
watermark_opacity: rawSettings.branding_watermark_opacity || 50,
watermark_size: rawSettings.branding_watermark_size || 15,
watermark_logo_url: rawSettings.branding_watermark_logo_url || undefined,
logo_url: rawSettings.branding_logo_url || undefined,
favicon_url: rawSettings.branding_favicon_url || undefined
};
},
+30
View File
@@ -0,0 +1,30 @@
import { toast as toastify } from 'react-toastify';
import i18n from '../i18n/config';
export const toast = {
success: (messageKey: string, interpolations?: Record<string, any>) => {
const message = i18n.t(messageKey, interpolations);
toastify.success(message);
},
error: (messageKey: string, interpolations?: Record<string, any>) => {
const message = i18n.t(messageKey, interpolations);
toastify.error(message);
},
info: (messageKey: string, interpolations?: Record<string, any>) => {
const message = i18n.t(messageKey, interpolations);
toastify.info(message);
},
warning: (messageKey: string, interpolations?: Record<string, any>) => {
const message = i18n.t(messageKey, interpolations);
toastify.warning(message);
},
// For direct messages (not translation keys)
successDirect: (message: string) => toastify.success(message),
errorDirect: (message: string) => toastify.error(message),
infoDirect: (message: string) => toastify.info(message),
warningDirect: (message: string) => toastify.warning(message),
};
+11
View File
@@ -42,10 +42,20 @@ export interface Photo {
url: string;
thumbnail_url?: string;
type: 'collage' | 'individual';
category_id?: number;
category_name?: string;
category_slug?: string;
size: number;
uploaded_at: string;
}
export interface PhotoCategory {
id: number;
name: string;
slug: string;
is_global: boolean;
}
export interface GalleryData {
event: {
id: number;
@@ -56,6 +66,7 @@ export interface GalleryData {
color_theme?: string;
expires_at: string;
};
categories?: PhotoCategory[];
photos: Photo[];
}