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:
+13
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -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,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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user