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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-08 09:49:45 +02:00
parent cfa0b0da69
commit 2012b0bab9
91 changed files with 6183 additions and 577 deletions
+11 -5
View File
@@ -2,10 +2,12 @@ import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react';
import { format } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useAdminAuth } from '../../contexts';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -14,6 +16,7 @@ interface AdminHeaderProps {
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate();
const { user, logout } = useAdminAuth();
const { t } = useTranslation();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
@@ -66,6 +69,9 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Language Selector */}
<LanguageSelector />
{/* Notifications */}
<div className="relative" ref={notificationRef}>
<button
@@ -82,7 +88,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-neutral-200 py-2">
<div className="px-4 py-2 border-b border-neutral-100">
<h3 className="text-sm font-semibold text-neutral-900">Notifications</h3>
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.map((notification) => (
@@ -99,7 +105,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</div>
<div className="px-4 py-2 border-t border-neutral-100">
<button className="text-sm text-primary-600 hover:text-primary-700">
View all notifications
{t('admin.viewAllNotifications')}
</button>
</div>
</div>
@@ -136,7 +142,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<Settings className="w-4 h-4" />
Settings
{t('navigation.settings')}
</button>
<button
onClick={() => {
@@ -146,14 +152,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<Lock className="w-4 h-4" />
Change Password
{t('admin.changePassword')}
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<LogOut className="w-4 h-4" />
Sign Out
{t('common.logout')}
</button>
</div>
)}
+19 -14
View File
@@ -9,9 +9,11 @@ import {
Settings,
Camera,
X,
Palette
Palette,
FileText
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
interface AdminSidebarProps {
@@ -20,23 +22,25 @@ interface AdminSidebarProps {
}
interface NavItem {
name: string;
nameKey: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ name: 'Events', href: '/admin/events', icon: Calendar },
{ name: 'Archives', href: '/admin/archives', icon: Archive },
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
{ name: 'Email Settings', href: '/admin/email', icon: Mail },
{ name: 'Branding', href: '/admin/branding', icon: Palette },
{ name: 'Settings', href: '/admin/settings', icon: Settings },
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 },
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText },
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
const location = useLocation();
const { t } = useTranslation();
return (
<div
@@ -49,7 +53,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200">
<div className="flex items-center">
<Camera className="w-8 h-8 text-primary-600" />
<span className="ml-2 text-xl font-bold text-neutral-900">Photo Admin</span>
<span className="ml-2 text-xl font-bold text-neutral-900">{t('admin.title')}</span>
</div>
<button
onClick={onClose}
@@ -67,7 +71,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
return (
<NavLink
key={item.name}
key={item.nameKey}
to={item.href}
onClick={() => onClose()}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
@@ -79,7 +83,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
<item.icon className={`w-5 h-5 mr-3 ${
isActive ? 'text-primary-600' : 'text-neutral-400'
}`} />
{item.name}
{t(item.nameKey)}
</NavLink>
);
})}
@@ -93,6 +97,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
};
const StorageInfo: React.FC = () => {
const { t } = useTranslation();
const { data: storageInfo } = useQuery({
queryKey: ['storage-info'],
queryFn: () => settingsService.getStorageInfo(),
@@ -115,7 +120,7 @@ const StorageInfo: React.FC = () => {
<div className="p-4 border-t border-neutral-200">
<div className="bg-neutral-100 rounded-lg p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-700">Storage Used</span>
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
<span className="font-medium text-neutral-900">
{settingsService.formatBytes(storageInfo.total_used)}
</span>
@@ -127,7 +132,7 @@ const StorageInfo: React.FC = () => {
/>
</div>
<p className="text-xs text-neutral-600 mt-1">
{usagePercent}% of {settingsService.formatBytes(storageInfo.storage_limit)}
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
</p>
</div>
</div>
+191
View File
@@ -0,0 +1,191 @@
import React, { useState } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
Heading1,
Heading2,
Undo,
Redo
} from 'lucide-react';
import { Button } from '../common';
interface CMSEditorProps {
content: string;
onChange: (content: string) => void;
}
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
// Update editor content when prop changes
React.useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
const addLink = () => {
if (linkUrl) {
editor.chain().focus().setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkDialog(false);
}
};
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
}> = ({ onClick, active, children, title }) => (
<button
onClick={onClick}
className={`p-2 rounded hover:bg-neutral-100 ${
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
}`}
title={title}
type="button"
>
{children}
</button>
);
return (
<div className="border border-neutral-300 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 p-2 border-b border-neutral-200 bg-neutral-50 flex-wrap">
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
<Heading1 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold"
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic"
>
<Italic className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet List"
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title="Add Link"
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
title="Undo"
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
title="Redo"
>
<Redo className="w-4 h-4" />
</MenuButton>
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..."
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>Add Link</Button>
<Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false);
setLinkUrl('');
}}>
Cancel
</Button>
</div>
)}
{/* Editor */}
<EditorContent
editor={editor}
className="min-h-[300px] p-4 prose prose-neutral max-w-none focus:outline-none"
/>
</div>
);
};
CMSEditor.displayName = 'CMSEditor';
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
export const CategoryManager: React.FC = () => {
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [newCategoryName, setNewCategoryName] = useState('');
const [editingName, setEditingName] = useState('');
// Fetch global categories
const { data: categories = [], isLoading } = useQuery({
queryKey: ['global-categories'],
queryFn: categoriesService.getGlobalCategories,
});
// Create category mutation
const createMutation = useMutation({
mutationFn: (name: string) =>
categoriesService.createCategory({ name, is_global: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category created successfully');
setNewCategoryName('');
setIsAdding(false);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to create category');
},
});
// Update category mutation
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) =>
categoriesService.updateCategory(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category updated successfully');
setEditingId(null);
setEditingName('');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to update category');
},
});
// Delete category mutation
const deleteMutation = useMutation({
mutationFn: categoriesService.deleteCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category deleted successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to delete category');
},
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
}
};
const handleUpdate = (id: number) => {
if (editingName.trim()) {
updateMutation.mutate({ id, name: editingName.trim() });
}
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
deleteMutation.mutate(category.id);
}
};
const startEdit = (category: PhotoCategory) => {
setEditingId(category.id);
setEditingName(category.name);
};
const cancelEdit = () => {
setEditingId(null);
setEditingName('');
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-neutral-900">Photo Categories</h3>
{!isAdding && (
<Button
variant="primary"
size="sm"
onClick={() => setIsAdding(true)}
leftIcon={<Plus className="w-4 h-4" />}
>
Add Category
</Button>
)}
</div>
{/* Add new category form */}
{isAdding && (
<div className="flex gap-2 p-3 bg-neutral-50 rounded-lg">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Category name"
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Create'
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
setIsAdding(false);
setNewCategoryName('');
}}
>
Cancel
</Button>
</div>
)}
{/* Categories list */}
<div className="space-y-2">
{categories.length === 0 ? (
<p className="text-neutral-500 text-center py-8">
No categories yet. Create your first category to organize photos.
</p>
) : (
categories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between p-3 bg-white rounded-lg border border-neutral-200 hover:border-neutral-300 transition-colors"
>
{editingId === category.id ? (
<div className="flex gap-2 flex-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') handleUpdate(category.id);
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 px-3 py-1 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={() => handleUpdate(category.id)}
disabled={!editingName.trim() || updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Save'
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={cancelEdit}
>
Cancel
</Button>
</div>
) : (
<>
<div>
<p className="font-medium text-neutral-900">{category.name}</p>
<p className="text-sm text-neutral-500">/{category.slug}</p>
</div>
<div className="flex gap-1">
<button
onClick={() => startEdit(category)}
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
title="Edit category"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(category)}
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete category"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</>
)}
</div>
))
)}
</div>
</div>
);
};
CategoryManager.displayName = 'CategoryManager';
@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, X, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
interface EventCategoryManagerProps {
eventId: number;
}
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
// Fetch categories for this event
const { data: categories = [], isLoading } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
});
// Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global);
// Create category mutation
const createMutation = useMutation({
mutationFn: (name: string) =>
categoriesService.createCategory({
name,
is_global: false,
event_id: eventId
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
toast.success('Category created successfully');
setNewCategoryName('');
setIsAdding(false);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to create category');
},
});
// Delete category mutation
const deleteMutation = useMutation({
mutationFn: categoriesService.deleteCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
toast.success('Category deleted successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to delete category');
},
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
}
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
deleteMutation.mutate(category.id);
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-primary-600" />
</div>
);
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium text-neutral-700">Event-Specific Categories</h3>
{!isAdding && (
<Button
variant="outline"
size="sm"
onClick={() => setIsAdding(true)}
leftIcon={<Plus className="w-3 h-3" />}
>
Add
</Button>
)}
</div>
{/* Add new category form */}
{isAdding && (
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Category name"
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
'Add'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsAdding(false);
setNewCategoryName('');
}}
>
Cancel
</Button>
</div>
)}
{/* Event categories list */}
{eventCategories.length === 0 ? (
<p className="text-sm text-neutral-500 italic">
No event-specific categories. Global categories are available by default.
</p>
) : (
<div className="space-y-1">
{eventCategories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
>
<span className="text-sm text-neutral-700">{category.name}</span>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
title="Delete category"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</div>
))}
</div>
)}
{/* Show available global categories */}
<div className="mt-4 pt-3 border-t border-neutral-200">
<p className="text-xs font-medium text-neutral-500 mb-2">Global Categories (always available):</p>
<div className="flex flex-wrap gap-1">
{categories
.filter(cat => cat.is_global)
.map(cat => (
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded">
{cat.name}
</span>
))}
</div>
</div>
</div>
);
};
EventCategoryManager.displayName = 'EventCategoryManager';
+35 -27
View File
@@ -4,6 +4,8 @@ import { Button } from '../common';
import { clsx } from 'clsx';
import { api } from '../../config/api';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service';
interface PhotoUploadProps {
eventId: number;
@@ -14,8 +16,14 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [photoType, setPhotoType] = useState<'individual' | 'collage'>('individual');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch categories for this event
const { data: categories = [] } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
});
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
@@ -36,10 +44,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setUploadProgress(0);
const formData = new FormData();
selectedFiles.forEach(file => {
selectedFiles.forEach((file, index) => {
console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`);
formData.append('photos', file);
});
formData.append('type', photoType);
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
// Debug: Log FormData contents
console.log('FormData entries:');
for (let pair of formData.entries()) {
console.log(pair[0], pair[1]);
}
try {
const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, {
@@ -86,33 +104,23 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
return (
<div className="space-y-4">
{/* Photo Type Selection */}
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Photo Type
Photo Category
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
value="individual"
checked={photoType === 'individual'}
onChange={(e) => setPhotoType(e.target.value as 'individual')}
className="mr-2"
/>
<span>Individual Photos</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="collage"
checked={photoType === 'collage'}
onChange={(e) => setPhotoType(e.target.value as 'collage')}
className="mr-2"
/>
<span>Collages</span>
</label>
</div>
<select
value={selectedCategoryId || ''}
onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">No category</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name} {!category.is_global && '(Event specific)'}
</option>
))}
</select>
</div>
{/* File Input Area */}
@@ -1,31 +1,39 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Palette, RotateCcw, Check, Upload } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { PRESET_THEMES, type ThemeConfig } from '../../contexts/ThemeContext';
import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify';
interface ThemeCustomizerProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
presetName?: string;
onPresetChange?: (presetName: string) => void;
isPreviewMode?: boolean;
}
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
value,
onChange,
presetName = 'default',
onPresetChange
onPresetChange,
isPreviewMode = false
}) => {
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
const logoInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLocalTheme(value);
setCustomCss(value.customCss || '');
}, [value]);
useEffect(() => {
setSelectedPreset(presetName);
}, [presetName]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
setLocalTheme(updated);
@@ -55,18 +63,31 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
};
const handleReset = () => {
handlePresetSelect('default');
const defaultPreset = PRESET_THEMES['default'];
if (defaultPreset) {
setSelectedPreset('default');
setLocalTheme(defaultPreset.config);
setCustomCss('');
onChange(defaultPreset.config);
if (onPresetChange) {
onPresetChange('default');
}
}
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
handleChange('logoUrl', dataUrl);
};
reader.readAsDataURL(file);
try {
// Upload to server
const logoUrl = await settingsService.uploadLogo(file);
// Update theme with the server URL
handleChange('logoUrl', logoUrl);
toast.success('Logo uploaded successfully');
} catch (error) {
console.error('Failed to upload logo:', error);
toast.error('Failed to upload logo');
}
}
};
@@ -252,33 +273,33 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
<div className="flex items-center gap-4">
{localTheme.logoUrl && (
<img
src={localTheme.logoUrl}
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${localTheme.logoUrl}`}
alt="Custom logo"
className="h-16 w-auto object-contain"
/>
)}
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
>
Upload Logo
</Button>
</label>
<input
ref={logoInputRef}
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => logoInputRef.current?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
Upload Logo
</Button>
{localTheme.logoUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleChange('logoUrl', undefined)}
onClick={() => handleChange('logoUrl', '')}
>
Remove
</Button>
@@ -303,34 +324,21 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPreviewMode}
onChange={(e) => setIsPreviewMode(e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-neutral-700">Live Preview</span>
</label>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
Reset to Default
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
>
Apply Theme
</Button>
</div>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
Reset to Default
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
>
Apply Theme
</Button>
</div>
</div>
);
+4 -1
View File
@@ -4,4 +4,7 @@ export { AdminHeader } from './AdminHeader';
export { ThemeCustomizer } from './ThemeCustomizer';
export { PasswordChangeModal } from './PasswordChangeModal';
export { AdminAuthWrapper } from './AdminAuthWrapper';
export { PhotoUpload } from './PhotoUpload';
export { PhotoUpload } from './PhotoUpload';
export { CategoryManager } from './CategoryManager';
export { EventCategoryManager } from './EventCategoryManager';
export { CMSEditor } from './CMSEditor';