Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped
Original: feat: enhance security logging and ensure rate limit blocks are properly tracked - Add comprehensive logging for rate limit blocks with full request details - IP address (with proper proxy detection), user agent, headers, timestamps - Rate limit info (current count, limit, remaining, reset time) - Separate tracking for auth vs general endpoints - Enhance authentication failure logging - JWT validation failures with detailed error info - Admin auth attempts without token - Failed token validation with user context - All events include IP, path, method, user agent - Improve Winston logger configuration for production - Add automatic log rotation (10MB errors, 50MB combined) - Create separate security.log for auth/rate limit events - Ensure logs directory exists automatically - Add structured JSON format for log aggregation - Support container logging with LOG_TO_CONSOLE env var - Create comprehensive documentation - Security logging guide with examples - Monitoring recommendations - Configuration reference - Add test script to verify logging functionality All rate limit settings remain configurable via admin panel: - Window duration, max requests, auth limits - Skip authenticated requests option - Public endpoints only option 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { api } from '../config/api';
|
||||
|
||||
interface GlobalThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
|
||||
const { setTheme } = useTheme();
|
||||
const themeAppliedRef = useRef(false);
|
||||
|
||||
// Fetch public settings including theme config
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['global-theme-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Apply global theme when settings are loaded (but not on gallery pages)
|
||||
useEffect(() => {
|
||||
// Skip if we're on a gallery page - gallery pages handle their own themes
|
||||
const isGalleryPage = window.location.pathname.includes('/gallery/');
|
||||
|
||||
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
||||
themeAppliedRef.current = true;
|
||||
setTheme(settingsData.theme_config);
|
||||
}
|
||||
}, [settingsData, setTheme]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../config/api';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
|
||||
interface BrandingSettings {
|
||||
branding_company_name?: string;
|
||||
branding_company_tagline?: string;
|
||||
branding_support_email?: string;
|
||||
branding_footer_text?: string;
|
||||
branding_favicon_url?: string;
|
||||
branding_logo_url?: string;
|
||||
default_language?: string;
|
||||
}
|
||||
|
||||
export const MaintenanceMode: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settings } = useQuery<BrandingSettings>({
|
||||
queryKey: ['public-settings-maintenance'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Return empty object if settings can't be fetched
|
||||
return {};
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
retry: false, // Don't retry on failure
|
||||
});
|
||||
|
||||
// Set language based on system settings
|
||||
useEffect(() => {
|
||||
if (settings?.default_language && settings.default_language !== i18n.language) {
|
||||
i18n.changeLanguage(settings.default_language);
|
||||
}
|
||||
}, [settings?.default_language, i18n]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex flex-col">
|
||||
{/* Header with branding - Always show, with PicPeak logo as fallback */}
|
||||
<div className="bg-white border-b border-neutral-200 py-4">
|
||||
<div className="container">
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={settings?.branding_logo_url ?
|
||||
(settings.branding_logo_url.startsWith('http')
|
||||
? settings.branding_logo_url
|
||||
: buildResourceUrl(settings.branding_logo_url))
|
||||
: '/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={settings?.branding_company_name || 'PicPeak'}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
{settings?.branding_company_name && settings.branding_company_name !== 'PicPeak' && (
|
||||
<div className="ml-4 text-center">
|
||||
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
|
||||
{settings.branding_company_tagline && (
|
||||
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-amber-100 rounded-full mb-6">
|
||||
<AlertTriangle className="w-10 h-10 text-amber-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-neutral-900 mb-4">
|
||||
{t('maintenance.title')}
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-neutral-600 mb-8">
|
||||
{t('maintenance.message')}
|
||||
</p>
|
||||
|
||||
{settings?.branding_support_email && (
|
||||
<p className="text-sm text-neutral-500 mt-8">
|
||||
{t('maintenance.urgentMatters')}{' '}
|
||||
<a
|
||||
href={`mailto:${settings.branding_support_email}`}
|
||||
className="text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{settings.branding_support_email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{settings?.branding_footer_text && (
|
||||
<footer className="py-4 border-t border-neutral-200">
|
||||
<div className="container text-center">
|
||||
<p className="text-sm text-neutral-500">
|
||||
{settings.branding_footer_text}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MaintenanceMode } from './MaintenanceMode';
|
||||
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
|
||||
import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api';
|
||||
|
||||
interface MaintenanceWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
|
||||
|
||||
// Check if current route is admin route
|
||||
const isAdminRoute = location.pathname.startsWith('/admin');
|
||||
const hasAdminAuth = !!getAuthToken(true);
|
||||
|
||||
// Register the maintenance mode callback
|
||||
useEffect(() => {
|
||||
setMaintenanceModeCallback((enabled: boolean) => {
|
||||
setMaintenanceMode(enabled);
|
||||
});
|
||||
}, [setMaintenanceMode]);
|
||||
|
||||
// Check maintenance mode on mount and when location changes
|
||||
useQuery({
|
||||
queryKey: ['maintenance-check', location.pathname],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Make a lightweight request to check maintenance status
|
||||
await api.get('/public/settings');
|
||||
// If successful, maintenance mode is off
|
||||
setMaintenanceMode(false);
|
||||
return { maintenance: false };
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 503) {
|
||||
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
|
||||
if (!isAdminRoute || !hasAdminAuth) {
|
||||
setMaintenanceMode(true);
|
||||
return { maintenance: true };
|
||||
}
|
||||
}
|
||||
return { maintenance: false };
|
||||
}
|
||||
},
|
||||
staleTime: 30000, // Check every 30 seconds
|
||||
retry: false, // Don't retry on failure
|
||||
enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance
|
||||
});
|
||||
|
||||
// Show maintenance page if in maintenance mode and not on admin route with auth
|
||||
if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) {
|
||||
return <MaintenanceMode />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { AdminAuthProvider } from '../../contexts';
|
||||
|
||||
export const AdminAuthWrapper: React.FC = () => {
|
||||
return (
|
||||
<AdminAuthProvider>
|
||||
<Outlet />
|
||||
</AdminAuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
AdminAuthWrapper.displayName = 'AdminAuthWrapper';
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
interface AdminAuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = ({
|
||||
src,
|
||||
fallback,
|
||||
alt,
|
||||
...props
|
||||
}) => {
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Make authenticated request to get the image
|
||||
const response = await api.get(src, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
if (!cancelled) {
|
||||
// Create object URL from blob
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(imageUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Image loading failed - handled by error state
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (src) {
|
||||
loadImage();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-200 animate-pulse" />
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return fallback ? (
|
||||
<>{fallback}</>
|
||||
) : (
|
||||
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
|
||||
<span className="text-xs">Failed to load</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={imageSrc || ''} alt={alt} {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
||||
import { PasswordChangeModal } from './PasswordChangeModal';
|
||||
import { LanguageSelector } from '../common';
|
||||
import { notificationsService } from '../../services/notifications.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface AdminHeaderProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAdminAuth();
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { formatTimeAgo } = useLocalizedTimeAgo();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOnClickOutside(userMenuRef, () => setShowUserMenu(false));
|
||||
useOnClickOutside(notificationRef, () => setShowNotifications(false));
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
// Fetch notifications
|
||||
const { data: notificationsData } = useQuery({
|
||||
queryKey: ['notifications', showNotifications],
|
||||
queryFn: () => notificationsService.getNotifications(showNotifications, 20),
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
});
|
||||
|
||||
// Mark all as read mutation
|
||||
const markAllAsReadMutation = useMutation({
|
||||
mutationFn: notificationsService.markAllAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
toast.success(t('admin.notificationToasts.markedAllRead'));
|
||||
},
|
||||
});
|
||||
|
||||
// Clear old notifications mutation
|
||||
const clearOldMutation = useMutation({
|
||||
mutationFn: notificationsService.clearOldNotifications,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount }));
|
||||
},
|
||||
});
|
||||
|
||||
const notifications = notificationsData?.notifications || [];
|
||||
const unreadCount = notificationsData?.unreadCount || 0;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Left side - Menu button and Date */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden text-neutral-500 hover:text-neutral-700"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Date display */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-base text-neutral-700">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Logo and PicPeak text */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-3">
|
||||
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-10 w-auto object-contain" />
|
||||
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||
</div>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language Selector */}
|
||||
<LanguageSelector />
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative" ref={notificationRef}>
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
{showNotifications && (
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-neutral-200">
|
||||
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
title={t('admin.markAllRead')}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
{t('admin.markAllRead')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => clearOldMutation.mutate()}
|
||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||
title={t('admin.clearOld')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t('admin.clearOld')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
{t('admin.noNotificationsMessage')}
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => {
|
||||
const style = notificationsService.getNotificationStyle(notification.type);
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`px-4 py-3 hover:bg-neutral-50 cursor-pointer border-l-4 ${
|
||||
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 ${style.color}`}>
|
||||
<Bell className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-neutral-900">
|
||||
{notificationsService.formatNotificationMessage(notification)}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{notifications.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-neutral-100 text-center">
|
||||
<button
|
||||
onClick={() => setShowNotifications(false)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{t('admin.close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
|
||||
<p className="text-xs text-neutral-500">{user?.email}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* User dropdown */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-neutral-200 py-1">
|
||||
<div className="px-4 py-2 border-b border-neutral-100 sm:hidden">
|
||||
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
|
||||
<p className="text-xs text-neutral-500">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
navigate('/admin/settings');
|
||||
}}
|
||||
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" />
|
||||
{t('navigation.settings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowPasswordModal(true);
|
||||
}}
|
||||
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" />
|
||||
{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" />
|
||||
{t('common.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Change Modal */}
|
||||
<PasswordChangeModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={() => setShowPasswordModal(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, Navigate } from 'react-router-dom';
|
||||
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
import { useSessionTimeout } from '../../hooks/useSessionTimeout';
|
||||
import { AdminSidebar } from './AdminSidebar';
|
||||
import { AdminHeader } from './AdminHeader';
|
||||
import { MaintenanceBanner } from './MaintenanceBanner';
|
||||
|
||||
export const AdminLayout: React.FC = () => {
|
||||
const { isAuthenticated, isLoading } = useAdminAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
// Handle session timeout
|
||||
useSessionTimeout();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-neutral-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-neutral-50 flex overflow-hidden">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||||
{/* Header */}
|
||||
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
|
||||
|
||||
{/* Maintenance mode banner */}
|
||||
<MaintenanceBanner />
|
||||
|
||||
{/* Page content */}
|
||||
<main id="main-content" className="flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AdminLayout.displayName = 'AdminLayout';
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Download, Trash2, Eye, Package } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
|
||||
interface AdminPhotoGridProps {
|
||||
photos: AdminPhoto[];
|
||||
eventId: number;
|
||||
onPhotoClick: (photo: AdminPhoto, index: number) => void;
|
||||
onPhotosDeleted: () => void;
|
||||
}
|
||||
|
||||
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
photos,
|
||||
eventId,
|
||||
onPhotoClick,
|
||||
onPhotosDeleted
|
||||
}) => {
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deletingPhotoId, setDeletingPhotoId] = useState<number | null>(null);
|
||||
|
||||
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedPhotos);
|
||||
if (newSelected.has(photoId)) {
|
||||
newSelected.delete(photoId);
|
||||
} else {
|
||||
newSelected.add(photoId);
|
||||
}
|
||||
setSelectedPhotos(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedPhotos.size === photos.length) {
|
||||
setSelectedPhotos(new Set());
|
||||
} else {
|
||||
setSelectedPhotos(new Set(photos.map(p => p.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${photo.filename}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingPhotoId(photo.id);
|
||||
try {
|
||||
await photosService.deletePhoto(eventId, photo.id);
|
||||
toast.success('Photo deleted successfully');
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete photo');
|
||||
} finally {
|
||||
setDeletingPhotoId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
const count = selectedPhotos.size;
|
||||
if (!confirm(`Are you sure you want to delete ${count} photo${count > 1 ? 's' : ''}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await photosService.deletePhotos(eventId, Array.from(selectedPhotos));
|
||||
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete photos');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (photo: AdminPhoto, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
|
||||
toast.success('Download started');
|
||||
} catch (error) {
|
||||
toast.error('Failed to download photo');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
if (isSelectionMode) {
|
||||
setSelectedPhotos(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Action Bar */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={isSelectionMode ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={toggleSelectionMode}
|
||||
leftIcon={<Package className="w-4 h-4" />}
|
||||
>
|
||||
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
|
||||
</Button>
|
||||
|
||||
{isSelectionMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
|
||||
{selectedPhotos.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-neutral-600">
|
||||
{selectedPhotos.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={isDeleting}
|
||||
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Selected
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-neutral-600">
|
||||
{photos.length} photo{photos.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{photos.map((photo, index) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 ${
|
||||
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
||||
}`}
|
||||
onClick={() => isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)}
|
||||
>
|
||||
{/* Selection Checkbox */}
|
||||
{isSelectionMode && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
|
||||
selectedPhotos.has(photo.id)
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'bg-white/80 border-neutral-300'
|
||||
}`}>
|
||||
{selectedPhotos.has(photo.id) && (
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-square">
|
||||
{photo.thumbnail_url ? (
|
||||
<AdminAuthenticatedImage
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
fallback={
|
||||
<div className="w-full h-full flex items-center justify-center text-neutral-400">
|
||||
<Eye className="w-8 h-8" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-neutral-400">
|
||||
<Eye className="w-8 h-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlay with actions */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
<p className="text-white text-xs font-medium truncate mb-1">
|
||||
{photo.filename}
|
||||
</p>
|
||||
<p className="text-white/80 text-xs mb-2">
|
||||
{photosService.formatBytes(photo.size)}
|
||||
</p>
|
||||
|
||||
{!isSelectionMode && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(e) => handleDownload(photo, e)}
|
||||
className="p-1 text-white hover:bg-white/20 rounded"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteSingle(photo, e)}
|
||||
className="p-1 text-white hover:bg-white/20 rounded"
|
||||
disabled={deletingPhotoId === photo.id}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Badge */}
|
||||
{photo.category_name && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded">
|
||||
{photo.category_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{photos.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-neutral-500">No photos uploaded yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
|
||||
interface AdminPhotoViewerProps {
|
||||
photos: AdminPhoto[];
|
||||
initialIndex: number;
|
||||
eventId: number;
|
||||
onClose: () => void;
|
||||
onPhotoDeleted: () => void;
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
}
|
||||
|
||||
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
photos,
|
||||
initialIndex,
|
||||
eventId,
|
||||
onClose,
|
||||
onPhotoDeleted,
|
||||
categories
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Are you sure you want to delete "${currentPhoto.filename}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await photosService.deletePhoto(eventId, currentPhoto.id);
|
||||
toast.success('Photo deleted successfully');
|
||||
|
||||
// Close viewer if this was the last photo
|
||||
if (photos.length === 1) {
|
||||
onClose();
|
||||
} else {
|
||||
// Move to next photo if available, otherwise previous
|
||||
if (currentIndex === photos.length - 1) {
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
onPhotoDeleted();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete photo');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
await photosService.downloadPhoto(eventId, currentPhoto.id, currentPhoto.filename);
|
||||
toast.success('Download started');
|
||||
} catch (error) {
|
||||
toast.error('Failed to download photo');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = async (categoryId: number | null) => {
|
||||
try {
|
||||
await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId);
|
||||
toast.success('Category updated');
|
||||
setShowCategoryMenu(false);
|
||||
// Trigger refresh to update the photo data
|
||||
onPhotoDeleted(); // This will refresh the photos list
|
||||
} catch (error) {
|
||||
toast.error('Failed to update category');
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
goToPrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
goToNext();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [currentIndex]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Navigation */}
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center min-h-0">
|
||||
<AdminAuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
fallback={
|
||||
<div className="flex items-center justify-center text-neutral-400">
|
||||
<div className="text-center">
|
||||
<Eye className="w-12 h-12 mx-auto mb-2" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
|
||||
<h3 className="text-white font-medium text-lg mb-4">{currentPhoto.filename}</h3>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
className="flex-1"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-neutral-400 text-sm flex items-center gap-1">
|
||||
<Tag className="w-4 h-4" />
|
||||
Category
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowCategoryMenu(!showCategoryMenu)}
|
||||
className="text-xs text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-white">
|
||||
{currentPhoto.category_name || 'Uncategorized'}
|
||||
</p>
|
||||
|
||||
{showCategoryMenu && (
|
||||
<div className="mt-2 bg-neutral-800 rounded-lg p-2">
|
||||
<button
|
||||
onClick={() => handleCategoryChange(null)}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
|
||||
>
|
||||
Uncategorized
|
||||
</button>
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<span className="text-neutral-400 flex items-center gap-1 mb-1">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
File Size
|
||||
</span>
|
||||
<p className="text-white">{photosService.formatBytes(currentPhoto.size)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-neutral-400 flex items-center gap-1 mb-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Uploaded
|
||||
</span>
|
||||
<p className="text-white">
|
||||
{format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentPhoto.view_count !== undefined && (
|
||||
<div>
|
||||
<span className="text-neutral-400 flex items-center gap-1 mb-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
Views
|
||||
</span>
|
||||
<p className="text-white">{currentPhoto.view_count}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhoto.download_count !== undefined && (
|
||||
<div>
|
||||
<span className="text-neutral-400 flex items-center gap-1 mb-1">
|
||||
<MousePointer className="w-4 h-4" />
|
||||
Downloads
|
||||
</span>
|
||||
<p className="text-white">{currentPhoto.download_count}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation info */}
|
||||
<div className="mt-6 pt-6 border-t border-neutral-700">
|
||||
<p className="text-neutral-400 text-sm text-center">
|
||||
{currentIndex + 1} of {photos.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
Mail,
|
||||
Archive,
|
||||
BarChart3,
|
||||
Settings,
|
||||
X,
|
||||
Palette,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { VersionInfo } from './VersionInfo';
|
||||
|
||||
interface AdminSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
nameKey: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ 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
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-neutral-200 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col h-screen lg:h-full">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden text-neutral-400 hover:text-neutral-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href ||
|
||||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.nameKey}
|
||||
to={item.href}
|
||||
onClick={() => onClose()}
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`w-5 h-5 mr-3 ${
|
||||
isActive ? 'text-primary-600' : 'text-neutral-400'
|
||||
}`} />
|
||||
{t(item.nameKey)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom section - sticky to bottom */}
|
||||
<div className="flex-shrink-0">
|
||||
{/* Version Info */}
|
||||
<VersionInfo />
|
||||
|
||||
{/* Storage Info */}
|
||||
<StorageInfo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StorageInfo: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: storageInfo } = useQuery({
|
||||
queryKey: ['storage-info'],
|
||||
queryFn: () => settingsService.getStorageInfo(),
|
||||
refetchInterval: 60000 // Refresh every minute
|
||||
});
|
||||
|
||||
if (!storageInfo) {
|
||||
return (
|
||||
<div className="p-4 border-t border-neutral-200">
|
||||
<div className="bg-neutral-100 rounded-lg p-3">
|
||||
<div className="h-12 animate-pulse bg-neutral-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const usagePercent = Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100);
|
||||
|
||||
return (
|
||||
<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">{t('admin.storageUsed')}</span>
|
||||
<span className="font-medium text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.total_used)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { Archive, AlertTriangle, X } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
import type { Event } from '../../types';
|
||||
|
||||
interface BulkArchiveModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
selectedEvents: Event[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
selectedEvents,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">Confirm Bulk Archive</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-neutral-700">
|
||||
<p className="mb-2">
|
||||
You are about to archive <strong>{selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}</strong>.
|
||||
This action will:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-neutral-600">
|
||||
<li>Create a ZIP archive of all photos for each event</li>
|
||||
<li>Make the galleries inaccessible to guests</li>
|
||||
<li>Remove the events from active listings</li>
|
||||
<li>Free up storage space by compressing photos</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-neutral-200 rounded-lg max-h-48 overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">Events to be archived:</h3>
|
||||
<ul className="space-y-1">
|
||||
{selectedEvents.map((event) => (
|
||||
<li key={event.id} className="text-sm text-neutral-600">
|
||||
• {event.event_name} ({event.event_type})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
isLoading={isLoading}
|
||||
leftIcon={<Archive className="w-4 h-4" />}
|
||||
>
|
||||
Archive {selectedEvents.length} Event{selectedEvents.length > 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BulkArchiveModal.displayName = 'BulkArchiveModal';
|
||||
@@ -0,0 +1,571 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import CharacterCount from '@tiptap/extension-character-count';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import { lowlight } from 'lowlight';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link as LinkIcon,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
Quote,
|
||||
Code,
|
||||
Code2,
|
||||
Minus,
|
||||
Undo,
|
||||
Redo,
|
||||
RemoveFormatting,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Eye,
|
||||
Edit3,
|
||||
Columns,
|
||||
Maximize2,
|
||||
HelpCircle,
|
||||
Save
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import DOMPurify from 'dompurify';
|
||||
import '../../styles/prose-overrides.css';
|
||||
|
||||
interface CMSEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
onSave?: () => void;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = 'edit' | 'preview' | 'split';
|
||||
|
||||
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('edit');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
hardBreak: false, // We'll use the separate HardBreak extension
|
||||
codeBlock: false, // We'll use CodeBlockLowlight instead
|
||||
}),
|
||||
HardBreak.configure({
|
||||
keepMarks: true,
|
||||
HTMLAttributes: {
|
||||
class: 'hard-break',
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
alignments: ['left', 'center', 'right', 'justify'],
|
||||
defaultAlignment: 'left',
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
HTMLAttributes: {
|
||||
class: 'hljs',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Start typing your content here...',
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: null,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
updateCounts(editor);
|
||||
},
|
||||
onCreate: ({ editor }) => {
|
||||
updateCounts(editor);
|
||||
},
|
||||
});
|
||||
|
||||
const updateCounts = useCallback((editor: any) => {
|
||||
const text = editor.state.doc.textContent;
|
||||
setCharCount(editor.storage.characterCount.characters());
|
||||
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
disabled?: boolean;
|
||||
}> = ({ onClick, active, children, title, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`p-2 rounded hover:bg-neutral-100 transition-colors ${
|
||||
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
const getPreviewContent = () => {
|
||||
return DOMPurify.sanitize(editor?.getHTML() || '', {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
|
||||
'code', 'pre', 'hr', 'div', 'span'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
KEEP_CONTENT: true,
|
||||
ADD_TAGS: ['br'], // Explicitly allow br tags
|
||||
ADD_ATTR: ['style'], // Allow style for text alignment
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}`}>
|
||||
<div className="border border-neutral-300 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
{/* Top Toolbar */}
|
||||
<div className="border-b border-neutral-200 bg-neutral-50">
|
||||
{/* View Mode Controls */}
|
||||
<div className="flex items-center justify-between p-2 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('edit')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'edit'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 inline-block mr-1" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'preview'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-4 h-4 inline-block mr-1" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('split')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'split'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Columns className="w-4 h-4 inline-block mr-1" />
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onSave && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
isLoading={isSaving}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowHelp(true)}
|
||||
title="Help & Keyboard Shortcuts"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||
active={isFullscreen}
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formatting Toolbar */}
|
||||
{viewMode !== 'preview' && (
|
||||
<div className="flex items-center gap-1 p-2 flex-wrap">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1 (Ctrl+Alt+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 (Ctrl+Alt+2)"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3 (Ctrl+Alt+3)"
|
||||
>
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
|
||||
active={editor.isActive('heading', { level: 4 })}
|
||||
title="Heading 4 (Ctrl+Alt+4)"
|
||||
>
|
||||
<Heading4 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
|
||||
active={editor.isActive('heading', { level: 5 })}
|
||||
title="Heading 5 (Ctrl+Alt+5)"
|
||||
>
|
||||
<Heading5 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
|
||||
active={editor.isActive('heading', { level: 6 })}
|
||||
title="Heading 6 (Ctrl+Alt+6)"
|
||||
>
|
||||
<Heading6 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 (Ctrl+B)"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
title="Inline Code (Ctrl+E)"
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
active={editor.isActive('codeBlock')}
|
||||
title="Code Block (Ctrl+Alt+C)"
|
||||
>
|
||||
<Code2 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 (Ctrl+Shift+8)"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Numbered List (Ctrl+Shift+9)"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title="Blockquote (Ctrl+Shift+B)"
|
||||
>
|
||||
<Quote 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 (Ctrl+K)"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
active={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
active={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
active={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||
active={editor.isActive({ textAlign: 'justify' })}
|
||||
title="Justify"
|
||||
>
|
||||
<AlignJustify className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title="Clear Formatting"
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
)}
|
||||
</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 Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Editor */}
|
||||
{viewMode !== 'preview' && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200' : 'w-full'} overflow-auto`}>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="min-h-[400px] p-4 prose prose-neutral max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{viewMode !== 'edit' && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 p-4`}>
|
||||
<div
|
||||
className="prose prose-neutral max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 border-t border-neutral-200 text-sm text-neutral-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{wordCount} words</span>
|
||||
<span>{charCount} characters</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
Press Shift+Enter for line break, Enter for new paragraph
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Modal */}
|
||||
{showHelp && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Text Formatting</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+B</kbd> - Bold</div>
|
||||
<div><kbd>Ctrl+I</kbd> - Italic</div>
|
||||
<div><kbd>Ctrl+E</kbd> - Inline code</div>
|
||||
<div><kbd>Ctrl+K</kbd> - Add link</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Headings</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Alt+1</kbd> - Heading 1</div>
|
||||
<div><kbd>Ctrl+Alt+2</kbd> - Heading 2</div>
|
||||
<div><kbd>Ctrl+Alt+3</kbd> - Heading 3</div>
|
||||
<div><kbd>Ctrl+Alt+4</kbd> - Heading 4</div>
|
||||
<div><kbd>Ctrl+Alt+5</kbd> - Heading 5</div>
|
||||
<div><kbd>Ctrl+Alt+6</kbd> - Heading 6</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Lists & Blocks</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Shift+8</kbd> - Bullet list</div>
|
||||
<div><kbd>Ctrl+Shift+9</kbd> - Numbered list</div>
|
||||
<div><kbd>Ctrl+Shift+B</kbd> - Blockquote</div>
|
||||
<div><kbd>Ctrl+Alt+C</kbd> - Code block</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Text Alignment</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>Click alignment buttons in toolbar</div>
|
||||
<div>Works on paragraphs and headings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Line Breaks</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div><kbd>Enter</kbd> - New paragraph</div>
|
||||
<div><kbd>Shift+Enter</kbd> - Line break (preserves formatting)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Navigation</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Z</kbd> - Undo</div>
|
||||
<div><kbd>Ctrl+Y</kbd> - Redo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={() => setShowHelp(false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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,99 @@
|
||||
import React from 'react';
|
||||
import { X, Mail, FileText } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
|
||||
interface EmailPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subject: string;
|
||||
htmlContent: string;
|
||||
textContent?: string;
|
||||
}
|
||||
|
||||
export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
subject,
|
||||
htmlContent,
|
||||
textContent
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = React.useState<'html' | 'text'>('html');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-6 h-6 text-primary-600" />
|
||||
<h2 className="text-xl font-semibold text-neutral-900">Email Preview</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
|
||||
<p className="text-sm font-medium text-neutral-600">Subject:</p>
|
||||
<p className="text-lg font-semibold text-neutral-900 mt-1">{subject}</p>
|
||||
</div>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="px-6 py-3 border-b border-neutral-200">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'html' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('html')}
|
||||
leftIcon={<Mail className="w-4 h-4" />}
|
||||
>
|
||||
HTML View
|
||||
</Button>
|
||||
{textContent && (
|
||||
<Button
|
||||
variant={viewMode === 'text' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('text')}
|
||||
leftIcon={<FileText className="w-4 h-4" />}
|
||||
>
|
||||
Text View
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{viewMode === 'html' ? (
|
||||
<div className="bg-white border border-neutral-200 rounded-lg shadow-sm">
|
||||
<iframe
|
||||
srcDoc={htmlContent}
|
||||
className="w-full h-[600px] border-0"
|
||||
title="Email Preview"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-6">
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700">
|
||||
{textContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
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';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EventCategoryManagerProps {
|
||||
eventId: number;
|
||||
}
|
||||
|
||||
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
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(t('categories.categoryCreatedSuccess'));
|
||||
setNewCategoryName('');
|
||||
setIsAdding(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('categories.failedToCreateCategory'));
|
||||
},
|
||||
});
|
||||
|
||||
// Delete category mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: categoriesService.deleteCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
toast.success(t('categories.categoryDeletedSuccess'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (category: PhotoCategory) => {
|
||||
if (window.confirm(t('categories.deleteConfirm', { name: 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">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdding(true)}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
{t('common.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={t('categories.categoryName')}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!newCategoryName.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
t('common.add')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewCategoryName('');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</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={t('categories.deleteCategoryTitle')}
|
||||
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">{t('categories.globalCategoriesAlwaysAvailable')}</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';
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||
|
||||
interface GalleryPreviewProps {
|
||||
theme: ThemeConfig;
|
||||
layoutType?: GalleryLayoutType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 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(),
|
||||
}));
|
||||
};
|
||||
|
||||
// Preview photo component
|
||||
const PreviewPhoto: React.FC<{
|
||||
photo: any;
|
||||
className?: string;
|
||||
aspectRatio?: string;
|
||||
}> = ({
|
||||
photo,
|
||||
className = '',
|
||||
aspectRatio = 'aspect-square'
|
||||
}) => (
|
||||
<div className={`relative overflow-hidden rounded-lg bg-gradient-to-br from-neutral-200 to-neutral-300 ${aspectRatio} ${className}`}>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Camera className="w-8 h-8 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-[10px]">{photo.category_name}</p>
|
||||
)}
|
||||
</div>
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute top-1 right-1">
|
||||
<span className="px-1.5 py-0.5 bg-black/60 text-white text-[10px] rounded">
|
||||
Collage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
theme,
|
||||
layoutType,
|
||||
className = ''
|
||||
}) => {
|
||||
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
|
||||
|
||||
// Use the provided layoutType or fallback to theme's gallery layout
|
||||
const activeLayout = layoutType || theme.galleryLayout || 'grid';
|
||||
|
||||
const renderLayout = () => {
|
||||
const spacing = theme.gallerySettings?.spacing || 'normal';
|
||||
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
|
||||
|
||||
switch (activeLayout) {
|
||||
case 'grid': {
|
||||
return (
|
||||
<div className={`grid grid-cols-3 md:grid-cols-4 ${gapClass}`}>
|
||||
{mockPhotos.slice(0, 8).map((photo) => (
|
||||
<PreviewPhoto key={photo.id} photo={photo} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'masonry':
|
||||
return (
|
||||
<div className={`columns-3 md:columns-4 ${gapClass}`}>
|
||||
{mockPhotos.slice(0, 10).map((photo, idx) => (
|
||||
<div key={photo.id} className={`break-inside-avoid mb-${spacing === 'tight' ? '1' : spacing === 'relaxed' ? '4' : '2'}`}>
|
||||
<PreviewPhoto
|
||||
photo={photo}
|
||||
aspectRatio={idx % 3 === 0 ? 'aspect-[4/5]' : idx % 3 === 1 ? 'aspect-[4/3]' : 'aspect-square'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'carousel':
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<PreviewPhoto photo={mockPhotos[0]} className="w-full max-w-md mx-auto" aspectRatio="aspect-[4/3]" />
|
||||
</div>
|
||||
<div className="flex justify-center gap-1 mt-3">
|
||||
{[0, 1, 2, 3].map((idx) => (
|
||||
<div key={idx} className={`w-2 h-2 rounded-full ${idx === 0 ? 'bg-primary-600' : 'bg-neutral-300'}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'timeline':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{['Today', 'Yesterday'].map((date, dateIdx) => (
|
||||
<div key={date}>
|
||||
<h4 className="text-sm font-medium text-neutral-700 mb-2">{date}</h4>
|
||||
<div className={`grid grid-cols-3 ${gapClass}`}>
|
||||
{mockPhotos.slice(dateIdx * 3, (dateIdx * 3) + 3).map((photo) => (
|
||||
<PreviewPhoto key={photo.id} photo={photo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'hero':
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PreviewPhoto photo={mockPhotos[0]} aspectRatio="aspect-[16/9]" className="w-full" />
|
||||
<div className={`grid grid-cols-4 ${gapClass}`}>
|
||||
{mockPhotos.slice(1, 5).map((photo) => (
|
||||
<PreviewPhoto key={photo.id} photo={photo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'mosaic':
|
||||
return (
|
||||
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
|
||||
<PreviewPhoto photo={mockPhotos[0]} className="col-span-2 row-span-2" aspectRatio="aspect-auto h-full" />
|
||||
<PreviewPhoto photo={mockPhotos[1]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
|
||||
<PreviewPhoto photo={mockPhotos[2]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
|
||||
<PreviewPhoto photo={mockPhotos[3]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
|
||||
<PreviewPhoto photo={mockPhotos[4]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
|
||||
<PreviewPhoto photo={mockPhotos[5]} className="col-span-2 row-span-1" aspectRatio="aspect-auto h-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm overflow-hidden ${className}`}
|
||||
style={{
|
||||
backgroundColor: theme.backgroundColor || '#ffffff',
|
||||
color: theme.textColor || '#171717',
|
||||
fontFamily: theme.fontFamily || 'Inter, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* Preview Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b"
|
||||
style={{
|
||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-medium">
|
||||
Gallery Preview - <span className="capitalize">{activeLayout}</span> Layout
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="p-4" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{renderLayout()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GalleryPreview.displayName = 'GalleryPreview';
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
|
||||
interface HeroPhotoSelectorProps {
|
||||
photos: AdminPhoto[];
|
||||
currentHeroPhotoId?: number | null;
|
||||
onSelect: (photoId: number | null) => void;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
photos,
|
||||
currentHeroPhotoId,
|
||||
onSelect,
|
||||
isEditing
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedPhotoId, setSelectedPhotoId] = useState<number | null>(currentHeroPhotoId || null);
|
||||
|
||||
const currentHeroPhoto = photos.find(p => p.id === currentHeroPhotoId);
|
||||
|
||||
const handleSelect = (photoId: number) => {
|
||||
setSelectedPhotoId(photoId);
|
||||
onSelect(photoId);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setSelectedPhotoId(null);
|
||||
onSelect(null);
|
||||
};
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
{currentHeroPhoto ? (
|
||||
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100">
|
||||
<AuthenticatedImage
|
||||
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
|
||||
alt={currentHeroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">{t('events.noHeroPhotoSelected')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 mb-2">
|
||||
{t('events.heroPhotoHelp')}
|
||||
</p>
|
||||
|
||||
{currentHeroPhoto ? (
|
||||
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100 mb-2">
|
||||
<AuthenticatedImage
|
||||
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
|
||||
alt={currentHeroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-white/90 hover:bg-white"
|
||||
>
|
||||
{t('common.change')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
leftIcon={<X className="w-4 h-4" />}
|
||||
className="bg-white/90 hover:bg-white"
|
||||
>
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<ImageIcon className="w-4 h-4" />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
{t('events.selectHeroPhoto')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Photo Selection Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('events.selectHeroPhoto')}</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-center text-neutral-500 py-8">
|
||||
{t('events.noPhotosAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => handleSelect(photo.id)}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
|
||||
photo.id === selectedPhotoId
|
||||
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
|
||||
: 'border-transparent hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-square bg-neutral-100">
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{photo.id === selectedPhotoId && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
||||
<p className="text-white text-xs truncate">{photo.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
|
||||
export const MaintenanceBanner: React.FC = () => {
|
||||
const [dismissed, setDismissed] = React.useState(false);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
refetchInterval: 60000 // Check every minute
|
||||
});
|
||||
|
||||
const isMaintenanceMode = settings?.general_maintenance_mode === true ||
|
||||
settings?.general_maintenance_mode === 'true';
|
||||
|
||||
if (!isMaintenanceMode || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border-b border-amber-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
Maintenance mode is currently enabled. Public access to galleries is restricted.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="text-amber-600 hover:text-amber-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { Button, Input, Card } from '../common';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
|
||||
interface PasswordChangeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [showPasswords, setShowPasswords] = useState({
|
||||
current: false,
|
||||
new: false,
|
||||
confirm: false
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: adminService.changePassword,
|
||||
onSuccess: () => {
|
||||
toast.success('Password changed successfully');
|
||||
onClose();
|
||||
// Reset form
|
||||
setFormData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setErrors({});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.data?.error) {
|
||||
toast.error(error.response.data.error);
|
||||
} else {
|
||||
toast.error('Failed to change password');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.currentPassword) {
|
||||
newErrors.currentPassword = 'Current password is required';
|
||||
}
|
||||
|
||||
if (!formData.newPassword) {
|
||||
newErrors.newPassword = 'New password is required';
|
||||
} else if (formData.newPassword.length < 6) {
|
||||
newErrors.newPassword = 'Password must be at least 6 characters';
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Please confirm your new password';
|
||||
} else if (formData.newPassword !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
if (formData.currentPassword === formData.newPassword) {
|
||||
newErrors.newPassword = 'New password must be different from current password';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
changePasswordMutation.mutate({
|
||||
currentPassword: formData.currentPassword,
|
||||
newPassword: formData.newPassword
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
// Clear error when user types
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">Change Password</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Current Password */}
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
Current Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showPasswords.current ? 'text' : 'password'}
|
||||
value={formData.currentPassword}
|
||||
onChange={handleInputChange('currentPassword')}
|
||||
error={errors.currentPassword}
|
||||
placeholder="Enter current password"
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
{showPasswords.current ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
<Eye className="w-4 h-4 text-neutral-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showPasswords.new ? 'text' : 'password'}
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange('newPassword')}
|
||||
error={errors.newPassword}
|
||||
placeholder="Enter new password"
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
{showPasswords.new ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
<Eye className="w-4 h-4 text-neutral-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showPasswords.confirm ? 'text' : 'password'}
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
placeholder="Confirm new password"
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
{showPasswords.confirm ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
<Eye className="w-4 h-4 text-neutral-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium">Password Requirements:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>At least 6 characters long</li>
|
||||
<li>Must be different from current password</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isLoading={changePasswordMutation.isPending}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Key, Copy, CheckCircle, Mail } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button, Card } from '../common';
|
||||
|
||||
interface PasswordResetModalProps {
|
||||
eventName: string;
|
||||
onConfirm: (sendEmail: boolean) => Promise<{ newPassword: string; emailSent: boolean }>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
eventName,
|
||||
onConfirm,
|
||||
onClose
|
||||
}) => {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleReset = async () => {
|
||||
setIsResetting(true);
|
||||
try {
|
||||
const result = await onConfirm(sendEmail);
|
||||
setNewPassword(result.newPassword);
|
||||
toast.success('Password reset successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to reset password');
|
||||
onClose();
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (newPassword) {
|
||||
await navigator.clipboard.writeText(newPassword);
|
||||
setCopied(true);
|
||||
toast.success('Password copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{newPassword ? 'New Password' : 'Reset Gallery Password'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!newPassword ? (
|
||||
<>
|
||||
<p className="text-neutral-600 mb-6">
|
||||
Are you sure you want to reset the password for <strong>{eventName}</strong>?
|
||||
This will generate a new password for gallery access.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendEmail}
|
||||
onChange={(e) => setSendEmail(e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
Send email notification
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Notify the host about the password change
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Note:</strong> The old password will no longer work.
|
||||
Make sure to share the new password with the host.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isResetting}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
isLoading={isResetting}
|
||||
leftIcon={<Key className="w-4 h-4" />}
|
||||
className="flex-1"
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<p className="font-medium text-green-900">Password reset successfully!</p>
|
||||
</div>
|
||||
{sendEmail && (
|
||||
<p className="text-sm text-green-700">
|
||||
An email notification has been sent to the host.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
New Gallery Password
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newPassword}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Important:</strong> Save this password securely. It cannot be recovered once you close this window.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onClose}
|
||||
className="w-full"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
|
||||
import { Input } from '../common';
|
||||
|
||||
interface PhotoFiltersProps {
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
selectedCategory: number | null | undefined;
|
||||
searchTerm: string;
|
||||
sortBy: 'date' | 'name' | 'size';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onCategoryChange: (categoryId: number | null | undefined) => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
onSortChange: (sort: 'date' | 'name' | 'size', order: 'asc' | 'desc') => void;
|
||||
}
|
||||
|
||||
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
categories,
|
||||
selectedCategory,
|
||||
searchTerm,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onCategoryChange,
|
||||
onSearchChange,
|
||||
onSortChange
|
||||
}) => {
|
||||
const handleSortToggle = () => {
|
||||
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-neutral-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by filename..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-neutral-400" />
|
||||
<select
|
||||
value={selectedCategory === null ? '' : selectedCategory || ''}
|
||||
onChange={(e) => onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="0">Uncategorized</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size', sortOrder)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="date">Sort by Date</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="size">Sort by Size</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleSortToggle}
|
||||
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
|
||||
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'}
|
||||
>
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="w-5 h-5 text-neutral-600" />
|
||||
) : (
|
||||
<SortDesc className="w-5 h-5 text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, X, Image, Loader2 } from 'lucide-react';
|
||||
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';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
eventId: number;
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [currentChunk, setCurrentChunk] = useState(0);
|
||||
const [totalChunks, setTotalChunks] = useState(0);
|
||||
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 || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||
);
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > 500) {
|
||||
const allowedNewFiles = 500 - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
|
||||
return;
|
||||
}
|
||||
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
// Validate file count
|
||||
if (selectedFiles.length > 500) {
|
||||
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// For large uploads, chunk the files to prevent memory issues
|
||||
const CHUNK_SIZE = 50; // Upload 50 files at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||
chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
|
||||
setTotalChunks(chunks.length);
|
||||
let totalUploaded = 0;
|
||||
let failedFiles = [];
|
||||
|
||||
try {
|
||||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
||||
setCurrentChunk(chunkIndex + 1);
|
||||
const chunk = chunks[chunkIndex];
|
||||
const formData = new FormData();
|
||||
|
||||
chunk.forEach((file) => {
|
||||
formData.append('photos', file);
|
||||
});
|
||||
|
||||
if (selectedCategoryId) {
|
||||
formData.append('category_id', selectedCategoryId.toString());
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
// Calculate overall progress across all chunks
|
||||
const chunkProgress = progressEvent.loaded / progressEvent.total;
|
||||
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
|
||||
setUploadProgress(Math.round(overallProgress));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
totalUploaded += chunk.length;
|
||||
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
|
||||
} catch (error: any) {
|
||||
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
||||
failedFiles.push(...chunk.map(f => f.name));
|
||||
|
||||
// Continue with next chunk even if one fails
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selected files
|
||||
setSelectedFiles([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
// Show appropriate message
|
||||
if (failedFiles.length === 0) {
|
||||
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
|
||||
} else {
|
||||
toast.warning(
|
||||
t('upload.someFilesFailed') ||
|
||||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
|
||||
);
|
||||
}
|
||||
|
||||
// Call callback
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Upload error:', error);
|
||||
toast.error(error.response?.data?.error || t('toast.uploadError'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
setCurrentChunk(0);
|
||||
setTotalChunks(0);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Category Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('upload.photoCategory')}
|
||||
</label>
|
||||
<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="">{t('upload.noCategory')}</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name} {!category.is_global && t('upload.eventSpecific')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* File Input Area */}
|
||||
<div
|
||||
className={clsx(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||
"hover:border-primary-400 hover:bg-primary-50/50",
|
||||
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30" : "border-neutral-300"
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-12 h-12 mx-auto text-neutral-400 mb-4" />
|
||||
<p className="text-neutral-700 font-medium mb-1">
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirements')}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected Files */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-neutral-700">
|
||||
{t('upload.selectedFiles')} ({selectedFiles.length})
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Image className="w-5 h-5 text-neutral-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
className="p-1 hover:bg-neutral-200 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={selectedFiles.length === 0 || isUploading}
|
||||
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
>
|
||||
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isUploading && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-neutral-600 mb-1">
|
||||
<span>
|
||||
{t('upload.uploading')}
|
||||
{totalChunks > 1 && ` (${t('common.chunk') || 'Chunk'} ${currentChunk}/${totalChunks})`}
|
||||
</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{totalChunks > 1 && (
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('upload.uploadingChunks') || `Uploading ${selectedFiles.length} files in ${totalChunks} batches...`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PhotoUpload.displayName = 'PhotoUpload';
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { PhotoUpload } from './PhotoUpload';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
eventId: number;
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
eventId,
|
||||
onUploadComplete
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleUploadComplete = () => {
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
|
||||
{/* Fixed Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">{t('events.uploadPhotos')}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="!p-1"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<PhotoUpload
|
||||
eventId={eventId}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PhotoUploadModal.displayName = 'PhotoUploadModal';
|
||||
@@ -0,0 +1,342 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Palette, RotateCcw, Check, Upload } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../common';
|
||||
import { GALLERY_THEME_PRESETS, type ThemeConfig } from '../../contexts/ThemeContext';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface ThemeCustomizerProps {
|
||||
value: ThemeConfig;
|
||||
onChange: (theme: ThemeConfig) => void;
|
||||
presetName?: string;
|
||||
onPresetChange?: (presetName: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
presetName = 'default',
|
||||
onPresetChange
|
||||
}) => {
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
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);
|
||||
// Always propagate changes to parent, not just in preview mode
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handlePresetSelect = (presetKey: string) => {
|
||||
const preset = GALLERY_THEME_PRESETS[presetKey];
|
||||
if (preset) {
|
||||
setSelectedPreset(presetKey);
|
||||
setLocalTheme(preset.config);
|
||||
if (onPresetChange) {
|
||||
onPresetChange(presetKey);
|
||||
}
|
||||
// Always propagate preset changes
|
||||
onChange(preset.config);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const themeWithCss = { ...localTheme, customCss };
|
||||
setLocalTheme(themeWithCss);
|
||||
onChange(themeWithCss);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultPreset = GALLERY_THEME_PRESETS['default'];
|
||||
if (defaultPreset) {
|
||||
setSelectedPreset('default');
|
||||
setLocalTheme(defaultPreset.config);
|
||||
setCustomCss('');
|
||||
onChange(defaultPreset.config);
|
||||
if (onPresetChange) {
|
||||
onPresetChange('default');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Preset Themes */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Preset Themes</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handlePresetSelect(key)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
selectedPreset === key
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-sm">{theme.name}</span>
|
||||
{selectedPreset === key && (
|
||||
<Check className="w-4 h-4 text-primary-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.primaryColor }}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.accentColor }}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.backgroundColor }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Color Customization */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Colors</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Primary Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.primaryColor || '#5C8762'}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.primaryColor || '#5C8762'}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
placeholder="#5C8762"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Accent Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.accentColor || '#22c55e'}
|
||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.accentColor || '#22c55e'}
|
||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
||||
placeholder="#22c55e"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Background Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.backgroundColor || '#fafafa'}
|
||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.backgroundColor || '#fafafa'}
|
||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
||||
placeholder="#fafafa"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Text Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.textColor || '#171717'}
|
||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.textColor || '#171717'}
|
||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
||||
placeholder="#171717"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Typography & Style */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Typography & Style</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Font Family
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.fontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => handleChange('fontFamily', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="Inter, sans-serif">Inter (Default)</option>
|
||||
<option value="Georgia, serif">Georgia (Elegant)</option>
|
||||
<option value="Helvetica, Arial, sans-serif">Helvetica (Clean)</option>
|
||||
<option value="'Playfair Display', serif">Playfair Display (Sophisticated)</option>
|
||||
<option value="'Comic Sans MS', cursive">Comic Sans (Playful)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Border Radius
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['none', 'sm', 'md', 'lg'] as const).map((radius) => (
|
||||
<button
|
||||
key={radius}
|
||||
onClick={() => handleChange('borderRadius', radius)}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all ${
|
||||
localTheme.borderRadius === radius
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{radius === 'none' ? 'None' : radius.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Branding</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Custom Logo
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
{localTheme.logoUrl && (
|
||||
<img
|
||||
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : buildResourceUrl(localTheme.logoUrl)}
|
||||
alt="Custom logo"
|
||||
className="h-16 w-auto object-contain"
|
||||
/>
|
||||
)}
|
||||
<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', '')}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Custom CSS */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Custom CSS</h3>
|
||||
<textarea
|
||||
value={customCss}
|
||||
onChange={(e) => setCustomCss(e.target.value)}
|
||||
placeholder="/* Add custom CSS here */"
|
||||
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
Advanced: Add custom CSS to further customize the appearance
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Theme
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,597 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../common';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
|
||||
// import { settingsService } from '../../services/settings.service';
|
||||
// import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemeCustomizerEnhancedProps {
|
||||
value: ThemeConfig;
|
||||
onChange: (theme: ThemeConfig) => void;
|
||||
presetName?: string;
|
||||
onPresetChange?: (presetName: string) => void;
|
||||
isPreviewMode?: boolean;
|
||||
showGalleryLayouts?: boolean;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
grid: <Grid3X3 className="w-5 h-5" />,
|
||||
masonry: <Layers className="w-5 h-5" />,
|
||||
carousel: <Play className="w-5 h-5" />,
|
||||
timeline: <Clock className="w-5 h-5" />,
|
||||
hero: <Image className="w-5 h-5" />,
|
||||
mosaic: <LayoutGrid className="w-5 h-5" />
|
||||
};
|
||||
|
||||
// Layout descriptions will use translation keys
|
||||
|
||||
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
presetName = 'default',
|
||||
onPresetChange,
|
||||
isPreviewMode = false,
|
||||
showGalleryLayouts = true,
|
||||
hideActions = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
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);
|
||||
|
||||
// When any change is made, mark it as custom
|
||||
if (selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
|
||||
if (isPreviewMode) {
|
||||
onChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSelect = (presetKey: string) => {
|
||||
const preset = GALLERY_THEME_PRESETS[presetKey];
|
||||
if (preset) {
|
||||
setSelectedPreset(presetKey);
|
||||
setLocalTheme(preset.config);
|
||||
if (onPresetChange) {
|
||||
onPresetChange(presetKey);
|
||||
}
|
||||
if (isPreviewMode) {
|
||||
onChange(preset.config);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onChange({ ...localTheme, customCss });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultPreset = GALLERY_THEME_PRESETS['default'];
|
||||
if (defaultPreset) {
|
||||
setSelectedPreset('default');
|
||||
setLocalTheme(defaultPreset.config);
|
||||
setCustomCss('');
|
||||
onChange(defaultPreset.config);
|
||||
if (onPresetChange) {
|
||||
onPresetChange('default');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// const file = e.target.files?.[0];
|
||||
// if (file) {
|
||||
// try {
|
||||
// const logoUrl = await settingsService.uploadLogo(file);
|
||||
// handleChange('logoUrl', logoUrl);
|
||||
// toast.success('Logo uploaded successfully');
|
||||
// } catch (error) {
|
||||
// console.error('Failed to upload logo:', error);
|
||||
// toast.error('Failed to upload logo');
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
const updateGallerySettings = (key: string, value: any) => {
|
||||
const updatedSettings = {
|
||||
...localTheme.gallerySettings,
|
||||
[key]: value
|
||||
};
|
||||
handleChange('gallerySettings', updatedSettings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Preset Themes */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
{t('branding.themePresets')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handlePresetSelect(key)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||
selectedPreset === key
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<span className="font-medium text-sm block">{theme.name}</span>
|
||||
{theme.description && (
|
||||
<span className="text-xs text-neutral-600 mt-1 block">{theme.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedPreset === key && (
|
||||
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<div className="flex gap-1">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.primaryColor }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.accentColor }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.backgroundColor }}
|
||||
/>
|
||||
</div>
|
||||
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
|
||||
<div className="ml-auto text-neutral-400">
|
||||
{layoutIcons[theme.config.galleryLayout]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Gallery Layout */}
|
||||
{showGalleryLayouts && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Layout className="w-5 h-5" />
|
||||
{t('branding.galleryLayout')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
|
||||
<button
|
||||
key={layout}
|
||||
onClick={() => handleChange('galleryLayout', layout)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
localTheme.galleryLayout === layout
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2 text-neutral-700">
|
||||
{layoutIcons[layout]}
|
||||
</div>
|
||||
<span className="font-medium text-sm capitalize">{layout}</span>
|
||||
<span className="text-xs text-neutral-600 mt-1">
|
||||
{t(`branding.layoutDescriptions.${layout}`)}
|
||||
</span>
|
||||
</div>
|
||||
{localTheme.galleryLayout === layout && (
|
||||
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Layout-specific settings */}
|
||||
{localTheme.galleryLayout && (
|
||||
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200">
|
||||
<h4 className="font-medium text-sm text-neutral-700">{t('branding.layoutSettings')}</h4>
|
||||
|
||||
{/* Common settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.photoSpacing')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.spacing || 'normal'}
|
||||
onChange={(e) => updateGallerySettings('spacing', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="tight">{t('branding.spacing.tight')}</option>
|
||||
<option value="normal">{t('branding.spacing.normal')}</option>
|
||||
<option value="relaxed">{t('branding.spacing.relaxed')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.photoAnimation')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.photoAnimation || 'fade'}
|
||||
onChange={(e) => updateGallerySettings('photoAnimation', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">{t('branding.animation.none')}</option>
|
||||
<option value="fade">{t('branding.animation.fade')}</option>
|
||||
<option value="scale">{t('branding.animation.scale')}</option>
|
||||
<option value="slide">{t('branding.animation.slide')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid specific */}
|
||||
{localTheme.galleryLayout === 'grid' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.columns')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">{t('branding.mobile')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="4"
|
||||
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
mobile: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">{t('branding.tablet')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
max="6"
|
||||
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
tablet: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">{t('branding.desktop')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="8"
|
||||
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
|
||||
onChange={(e) => updateGallerySettings('gridColumns', {
|
||||
...localTheme.gallerySettings?.gridColumns,
|
||||
desktop: parseInt(e.target.value)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Carousel specific */}
|
||||
{localTheme.galleryLayout === 'carousel' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localTheme.gallerySettings?.carouselAutoplay || false}
|
||||
onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700">{t('branding.enableAutoplay')}</span>
|
||||
</label>
|
||||
</div>
|
||||
{localTheme.gallerySettings?.carouselAutoplay && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.autoplayInterval')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
max="10"
|
||||
value={(localTheme.gallerySettings?.carouselInterval || 5000) / 1000}
|
||||
onChange={(e) => updateGallerySettings('carouselInterval', parseInt(e.target.value) * 1000)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Timeline specific */}
|
||||
{localTheme.galleryLayout === 'timeline' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.groupPhotosBy')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.timelineGrouping || 'day'}
|
||||
onChange={(e) => updateGallerySettings('timelineGrouping', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="day">{t('branding.grouping.day')}</option>
|
||||
<option value="week">{t('branding.grouping.week')}</option>
|
||||
<option value="month">{t('branding.grouping.month')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Color Customization */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Palette className="w-5 h-5" />
|
||||
{t('branding.colors')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.primaryColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.primaryColor || '#5C8762'}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.primaryColor || '#5C8762'}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
placeholder="#5C8762"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.accentColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.accentColor || '#22c55e'}
|
||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.accentColor || '#22c55e'}
|
||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
||||
placeholder="#22c55e"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.backgroundColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.backgroundColor || '#fafafa'}
|
||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.backgroundColor || '#fafafa'}
|
||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
||||
placeholder="#fafafa"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.textColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.textColor || '#171717'}
|
||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.textColor || '#171717'}
|
||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
||||
placeholder="#171717"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Typography & Style */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Type className="w-5 h-5" />
|
||||
{t('branding.typographyAndStyle')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.bodyFont')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.fontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => handleChange('fontFamily', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="Inter, sans-serif">Inter</option>
|
||||
<option value="system-ui, sans-serif">System UI</option>
|
||||
<option value="Georgia, serif">Georgia</option>
|
||||
<option value="'Playfair Display', serif">Playfair Display</option>
|
||||
<option value="'Montserrat', sans-serif">Montserrat</option>
|
||||
<option value="'IBM Plex Sans', sans-serif">IBM Plex Sans</option>
|
||||
<option value="'Comic Neue', cursive">Comic Neue</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.headingFont')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => handleChange('headingFontFamily', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="">{t('branding.sameAsBody')}</option>
|
||||
<option value="'Playfair Display', serif">Playfair Display</option>
|
||||
<option value="'Montserrat', sans-serif">Montserrat</option>
|
||||
<option value="Georgia, serif">Georgia</option>
|
||||
<option value="'IBM Plex Sans', sans-serif">IBM Plex Sans</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.fontSize')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.fontSize || 'normal'}
|
||||
onChange={(e) => handleChange('fontSize', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="small">{t('branding.fontSizes.small')}</option>
|
||||
<option value="normal">{t('branding.fontSizes.normal')}</option>
|
||||
<option value="large">{t('branding.fontSizes.large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.borderRadius')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.borderRadius || 'md'}
|
||||
onChange={(e) => handleChange('borderRadius', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">{t('branding.borderRadiusOptions.none')}</option>
|
||||
<option value="sm">{t('branding.borderRadiusOptions.small')}</option>
|
||||
<option value="md">{t('branding.borderRadiusOptions.medium')}</option>
|
||||
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.shadowStyle')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.shadowStyle || 'normal'}
|
||||
onChange={(e) => handleChange('shadowStyle', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">{t('branding.shadowOptions.none')}</option>
|
||||
<option value="subtle">{t('branding.shadowOptions.subtle')}</option>
|
||||
<option value="normal">{t('branding.shadowOptions.normal')}</option>
|
||||
<option value="dramatic">{t('branding.shadowOptions.dramatic')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.backgroundPattern')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.backgroundPattern || 'none'}
|
||||
onChange={(e) => handleChange('backgroundPattern', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">{t('branding.backgroundOptions.none')}</option>
|
||||
<option value="dots">{t('branding.backgroundOptions.dots')}</option>
|
||||
<option value="grid">{t('branding.backgroundOptions.grid')}</option>
|
||||
<option value="waves">{t('branding.backgroundOptions.waves')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Custom CSS */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.customCSS')}</h3>
|
||||
<textarea
|
||||
value={customCss}
|
||||
onChange={(e) => {
|
||||
setCustomCss(e.target.value);
|
||||
// Mark as custom when CSS is added
|
||||
if (e.target.value && selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
}}
|
||||
placeholder="/* Add custom CSS here */"
|
||||
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
{t('branding.customCSSHelp')}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
{!hideActions && (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('branding.resetToDefault')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{t('branding.applyTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Palette,
|
||||
Type,
|
||||
Grid3X3,
|
||||
Layers,
|
||||
Play,
|
||||
Clock,
|
||||
Image,
|
||||
LayoutGrid,
|
||||
Layout
|
||||
} from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemeDisplayProps {
|
||||
theme: ThemeConfig | string;
|
||||
presetName?: string;
|
||||
className?: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
grid: <Grid3X3 className="w-4 h-4" />,
|
||||
masonry: <Layers className="w-4 h-4" />,
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
theme,
|
||||
presetName,
|
||||
className = '',
|
||||
showDetails = true
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Parse theme if it's a string
|
||||
let themeConfig: ThemeConfig | null = null;
|
||||
let themeName = t('branding.theme');
|
||||
|
||||
if (typeof theme === 'string') {
|
||||
try {
|
||||
if (theme.startsWith('{')) {
|
||||
themeConfig = JSON.parse(theme);
|
||||
} else {
|
||||
// Legacy theme name - find matching preset
|
||||
const preset = Object.entries(GALLERY_THEME_PRESETS).find(([key]) => key === theme);
|
||||
if (preset) {
|
||||
themeConfig = preset[1].config;
|
||||
themeName = preset[1].name;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse theme:', e);
|
||||
}
|
||||
} else {
|
||||
themeConfig = theme;
|
||||
}
|
||||
|
||||
// If we have a preset name, use its display name
|
||||
if (presetName && GALLERY_THEME_PRESETS[presetName]) {
|
||||
themeName = GALLERY_THEME_PRESETS[presetName].name;
|
||||
}
|
||||
|
||||
if (!themeConfig) {
|
||||
return (
|
||||
<div className={`text-sm text-neutral-500 ${className}`}>
|
||||
{t('events.noThemeSet')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const galleryLayout = themeConfig.galleryLayout || 'grid';
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{/* Theme Name & Layout */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm font-medium text-neutral-700">{themeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
{layoutIcons[galleryLayout]}
|
||||
<span className="capitalize">{t(`branding.layoutDescriptions.${galleryLayout}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* Color Palette */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">{t('branding.colors')}:</span>
|
||||
<div className="flex gap-1">
|
||||
{themeConfig.primaryColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.primaryColor }}
|
||||
title={t('branding.primaryColor')}
|
||||
/>
|
||||
)}
|
||||
{themeConfig.accentColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.accentColor }}
|
||||
title={t('branding.accentColor')}
|
||||
/>
|
||||
)}
|
||||
{themeConfig.backgroundColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.backgroundColor }}
|
||||
title={t('branding.backgroundColor')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Typography */}
|
||||
{themeConfig.fontFamily && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">{t('branding.bodyFont')}:</span>
|
||||
<span className="text-sm font-medium" style={{ fontFamily: themeConfig.fontFamily }}>
|
||||
{themeConfig.fontFamily}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layout Settings */}
|
||||
{themeConfig.gallerySettings && (
|
||||
<div className="text-sm text-neutral-600">
|
||||
{themeConfig.gallerySettings.spacing && (
|
||||
<span className="inline-flex items-center gap-1 mr-3">
|
||||
<span>{t('branding.photoSpacing')}:</span>
|
||||
<span className="font-medium capitalize">
|
||||
{t(`branding.spacing.${themeConfig.gallerySettings.spacing}`)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{themeConfig.gallerySettings.photoAnimation && themeConfig.gallerySettings.photoAnimation !== 'none' && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>{t('branding.photoAnimation')}:</span>
|
||||
<span className="font-medium capitalize">
|
||||
{t(`branding.animation.${themeConfig.gallerySettings.photoAnimation}`)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ThemeDisplay.displayName = 'ThemeDisplay';
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, Check } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
import { GalleryPreview } from './GalleryPreview';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemeEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (theme: ThemeConfig, presetName: string) => void;
|
||||
currentTheme: ThemeConfig | string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
grid: <Grid3X3 className="w-4 h-4" />,
|
||||
masonry: <Layers className="w-4 h-4" />,
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
currentTheme,
|
||||
eventName
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
|
||||
const [presetName, setPresetName] = useState<string>('default');
|
||||
const [previewLayout, setPreviewLayout] = useState<GalleryLayoutType | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTheme) {
|
||||
if (typeof currentTheme === 'string') {
|
||||
try {
|
||||
if (currentTheme.startsWith('{')) {
|
||||
const parsedTheme = JSON.parse(currentTheme);
|
||||
setTheme(parsedTheme);
|
||||
// Try to find matching preset
|
||||
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
|
||||
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
|
||||
);
|
||||
setPresetName(matchingPreset ? matchingPreset[0] : 'custom');
|
||||
} else {
|
||||
// Legacy theme name
|
||||
const preset = GALLERY_THEME_PRESETS[currentTheme];
|
||||
if (preset) {
|
||||
setTheme(preset.config);
|
||||
setPresetName(currentTheme);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse theme:', e);
|
||||
setTheme(GALLERY_THEME_PRESETS.default.config);
|
||||
setPresetName('default');
|
||||
}
|
||||
} else {
|
||||
setTheme(currentTheme);
|
||||
setPresetName('custom');
|
||||
}
|
||||
}
|
||||
}, [currentTheme]);
|
||||
|
||||
const handleThemeChange = (newTheme: ThemeConfig) => {
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
const handlePresetChange = (newPresetName: string) => {
|
||||
setPresetName(newPresetName);
|
||||
if (newPresetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[newPresetName];
|
||||
if (preset) {
|
||||
setTheme(preset.config);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(theme, presetName);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultPreset = GALLERY_THEME_PRESETS.default;
|
||||
setTheme(defaultPreset.config);
|
||||
setPresetName('default');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{t('events.galleryTheme')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 mt-1">
|
||||
{t('events.customizingThemeFor', { event: eventName })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
|
||||
{/* Left side - Theme Customizer */}
|
||||
<div className="p-6 overflow-y-auto border-r border-neutral-200">
|
||||
<ThemeCustomizerEnhanced
|
||||
value={theme}
|
||||
onChange={handleThemeChange}
|
||||
presetName={presetName}
|
||||
onPresetChange={handlePresetChange}
|
||||
isPreviewMode={true}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side - Gallery Preview */}
|
||||
<div className="p-6 bg-neutral-50 overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
{/* Grid Style Selector */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-3">
|
||||
{t('branding.previewLayout')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
|
||||
<button
|
||||
key={layout}
|
||||
onClick={() => setPreviewLayout(layout)}
|
||||
className={`relative p-3 rounded-lg border-2 transition-all ${
|
||||
(previewLayout || theme.galleryLayout || 'grid') === layout
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="text-neutral-700">
|
||||
{layoutIcons[layout]}
|
||||
</div>
|
||||
<span className="text-xs capitalize">{layout}</span>
|
||||
</div>
|
||||
{(previewLayout || theme.galleryLayout || 'grid') === layout && (
|
||||
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery Preview */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-3">
|
||||
{t('branding.livePreview')}
|
||||
</h3>
|
||||
<GalleryPreview
|
||||
theme={theme}
|
||||
layoutType={previewLayout}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-neutral-200 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('branding.resetToDefault')}
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('branding.saveTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ThemeEditorModal.displayName = 'ThemeEditorModal';
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info } from 'lucide-react';
|
||||
import { api } from '../../config/api';
|
||||
import packageJson from '../../../package.json';
|
||||
|
||||
// Frontend version from package.json
|
||||
const FRONTEND_VERSION = packageJson.version;
|
||||
|
||||
interface SystemVersion {
|
||||
backend: string;
|
||||
frontend: string;
|
||||
node: string;
|
||||
environment: string;
|
||||
}
|
||||
|
||||
async function fetchSystemVersion(): Promise<SystemVersion> {
|
||||
const response = await api.get<SystemVersion>('/admin/system/version');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const VersionInfo: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: versionInfo } = useQuery({
|
||||
queryKey: ['system-version'],
|
||||
queryFn: fetchSystemVersion,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-neutral-200">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<Info className="w-3 h-3" />
|
||||
<span className="font-medium">{t('admin.version')}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
|
||||
<div>Frontend: v{FRONTEND_VERSION}</div>
|
||||
{versionInfo && (
|
||||
<div>Backend: v{versionInfo.backend}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
interface WelcomeMessageEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 6
|
||||
}) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
// Convert newlines to <br> tags for preview
|
||||
const getPreviewHtml = () => {
|
||||
return value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('<br />');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 text-neutral-400">
|
||||
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-neutral-500">
|
||||
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-neutral-700 mb-2">Preview:</p>
|
||||
<div className="p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<div
|
||||
className="text-sm text-neutral-700 whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
|
||||
@@ -0,0 +1,25 @@
|
||||
export { AdminLayout } from './AdminLayout';
|
||||
export { AdminSidebar } from './AdminSidebar';
|
||||
export { AdminHeader } from './AdminHeader';
|
||||
export { ThemeCustomizer } from './ThemeCustomizer';
|
||||
export { PasswordChangeModal } from './PasswordChangeModal';
|
||||
export { AdminAuthWrapper } from './AdminAuthWrapper';
|
||||
export { PhotoUpload } from './PhotoUpload';
|
||||
export { CategoryManager } from './CategoryManager';
|
||||
export { EventCategoryManager } from './EventCategoryManager';
|
||||
export { CMSEditor } from './CMSEditor';
|
||||
export { WelcomeMessageEditor } from './WelcomeMessageEditor';
|
||||
export { BulkArchiveModal } from './BulkArchiveModal';
|
||||
export { MaintenanceBanner } from './MaintenanceBanner';
|
||||
export { EmailPreviewModal } from './EmailPreviewModal';
|
||||
export { AdminPhotoGrid } from './AdminPhotoGrid';
|
||||
export { AdminPhotoViewer } from './AdminPhotoViewer';
|
||||
export { PhotoFilters } from './PhotoFilters';
|
||||
export { PasswordResetModal } from './PasswordResetModal';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
export { ThemeDisplay } from './ThemeDisplay';
|
||||
export { ThemeEditorModal } from './ThemeEditorModal';
|
||||
export { HeroPhotoSelector } from './HeroPhotoSelector';
|
||||
export { PhotoUploadModal } from './PhotoUploadModal';
|
||||
export { GalleryPreview } from './GalleryPreview';
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getAuthToken } from '../../config/api';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
isGallery?: boolean;
|
||||
}
|
||||
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
src,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
useWatermark = false,
|
||||
isGallery = false,
|
||||
...props
|
||||
}) => {
|
||||
const [imageSrc, setImageSrc] = useState<string>('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
// Determine which token to use based on context
|
||||
let token: string | undefined;
|
||||
|
||||
if (isGallery) {
|
||||
// For gallery images, get the gallery-specific token
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
||||
const gallerySlug = pathParts[2];
|
||||
token = localStorage.getItem(`gallery_token_${gallerySlug}`) || undefined;
|
||||
}
|
||||
} else {
|
||||
// For admin images, use the admin token
|
||||
token = getAuthToken(true);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// No auth token - use fallback
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Create a new URL with auth header
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
// Use the src as-is since it should already be the correct endpoint
|
||||
let imageUrl = src;
|
||||
|
||||
// Build full URL for the image
|
||||
// For API paths that start with /admin, we need to prepend /api
|
||||
const fullImageUrl = imageUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${imageUrl}`)
|
||||
: imageUrl.startsWith('/')
|
||||
? buildResourceUrl(imageUrl)
|
||||
: imageUrl;
|
||||
|
||||
// Fetch authenticated image
|
||||
const response = await fetch(fullImageUrl, {
|
||||
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) {
|
||||
// Image loading failed - use fallback
|
||||
setError(true);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImage();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src, fallbackSrc, useWatermark, isGallery]);
|
||||
|
||||
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,70 @@
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
disabled,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const baseStyles = 'btn';
|
||||
|
||||
const variants = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
outline: 'btn-outline',
|
||||
ghost: 'bg-transparent hover:bg-neutral-100 text-neutral-700',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'btn-sm',
|
||||
md: 'btn-md',
|
||||
lg: 'btn-lg',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
aria-busy={isLoading}
|
||||
aria-disabled={disabled || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-label="Loading" />
|
||||
) : (
|
||||
leftIcon && <span className="mr-2" aria-hidden="true">{leftIcon}</span>
|
||||
)}
|
||||
{children}
|
||||
{!isLoading && rightIcon && <span className="ml-2" aria-hidden="true">{rightIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'hover';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const paddingStyles = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
variant === 'hover' ? 'card-hover' : 'card',
|
||||
paddingStyles[padding],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CardHeader: React.FC<CardHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-start justify-between mb-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-neutral-500">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="ml-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CardContent: React.FC<CardContentProps> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx('', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CardFooter: React.FC<CardFooterProps> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-6 pt-6 border-t border-neutral-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
|
||||
|
||||
export const DynamicFavicon: React.FC = () => {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${getApiBaseUrl()}/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
|
||||
: buildResourceUrl(settings.branding_favicon_url);
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}, [settings?.branding_favicon_url]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
console.error('Component stack:', errorInfo.componentStack);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return <>{this.props.fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center p-4">
|
||||
<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">
|
||||
{i18n.t('errors.somethingWentWrong')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 mb-6">
|
||||
{this.state.error?.message || i18n.t('errors.tryAgainLater')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={this.handleReset}
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
>
|
||||
{i18n.t('errors.refreshPage')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Page-level error boundary with more prominent UI
|
||||
export class PageErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Page error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center p-4">
|
||||
<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">
|
||||
{i18n.t('errors.oopsSomethingWentWrong')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mb-8">
|
||||
{i18n.t('errors.unexpectedError')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={this.handleReset}
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
{i18n.t('errors.goToHomepage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full"
|
||||
>
|
||||
{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">
|
||||
{i18n.t('errors.errorDetails')}
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-neutral-100 p-3 rounded overflow-auto">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-neutral-700 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span className="text-neutral-500">{leftIcon}</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'input',
|
||||
leftIcon && 'pl-10',
|
||||
rightIcon && 'pr-10',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
aria-describedby={
|
||||
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<span className="text-neutral-500">{rightIcon}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface LoadingProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
text?: string;
|
||||
fullScreen?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Loading: React.FC<LoadingProps> = ({
|
||||
size = 'md',
|
||||
text,
|
||||
fullScreen = false,
|
||||
className,
|
||||
}) => {
|
||||
const sizeStyles = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className={clsx('flex flex-col items-center justify-center', className)}>
|
||||
<Loader2 className={clsx('animate-spin text-primary-600', sizeStyles[size])} />
|
||||
{text && (
|
||||
<p className="mt-4 text-sm text-neutral-600">{text}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
className?: string;
|
||||
count?: number;
|
||||
type?: 'text' | 'card' | 'image';
|
||||
}
|
||||
|
||||
export const LoadingSkeleton: React.FC<LoadingSkeletonProps> = ({
|
||||
className,
|
||||
count = 1,
|
||||
type = 'text',
|
||||
}) => {
|
||||
const baseStyles = 'skeleton';
|
||||
|
||||
const typeStyles = {
|
||||
text: 'h-4 w-full rounded',
|
||||
card: 'h-32 w-full rounded-xl',
|
||||
image: 'aspect-square w-full rounded-lg',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
typeStyles[type],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { WifiOff, Wifi } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const OfflineIndicator: React.FC = () => {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [showIndicator, setShowIndicator] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setIsOnline(true);
|
||||
// Show "back online" message briefly
|
||||
setShowIndicator(true);
|
||||
setTimeout(() => setShowIndicator(false), 3000);
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
setIsOnline(false);
|
||||
setShowIndicator(true);
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
// Check initial state
|
||||
if (!navigator.onLine) {
|
||||
setShowIndicator(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!showIndicator) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-auto z-50',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
isOnline ? 'translate-y-0' : 'translate-y-0'
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg',
|
||||
isOnline
|
||||
? 'bg-green-50 border border-green-200 text-green-900'
|
||||
: 'bg-red-50 border border-red-200 text-red-900'
|
||||
)}
|
||||
>
|
||||
{isOnline ? (
|
||||
<>
|
||||
<Wifi className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Back online</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">No internet connection</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to monitor online status
|
||||
export const useOnlineStatus = () => {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStatusChange = () => {
|
||||
setIsOnline(navigator.onLine);
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleStatusChange);
|
||||
window.addEventListener('offline', handleStatusChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleStatusChange);
|
||||
window.removeEventListener('offline', handleStatusChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getApiBaseUrl } from '../../utils/url';
|
||||
|
||||
interface ReCaptchaProps {
|
||||
onChange: (token: string | null) => void;
|
||||
onExpired?: () => void;
|
||||
size?: 'normal' | 'compact';
|
||||
}
|
||||
|
||||
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
|
||||
onChange,
|
||||
onExpired,
|
||||
size = 'normal'
|
||||
}) => {
|
||||
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
|
||||
const [siteKey, setSiteKey] = useState<string>('');
|
||||
|
||||
// Fetch public settings to get reCAPTCHA site key
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.recaptcha_site_key) {
|
||||
setSiteKey(settings.recaptcha_site_key);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// If reCAPTCHA is not enabled or site key is not available, return null
|
||||
if (!settings?.enable_recaptcha || !siteKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey={siteKey}
|
||||
onChange={onChange}
|
||||
onExpired={onExpired}
|
||||
size={size}
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReCaptcha;
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: 'text' | 'circular' | 'rectangular';
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
export const Skeleton: React.FC<SkeletonProps> = ({
|
||||
className,
|
||||
variant = 'rectangular',
|
||||
width,
|
||||
height,
|
||||
animation = 'pulse'
|
||||
}) => {
|
||||
const baseClasses = 'bg-neutral-200';
|
||||
|
||||
const animationClasses = {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'animate-shimmer',
|
||||
none: ''
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
text: 'rounded',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded-lg'
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
|
||||
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
baseClasses,
|
||||
animationClasses[animation],
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Skeleton group for consistent loading states
|
||||
interface SkeletonGroupProps {
|
||||
count?: number;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SkeletonGroup: React.FC<SkeletonGroupProps> = ({
|
||||
count = 1,
|
||||
className,
|
||||
children
|
||||
}) => {
|
||||
if (children) {
|
||||
return <div className={cn('space-y-3', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<Skeleton key={index} height={20} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Common skeleton patterns
|
||||
export const SkeletonCard: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<div className={cn('bg-white rounded-lg shadow-sm p-6', className)}>
|
||||
<Skeleton height={24} width="60%" className="mb-4" />
|
||||
<SkeletonGroup count={3} />
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Skeleton width={100} height={36} />
|
||||
<Skeleton width={100} height={36} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SkeletonTable: React.FC<{ rows?: number; className?: string }> = ({
|
||||
rows = 5,
|
||||
className
|
||||
}) => (
|
||||
<div className={cn('bg-white rounded-lg shadow-sm overflow-hidden', className)}>
|
||||
<div className="border-b border-neutral-200 p-4">
|
||||
<div className="flex gap-4">
|
||||
<Skeleton width="30%" height={20} />
|
||||
<Skeleton width="25%" height={20} />
|
||||
<Skeleton width="20%" height={20} />
|
||||
<Skeleton width="25%" height={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-neutral-100">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<div key={index} className="p-4">
|
||||
<div className="flex gap-4">
|
||||
<Skeleton width="30%" height={16} />
|
||||
<Skeleton width="25%" height={16} />
|
||||
<Skeleton width="20%" height={16} />
|
||||
<Skeleton width="25%" height={16} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SkeletonGalleryGrid: React.FC<{ count?: number; className?: string }> = ({
|
||||
count = 12,
|
||||
className
|
||||
}) => (
|
||||
<div className={cn('gallery-grid', className)}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rectangular"
|
||||
className="aspect-square w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SkeletonList: React.FC<{ count?: number; className?: string }> = ({
|
||||
count = 5,
|
||||
className
|
||||
}) => (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-4">
|
||||
<Skeleton variant="circular" width={48} height={48} />
|
||||
<div className="flex-1">
|
||||
<Skeleton height={20} width="70%" className="mb-2" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export const SkipLink: React.FC = () => {
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-primary-600 text-white px-4 py-2 rounded-lg z-50 focus:outline-none focus:ring-2 focus:ring-primary-700"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
||||
export { Loading, LoadingSkeleton } from './Loading';
|
||||
export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary';
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonGroup,
|
||||
SkeletonCard,
|
||||
SkeletonTable,
|
||||
SkeletonGalleryGrid,
|
||||
SkeletonList
|
||||
} from './Skeleton';
|
||||
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
|
||||
export { SkipLink } from './SkipLink';
|
||||
export { DynamicFavicon } from './DynamicFavicon';
|
||||
export { LanguageSelector } from './LanguageSelector';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export { ReCaptcha } from './ReCaptcha';
|
||||
@@ -0,0 +1,77 @@
|
||||
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;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, className = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
isExpired: boolean;
|
||||
}>({ hours: 0, minutes: 0, seconds: 0, isExpired: false });
|
||||
|
||||
useEffect(() => {
|
||||
const calculateTimeLeft = () => {
|
||||
const expirationDate = new Date(expiresAt);
|
||||
const now = new Date();
|
||||
|
||||
if (expirationDate <= now) {
|
||||
setTimeLeft({ hours: 0, minutes: 0, seconds: 0, isExpired: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSeconds = differenceInSeconds(expirationDate, now);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
setTimeLeft({ hours, minutes, seconds, isExpired: false });
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
const interval = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [expiresAt]);
|
||||
|
||||
if (timeLeft.isExpired) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-red-600 ${className}`}>
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span className="font-semibold">{t('gallery.expired')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show countdown if less than 24 hours remain
|
||||
if (timeLeft.hours >= 24) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<Clock className="w-5 h-5 text-orange-600 animate-pulse" />
|
||||
<div className="flex items-center gap-1 font-mono text-lg">
|
||||
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
|
||||
{String(timeLeft.hours).padStart(2, '0')}
|
||||
</div>
|
||||
<span className="text-orange-600">:</span>
|
||||
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
|
||||
{String(timeLeft.minutes).padStart(2, '0')}
|
||||
</div>
|
||||
<span className="text-orange-600">:</span>
|
||||
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
|
||||
{String(timeLeft.seconds).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-orange-600 font-medium">{t('gallery.remaining')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
|
||||
interface DownloadProgressProps {
|
||||
isDownloading: boolean;
|
||||
progress?: number;
|
||||
fileName?: string;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
||||
isDownloading,
|
||||
progress = 0,
|
||||
fileName,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!isDownloading) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg border border-neutral-200 p-4 min-w-[300px] z-50">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">Downloading...</p>
|
||||
{fileName && (
|
||||
<p className="text-xs text-neutral-500 truncate max-w-[200px]">{fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-neutral-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{progress > 0 && (
|
||||
<p className="text-xs text-neutral-500 mt-1">{Math.round(progress)}% complete</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
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>{t('gallery.expired')}</span>;
|
||||
} else {
|
||||
return (
|
||||
<span className="font-mono">
|
||||
{days}d {hours}h {minutes}m
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getBannerColor = () => {
|
||||
if (daysRemaining <= 1) return 'bg-red-600';
|
||||
if (daysRemaining <= 3) return 'bg-amber-600';
|
||||
return 'bg-amber-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${getBannerColor()} text-white sticky top-0 z-50`}>
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="w-5 h-5 mr-2 animate-pulse" />
|
||||
<span className="font-medium">
|
||||
{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>{t('gallery.downloadBefore')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
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;
|
||||
menuButton?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
event,
|
||||
brandingSettings,
|
||||
showLogout = false,
|
||||
onLogout,
|
||||
showDownloadAll = false,
|
||||
onDownloadAll,
|
||||
isDownloading = false,
|
||||
headerExtra,
|
||||
menuButton,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid' && theme.galleryLayout !== 'hero';
|
||||
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Dynamic Favicon */}
|
||||
<DynamicFavicon />
|
||||
|
||||
{/* Header structure */}
|
||||
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
||||
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
||||
{isNonGridLayout && (
|
||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button and other header extras */}
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Right side - Download and Logout */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For grid layout - everything in one bar */}
|
||||
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
||||
{/* Left side - Menu button, Logo */}
|
||||
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
|
||||
{/* Menu button */}
|
||||
{menuButton && (
|
||||
<div className="flex-shrink-0">
|
||||
{menuButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
buildResourceUrl(brandingSettings.logo_url) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Event info */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left">
|
||||
<h1
|
||||
className="text-base sm:text-lg lg:text-xl font-bold text-neutral-900 leading-tight truncate"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-neutral-600">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||
<span>{format(parseISO(event.event_date), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||
<span>{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* Extra header items (upload button, etc.) */}
|
||||
{headerExtra && (
|
||||
<div className="hidden sm:block">
|
||||
{headerExtra}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download all button - hidden on mobile when sidebar is shown */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile dates row */}
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex sm:hidden justify-center gap-x-3 mt-2 text-xs text-neutral-600">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span>{format(parseISO(event.event_date), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span>{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For hero layout - minimal header with just menu and logout */}
|
||||
{theme.galleryLayout === 'hero' && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
||||
{isNonGridLayout && (
|
||||
<div
|
||||
className="relative text-white overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.accentColor || '#22c55e',
|
||||
backgroundImage: theme.backgroundPattern !== 'none'
|
||||
? `url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E")`
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<div className="container py-12 sm:py-16 lg:py-20 relative z-10">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
buildResourceUrl(brandingSettings.logo_url) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto"
|
||||
style={{
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Name */}
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4"
|
||||
style={{
|
||||
fontFamily: headingFontFamily,
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
|
||||
{/* Event Details */}
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
|
||||
{event.event_date && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
{format(parseISO(event.event_date), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative bottom wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z"
|
||||
fill="var(--color-background, #fafafa)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
|
||||
<div className="container text-center px-4">
|
||||
{brandingSettings?.support_email && (
|
||||
<p className="text-xs sm:text-sm text-neutral-600 mb-2">
|
||||
{t('gallery.needHelp')}{' '}
|
||||
<a
|
||||
href={`mailto:${brandingSettings.support_email}`}
|
||||
className="text-primary-600 hover:text-primary-700 break-all"
|
||||
>
|
||||
{brandingSettings.support_email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs sm:text-sm text-neutral-500">
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'} | Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
{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';
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { PhotoCategory } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GallerySidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: PhotoCategory[];
|
||||
selectedCategoryId: number | null;
|
||||
onCategoryChange: (categoryId: number | null) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
sortBy: 'date' | 'name' | 'size';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size') => void;
|
||||
isSelectionMode: boolean;
|
||||
onToggleSelectionMode: () => void;
|
||||
selectedCount: number;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
isDownloading: boolean;
|
||||
photoCounts?: Record<number, number>;
|
||||
totalPhotos: number;
|
||||
isMobile: boolean;
|
||||
galleryLayout?: string;
|
||||
allowUploads?: boolean;
|
||||
onUploadClick?: () => void;
|
||||
}
|
||||
|
||||
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
selectedCategoryId,
|
||||
onCategoryChange,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
isSelectionMode,
|
||||
onToggleSelectionMode,
|
||||
selectedCount,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
isDownloading,
|
||||
photoCounts = {},
|
||||
totalPhotos,
|
||||
isMobile,
|
||||
galleryLayout,
|
||||
allowUploads,
|
||||
onUploadClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isMobile, isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when sidebar is open on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}
|
||||
}, [isMobile, isOpen]);
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
|
||||
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
|
||||
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobile && isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`
|
||||
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out
|
||||
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Upload Section - Only show on mobile when uploads are allowed */}
|
||||
{isMobile && allowUploads && onUploadClick && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
onUploadClick();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{t('upload.uploadPhotos')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t('gallery.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Section */}
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
{t('gallery.download')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading || totalPhotos === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{t('gallery.downloadAll')} ({totalPhotos})
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isSelectionMode ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onToggleSelectionMode}
|
||||
className="w-full"
|
||||
>
|
||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||
</Button>
|
||||
|
||||
{isSelectionMode && selectedCount > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadSelected}
|
||||
disabled={isDownloading}
|
||||
className="w-full"
|
||||
>
|
||||
{t('gallery.downloadSelected')} ({selectedCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('gallery.categories')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onCategoryChange(null);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${selectedCategoryId === null
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{t('gallery.allCategories')}</span>
|
||||
<span className="text-sm text-neutral-500">{totalPhotos}</span>
|
||||
</button>
|
||||
|
||||
{categories.map((category) => {
|
||||
const count = photoCounts[category.id] || 0;
|
||||
const isSelected = selectedCategoryId === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
onCategoryChange(category.id);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{isSelected && <Check className="w-4 h-4" />}
|
||||
{category.name}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
{t('gallery.sortBy')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
{sortOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = sortBy === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onSortChange(option.value as 'date' | 'name' | 'size');
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{option.label}</span>
|
||||
{isSelected && <Check className="w-4 h-4 ml-auto" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,460 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, SkeletonGalleryGrid, Skeleton } from '../common';
|
||||
import { useGalleryAuth, useTheme } from '../../contexts';
|
||||
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
||||
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
|
||||
import { ExpirationBanner } from './ExpirationBanner';
|
||||
import { CountdownTimer } from './CountdownTimer';
|
||||
import { GalleryLayout } from './GalleryLayout';
|
||||
import { GallerySidebar } from './GallerySidebar';
|
||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
event: {
|
||||
id: number;
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const { watermarkEnabled } = useWatermarkSettings();
|
||||
|
||||
// Fetch photos
|
||||
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
|
||||
|
||||
// Data updates are handled by React Query
|
||||
const downloadAllMutation = useDownloadAllPhotos();
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['gallery-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Apply branding settings
|
||||
useEffect(() => {
|
||||
if (settingsData) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
// Apply theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settingsData && data?.event) {
|
||||
let themeToApply = null;
|
||||
const fullEvent = data.event; // Use the full event data from API
|
||||
|
||||
if (fullEvent.color_theme) {
|
||||
try {
|
||||
// Check if it's a valid JSON string
|
||||
if (fullEvent.color_theme.startsWith('{')) {
|
||||
const eventTheme = JSON.parse(fullEvent.color_theme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// Handle legacy theme names - check if it's a preset
|
||||
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
|
||||
if (preset) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
// Unknown theme name, fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid theme format - use default
|
||||
// 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 with a small delay to ensure it overrides any global theme
|
||||
if (themeToApply) {
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
||||
// Apply hero photo ID to existing gallery settings
|
||||
} else if (fullEvent.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
|
||||
// Create gallery settings with hero photo ID
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
const showUrgentWarning = daysUntilExpiration <= 7;
|
||||
|
||||
// Filter and sort photos
|
||||
const filteredPhotos = useMemo(() => {
|
||||
if (!data?.photos) return [];
|
||||
|
||||
let photos = [...data.photos];
|
||||
|
||||
// Apply category filter
|
||||
if (selectedCategoryId) {
|
||||
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
photos = photos.filter(photo =>
|
||||
photo.filename.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
// Transform URLs for watermarks if enabled
|
||||
if (watermarkEnabled) {
|
||||
photos = photos.map(photo => ({
|
||||
...photo,
|
||||
url: `/gallery/${slug}/photo/${photo.id}`,
|
||||
thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
|
||||
}));
|
||||
}
|
||||
|
||||
return photos;
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug]);
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
downloadAllMutation.mutate(slug);
|
||||
|
||||
// Track download all action
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: data?.photos.length || 0,
|
||||
is_download_all: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
|
||||
|
||||
// Track bulk download
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: selectedPhotos.size
|
||||
});
|
||||
|
||||
// Download each selected photo
|
||||
for (const photo of selectedPhotosList) {
|
||||
await galleryService.downloadPhoto(slug, photo.id, photo.filename);
|
||||
}
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
};
|
||||
|
||||
// Calculate photo counts per category
|
||||
const photoCounts = useMemo(() => {
|
||||
if (!data?.photos) return {};
|
||||
const counts: Record<number, number> = {};
|
||||
data.photos.forEach(photo => {
|
||||
if (photo.category_id) {
|
||||
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [data?.photos]);
|
||||
|
||||
// Track search usage with debouncing
|
||||
useEffect(() => {
|
||||
if (searchTerm.length > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
analyticsService.trackSearch(searchTerm, filteredPhotos.length, 'gallery');
|
||||
}, 1000); // Debounce for 1 second
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [searchTerm, filteredPhotos.length]);
|
||||
|
||||
// Track expiration warning views
|
||||
useEffect(() => {
|
||||
if (showUrgentWarning && daysUntilExpiration > 0) {
|
||||
analyticsService.trackExpirationWarning(slug, daysUntilExpiration);
|
||||
}
|
||||
}, [showUrgentWarning, daysUntilExpiration, slug]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Header Skeleton */}
|
||||
<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>
|
||||
<Skeleton height={32} width={200} className="mb-2" />
|
||||
<Skeleton height={20} width={300} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton height={40} width={120} />
|
||||
<Skeleton height={40} width={100} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<div className="container mt-6">
|
||||
<Skeleton height={80} className="mb-6" />
|
||||
<SkeletonGalleryGrid count={12} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
// Check if it's an authentication error (401)
|
||||
const is401Error = (error as any)?.response?.status === 401;
|
||||
|
||||
if (is401Error) {
|
||||
// Authentication failed - logout and let the parent component handle re-authentication
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
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">{t('gallery.failedToLoad')}</p>
|
||||
<Button onClick={() => refetch()} className="mt-4">
|
||||
{t('gallery.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showSidebar = theme.galleryLayout !== 'grid';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sidebar for non-grid layouts */}
|
||||
{showSidebar ? (
|
||||
<GallerySidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(!sidebarOpen)}
|
||||
categories={(data?.categories || []).filter(cat => photoCounts[cat.id] > 0)}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
selectedCount={selectedPhotos.size}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
photoCounts={photoCounts}
|
||||
totalPhotos={data?.photos.length || 0}
|
||||
isMobile={isMobile}
|
||||
galleryLayout={theme.galleryLayout}
|
||||
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
|
||||
onUploadClick={() => setShowUploadModal(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={!showSidebar}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
menuButton={showSidebar ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<Menu className="w-4 h-4" />}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label={t('gallery.toggleMenu')}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.menu')}</span>
|
||||
</Button>
|
||||
) : undefined}
|
||||
headerExtra={(() => {
|
||||
const items = [];
|
||||
|
||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
items.push(
|
||||
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
||||
);
|
||||
}
|
||||
|
||||
// Upload button only on desktop when sidebar is shown
|
||||
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
|
||||
if (allowUploads && showSidebar && !isMobile) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
>
|
||||
{t('upload.uploadPhotos')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Upload button for non-sidebar layouts
|
||||
if (allowUploads && !showSidebar) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
|
||||
<span className="sm:hidden">{t('common.upload')}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return items.length > 0 ? <>{items}</> : null;
|
||||
})()}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
{/* Search and Filters - Only for grid layout */}
|
||||
{!showSidebar ? (
|
||||
<div className="mt-6">
|
||||
<PhotoFilterBar
|
||||
categories={data.categories}
|
||||
photos={data.photos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className={showSidebar ? "mt-6" : "mt-6"}>
|
||||
<PhotoGridWithLayouts
|
||||
photos={filteredPhotos}
|
||||
slug={slug}
|
||||
categoryId={selectedCategoryId}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectionChange={setSelectedPhotos}
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
showSelectionControls={!showSidebar}
|
||||
eventName={event.event_name}
|
||||
eventLogo={brandingSettings?.logo_url}
|
||||
eventDate={event.event_date}
|
||||
expiresAt={event.expires_at}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && (
|
||||
<UserPhotoUpload
|
||||
eventId={data?.event?.id || event?.id}
|
||||
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
window.location.reload(); // Simple reload for now
|
||||
}}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</GalleryLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
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 sm:flex-row gap-3 sm: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)}
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<SortAsc className="w-4 h-4" />}
|
||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
||||
className="w-full sm:w-auto text-sm sm:text-base"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.sortBy')} </span>
|
||||
{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 sm:right-auto sm:left-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="space-y-3">
|
||||
<div className="flex items-start sm:items-center justify-between flex-col sm:flex-row gap-3">
|
||||
<div className="w-full sm:w-auto overflow-x-auto pb-2 sm:pb-0">
|
||||
<div className="flex items-center gap-2 min-w-max">
|
||||
<Button
|
||||
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
leftIcon={<Grid className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||
className="text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
{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)}
|
||||
className="text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
{category.name} ({categoryPhotoCount})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs sm:text-sm text-neutral-600 flex-shrink-0">
|
||||
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PhotoFilterBar.displayName = 'PhotoFilterBar';
|
||||
@@ -0,0 +1,297 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Maximize2, Check, Package } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
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, 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, 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();
|
||||
|
||||
// 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);
|
||||
} else {
|
||||
newSelected.add(photos[index].id);
|
||||
}
|
||||
setSelectedPhotos(newSelected);
|
||||
} else {
|
||||
setSelectedPhotoIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (photo: Photo, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Track individual photo download
|
||||
analyticsService.trackDownload(photo.id, slug, false);
|
||||
|
||||
downloadPhotoMutation.mutate({
|
||||
slug,
|
||||
photoId: photo.id,
|
||||
filename: photo.filename,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
setSelectedPhotos(new Set());
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedPhotos(new Set(photos.map(p => p.id)));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedPhotos(new Set());
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
|
||||
|
||||
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
|
||||
|
||||
// Download each selected photo
|
||||
const downloadPromises = selectedPhotosList.map(photo =>
|
||||
galleryService.downloadPhoto(slug, photo.id, photo.filename)
|
||||
.catch(err => {
|
||||
// Download failed - error handled by UI
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all(downloadPromises);
|
||||
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
|
||||
|
||||
// Track bulk download
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: selectedPhotos.size
|
||||
});
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
}
|
||||
};
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Selection Mode Controls */}
|
||||
{photos.length > 1 && (
|
||||
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleSelectionMode}
|
||||
title={t('gallery.selectPhotosHint')}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||
</Button>
|
||||
{!isSelectionMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsSelectionMode(true);
|
||||
selectAll();
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{t('gallery.selectAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelectionMode && (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm text-neutral-600">
|
||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="ghost" size="sm" onClick={selectAll} className="text-xs sm:text-sm">
|
||||
{t('gallery.selectAll')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={deselectAll} className="text-xs sm:text-sm">
|
||||
{t('gallery.deselectAll')}
|
||||
</Button>
|
||||
{selectedPhotos.size > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Package className="w-4 h-4" />}
|
||||
onClick={handleDownloadSelected}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadSelected', { count: selectedPhotos.size })}</span>
|
||||
<span className="sm:hidden">{t('common.download')} ({selectedPhotos.size})</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className="gallery-grid">
|
||||
{photos.map((photo, index) => (
|
||||
<PhotoThumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={(e) => handlePhotoClick(index, e)}
|
||||
onDownload={(e) => handleDownload(photo, e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{selectedPhotoIndex !== null && (
|
||||
<PhotoLightbox
|
||||
photos={photos}
|
||||
initialIndex={selectedPhotoIndex}
|
||||
onClose={() => setSelectedPhotoIndex(null)}
|
||||
slug={slug}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
photo,
|
||||
isSelected,
|
||||
isSelectionMode,
|
||||
onClick,
|
||||
onDownload,
|
||||
}) => {
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
threshold: 0.1,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative group cursor-pointer aspect-square"
|
||||
onClick={(e) => onClick(e)}
|
||||
>
|
||||
{inView ? (
|
||||
<>
|
||||
<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"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
|
||||
{/* Overlay on hover/tap - Always visible on mobile for better UX */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 sm:p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 sm:p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={onDownload}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection checkbox - Larger on mobile for easier tapping */}
|
||||
{isSelectionMode && (
|
||||
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100'} transition-opacity`}>
|
||||
<div className={`w-7 h-7 sm:w-6 sm: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>
|
||||
)}
|
||||
|
||||
{/* Photo type badge */}
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
Collage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="skeleton aspect-square w-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
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 { galleryService } from '../../services/gallery.service';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
// Import all layouts
|
||||
import {
|
||||
GridGalleryLayout,
|
||||
MasonryGalleryLayout,
|
||||
CarouselGalleryLayout,
|
||||
TimelineGalleryLayout,
|
||||
HeroGalleryLayout,
|
||||
MosaicGalleryLayout,
|
||||
} from './layouts';
|
||||
|
||||
interface PhotoGridWithLayoutsProps {
|
||||
photos: Photo[];
|
||||
slug: string;
|
||||
categoryId?: number | null;
|
||||
isSelectionMode?: boolean;
|
||||
selectedPhotos?: Set<number>;
|
||||
onSelectionChange?: (photos: Set<number>) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
showSelectionControls?: boolean;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
eventDate?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
categoryId,
|
||||
isSelectionMode: parentSelectionMode,
|
||||
selectedPhotos: parentSelectedPhotos,
|
||||
onSelectionChange,
|
||||
onToggleSelectionMode: parentToggleSelectionMode,
|
||||
showSelectionControls = true,
|
||||
eventName,
|
||||
eventLogo,
|
||||
eventDate,
|
||||
expiresAt
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [localSelectionMode, setLocalSelectionMode] = useState(false);
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
|
||||
// Use parent state if provided, otherwise use local state
|
||||
const selectedPhotos = parentSelectedPhotos ?? localSelectedPhotos;
|
||||
const isSelectionMode = parentSelectionMode ?? localSelectionMode;
|
||||
const setSelectedPhotos = onSelectionChange ?? setLocalSelectedPhotos;
|
||||
const toggleSelectionMode = parentToggleSelectionMode ?? (() => setLocalSelectionMode(!localSelectionMode));
|
||||
|
||||
// Clear selection when category changes
|
||||
useEffect(() => {
|
||||
setSelectedPhotos(new Set());
|
||||
}, [categoryId]);
|
||||
|
||||
const handlePhotoClick = (index: number) => {
|
||||
setSelectedPhotoIndex(index);
|
||||
};
|
||||
|
||||
const handlePhotoSelect = (photoId: number) => {
|
||||
const newSelected = new Set(selectedPhotos);
|
||||
if (newSelected.has(photoId)) {
|
||||
newSelected.delete(photoId);
|
||||
} else {
|
||||
newSelected.add(photoId);
|
||||
}
|
||||
setSelectedPhotos(newSelected);
|
||||
};
|
||||
|
||||
const handleDownload = (photo: Photo, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Track individual photo download
|
||||
analyticsService.trackDownload(photo.id, slug, false);
|
||||
|
||||
downloadPhotoMutation.mutate({
|
||||
slug,
|
||||
photoId: photo.id,
|
||||
filename: photo.filename,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedPhotos(new Set(photos.map(p => p.id)));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedPhotos(new Set());
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
|
||||
|
||||
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
|
||||
|
||||
// Download each selected photo
|
||||
const downloadPromises = selectedPhotosList.map(photo =>
|
||||
galleryService.downloadPhoto(slug, photo.id, photo.filename)
|
||||
.catch(err => {
|
||||
// Download failed - error handled by UI
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all(downloadPromises);
|
||||
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
|
||||
|
||||
// Track bulk download
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: selectedPhotos.size
|
||||
});
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
if (parentToggleSelectionMode) {
|
||||
parentToggleSelectionMode();
|
||||
} else {
|
||||
setLocalSelectionMode(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
}
|
||||
};
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get the current layout from theme
|
||||
const galleryLayout = theme.galleryLayout || 'grid';
|
||||
|
||||
// Select the appropriate layout component
|
||||
const layoutProps = {
|
||||
photos,
|
||||
slug,
|
||||
onPhotoClick: handlePhotoClick,
|
||||
onDownload: handleDownload,
|
||||
selectedPhotos,
|
||||
isSelectionMode,
|
||||
onPhotoSelect: handlePhotoSelect,
|
||||
eventName,
|
||||
eventLogo,
|
||||
eventDate,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
let LayoutComponent;
|
||||
switch (galleryLayout) {
|
||||
case 'masonry':
|
||||
LayoutComponent = MasonryGalleryLayout;
|
||||
break;
|
||||
case 'carousel':
|
||||
LayoutComponent = CarouselGalleryLayout;
|
||||
break;
|
||||
case 'timeline':
|
||||
LayoutComponent = TimelineGalleryLayout;
|
||||
break;
|
||||
case 'hero':
|
||||
LayoutComponent = HeroGalleryLayout;
|
||||
break;
|
||||
case 'mosaic':
|
||||
LayoutComponent = MosaicGalleryLayout;
|
||||
break;
|
||||
default:
|
||||
LayoutComponent = GridGalleryLayout;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
|
||||
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
|
||||
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleSelectionMode}
|
||||
title={t('gallery.selectPhotosHint')}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||
</Button>
|
||||
{!isSelectionMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
toggleSelectionMode();
|
||||
selectAll();
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{t('gallery.selectAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelectionMode && (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm text-neutral-600">
|
||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="ghost" size="sm" onClick={selectAll} className="text-xs sm:text-sm">
|
||||
{t('gallery.selectAll')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={deselectAll} className="text-xs sm:text-sm">
|
||||
{t('gallery.deselectAll')}
|
||||
</Button>
|
||||
{selectedPhotos.size > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Package className="w-4 h-4" />}
|
||||
onClick={handleDownloadSelected}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadSelected', { count: selectedPhotos.size })}</span>
|
||||
<span className="sm:hidden">{t('common.download')} ({selectedPhotos.size})</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render the selected layout */}
|
||||
<LayoutComponent {...layoutProps} />
|
||||
|
||||
{/* Lightbox */}
|
||||
{selectedPhotoIndex !== null && (
|
||||
<PhotoLightbox
|
||||
photos={photos}
|
||||
initialIndex={selectedPhotoIndex}
|
||||
onClose={() => setSelectedPhotoIndex(null)}
|
||||
slug={slug}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
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[];
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
photos,
|
||||
initialIndex,
|
||||
onClose,
|
||||
slug,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
goToPrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
goToNext();
|
||||
break;
|
||||
case '+':
|
||||
case '=':
|
||||
handleZoomIn();
|
||||
break;
|
||||
case '-':
|
||||
case '_':
|
||||
handleZoomOut();
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
handleDownload();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [currentIndex]);
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoom(1);
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setZoom((prev) => Math.min(prev + 0.5, 3));
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setZoom((prev) => Math.max(prev - 0.5, 1));
|
||||
if (zoom - 0.5 <= 1) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadPhotoMutation.mutate({
|
||||
slug,
|
||||
photoId: currentPhoto.id,
|
||||
filename: currentPhoto.filename,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom > 1) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging && zoom > 1) {
|
||||
setDragOffset({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleImageClick = (e: React.MouseEvent) => {
|
||||
// Only close if clicking the background, not the image
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Touch event handlers for pinch-to-zoom
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const distance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
setTouchDistance(distance);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2 && touchDistance !== null) {
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const newDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
|
||||
const scale = newDistance / touchDistance;
|
||||
const newZoom = Math.max(1, Math.min(3, zoom * scale));
|
||||
setZoom(newZoom);
|
||||
setTouchDistance(newDistance);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setTouchDistance(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-50 flex items-center justify-center">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<div className="text-white">
|
||||
<p className="text-sm opacity-75">
|
||||
{currentIndex + 1} / {photos.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<span className="text-white text-sm w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 3}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/20 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image container */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center z-0"
|
||||
onClick={handleImageClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
style={{
|
||||
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s',
|
||||
}}
|
||||
draggable={false}
|
||||
useWatermark={true}
|
||||
isGallery={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Touch/swipe indicators for mobile */}
|
||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
||||
Swipe to navigate
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Upload, X, CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
interface UserPhotoUploadProps {
|
||||
eventId: number;
|
||||
categoryId: number | null | undefined;
|
||||
onUploadComplete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
eventId,
|
||||
categoryId,
|
||||
onUploadComplete,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
|
||||
// Validate file types
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const validFiles = selectedFiles.filter(file => {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error(`Invalid file type: ${file.name}`);
|
||||
return false;
|
||||
}
|
||||
// Check file size (50MB max)
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toast.error(`File too large: ${file.name}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
setFiles(prev => [...prev, ...validFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('photos', file);
|
||||
if (categoryId) {
|
||||
formData.append('category_id', categoryId.toString());
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/gallery/${eventId}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
setUploadProgress(prev => ({
|
||||
...prev,
|
||||
[file.name]: progress,
|
||||
}));
|
||||
}
|
||||
},
|
||||
});
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
// Upload error handled - user notified via UI
|
||||
failedCount++;
|
||||
|
||||
// Show specific error message
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Upload failed';
|
||||
toast.error(`${file.name}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(t('toast.uploadSuccess') + ` (${successCount} ${t('common.photos')})`);
|
||||
onUploadComplete();
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.error(`${failedCount} ${t('upload.someFilesFailed')}`);
|
||||
}
|
||||
|
||||
if (failedCount === 0) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
||||
<div className="w-full sm:max-w-2xl bg-white flex flex-col max-h-[100vh] sm:max-h-[90vh] rounded-2xl shadow-xl overflow-hidden">
|
||||
{/* Fixed Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-neutral-200 flex-shrink-0">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-neutral-900">{t('upload.uploadPhotos')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 sm:p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 p-4 sm:p-6 overflow-y-auto min-h-0">
|
||||
{/* Upload Area */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<label className="block">
|
||||
<div className="border-2 border-dashed border-neutral-300 rounded-lg p-6 sm:p-8 text-center hover:border-primary-500 transition-colors cursor-pointer">
|
||||
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t('upload.fileRequirements')}
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Selected Files */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('upload.selectedFiles')} ({files.length})
|
||||
</h3>
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-neutral-50 rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-900 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
{uploadProgress[file.name] !== undefined ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{uploadProgress[file.name] === 100 ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<div className="w-20">
|
||||
<div className="bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress[file.name]}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-1 hover:bg-neutral-200 rounded transition-colors"
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="w-4 h-4 text-neutral-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<div className="flex items-center justify-end gap-2 sm:gap-3 p-4 sm:p-6 border-t border-neutral-200 bg-white flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={uploading}
|
||||
className="text-sm sm:text-base"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || uploading}
|
||||
isLoading={uploading}
|
||||
className="text-sm sm:text-base"
|
||||
>
|
||||
{uploading ? t('upload.uploading') : t('common.upload')} ({files.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UserPhotoUpload.displayName = 'UserPhotoUpload';
|
||||
@@ -0,0 +1,8 @@
|
||||
export { GalleryView } from './GalleryView';
|
||||
export { PhotoGrid } from './PhotoGrid';
|
||||
export { PhotoLightbox } from './PhotoLightbox';
|
||||
export { ExpirationBanner } from './ExpirationBanner';
|
||||
export { CountdownTimer } from './CountdownTimer';
|
||||
export { GalleryLayout } from './GalleryLayout';
|
||||
export { PhotoFilterBar } from './PhotoFilterBar';
|
||||
export { UserPhotoUpload } from './UserPhotoUpload';
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
export interface BaseGalleryLayoutProps {
|
||||
photos: Photo[];
|
||||
slug: string;
|
||||
onPhotoClick: (index: number) => void;
|
||||
onDownload: (photo: Photo, e: React.MouseEvent) => void;
|
||||
selectedPhotos?: Set<number>;
|
||||
isSelectionMode?: boolean;
|
||||
onPhotoSelect?: (photoId: number) => void;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
eventDate?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||
abstract render(): React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause } from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage, Button } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
|
||||
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
// selectedPhotos = new Set(),
|
||||
// isSelectionMode = false
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const autoplay = gallerySettings.carouselAutoplay || false;
|
||||
const interval = gallerySettings.carouselInterval || 5000;
|
||||
const showThumbnails = gallerySettings.carouselShowThumbnails !== false;
|
||||
|
||||
// Auto-play functionality
|
||||
useEffect(() => {
|
||||
if (isPlaying && photos.length > 1) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % photos.length);
|
||||
}, interval);
|
||||
} else if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isPlaying, photos.length, interval]);
|
||||
|
||||
// Start autoplay if enabled
|
||||
useEffect(() => {
|
||||
if (autoplay) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [autoplay]);
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + photos.length) % photos.length);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % photos.length);
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
if (photos.length === 0) return null;
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Main Carousel */}
|
||||
<div className="relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
|
||||
<AuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
className="w-full h-full object-contain"
|
||||
isGallery={true}
|
||||
/>
|
||||
|
||||
{/* Navigation Controls */}
|
||||
<div className="absolute inset-0 flex items-center justify-between p-4">
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="p-2 bg-black/50 text-white rounded-full hover:bg-black/70 transition-colors"
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="p-2 bg-black/50 text-white rounded-full hover:bg-black/70 transition-colors"
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Top Controls */}
|
||||
<div className="absolute top-4 left-4 right-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 bg-black/50 text-white rounded-full text-sm">
|
||||
{currentIndex + 1} / {photos.length}
|
||||
</span>
|
||||
{currentPhoto.category_name && (
|
||||
<span className="px-3 py-1 bg-black/50 text-white rounded-full text-sm">
|
||||
{currentPhoto.category_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={togglePlayPause}
|
||||
className="text-white hover:bg-white/20"
|
||||
title={isPlaying ? 'Pause slideshow' : 'Play slideshow'}
|
||||
>
|
||||
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPhotoClick(currentIndex)}
|
||||
className="text-white hover:bg-white/20"
|
||||
title="View fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => onDownload(currentPhoto, e)}
|
||||
className="text-white hover:bg-white/20"
|
||||
title="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isPlaying && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/20">
|
||||
<div
|
||||
className="h-full bg-white transition-all duration-1000 ease-linear"
|
||||
style={{
|
||||
width: '100%',
|
||||
animation: `progress ${interval}ms linear infinite`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{showThumbnails && photos.length > 1 && (
|
||||
<div className="mt-4 relative">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-neutral-400">
|
||||
{photos.map((photo, index) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
className={`relative flex-shrink-0 w-20 h-20 rounded overflow-hidden transition-all ${
|
||||
index === currentIndex
|
||||
? 'ring-2 ring-primary-600 scale-110'
|
||||
: 'opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes progress {
|
||||
from { width: 0%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Download, Maximize2, Check } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
interface GridPhotoProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
animationType?: string;
|
||||
}
|
||||
|
||||
const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
photo,
|
||||
isSelected,
|
||||
isSelectionMode,
|
||||
onClick,
|
||||
onDownload,
|
||||
animationType = 'fade'
|
||||
}) => {
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
threshold: 0.1,
|
||||
});
|
||||
|
||||
const animationClass = animationType === 'scale'
|
||||
? 'transition-transform duration-300 hover:scale-105'
|
||||
: animationType === 'fade'
|
||||
? 'transition-opacity duration-300'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative group cursor-pointer aspect-square ${animationClass}`}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
opacity: !inView && animationType === 'fade' ? 0 : 1
|
||||
}}
|
||||
>
|
||||
{inView ? (
|
||||
<>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={onDownload}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 transition-colors`}>
|
||||
{isSelected && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
Collage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="skeleton aspect-square w-full rounded-lg" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
|
||||
const spacing = gallerySettings.spacing || 'normal';
|
||||
const animation = gallerySettings.photoAnimation || 'fade';
|
||||
|
||||
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
||||
|
||||
const gridClass = `grid ${spacingClass}
|
||||
grid-cols-${columns.mobile}
|
||||
sm:grid-cols-${columns.tablet}
|
||||
lg:grid-cols-${columns.desktop}
|
||||
xl:grid-cols-${columns.desktop + 1}`;
|
||||
|
||||
return (
|
||||
<div className={gridClass}>
|
||||
{photos.map((photo, index) => (
|
||||
<GridPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photo.id);
|
||||
} else {
|
||||
onPhotoClick(index);
|
||||
}
|
||||
}}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
animationType={animation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock } from 'lucide-react';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import { buildResourceUrl } from '../../../utils/url';
|
||||
|
||||
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
eventDate?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect,
|
||||
eventName,
|
||||
eventLogo,
|
||||
eventDate,
|
||||
expiresAt
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||
|
||||
// Reset initialization when heroImageId changes
|
||||
useEffect(() => {
|
||||
if (gallerySettings.heroImageId) {
|
||||
setHasInitialized(false);
|
||||
}
|
||||
}, [gallerySettings.heroImageId]);
|
||||
|
||||
// Select hero photo (admin-selected or first photo only if gallery was empty)
|
||||
useEffect(() => {
|
||||
if (photos.length > 0) {
|
||||
const heroId = gallerySettings.heroImageId;
|
||||
// Process hero layout with provided photos
|
||||
|
||||
// If admin has selected a specific hero image, always use it
|
||||
if (heroId) {
|
||||
const adminSelectedHero = photos.find(p => p.id === heroId);
|
||||
// Hero photo selected by admin
|
||||
if (adminSelectedHero) {
|
||||
setHeroPhoto(adminSelectedHero);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only auto-select first photo on initial load when gallery was empty
|
||||
// This prevents changing the hero when new photos are uploaded
|
||||
if (!hasInitialized) {
|
||||
setHeroPhoto(photos[0]);
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}
|
||||
}, [photos, gallerySettings.heroImageId, hasInitialized]);
|
||||
|
||||
if (!heroPhoto) return null;
|
||||
|
||||
// Show all photos including the hero photo in the grid
|
||||
const remainingPhotos = photos;
|
||||
|
||||
return (
|
||||
<div className="relative -mt-6">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.url}
|
||||
alt={heroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
isGallery={true}
|
||||
/>
|
||||
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black"
|
||||
style={{ opacity: overlayOpacity }}
|
||||
/>
|
||||
|
||||
{/* Hero Content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center px-4">
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo ?
|
||||
buildResourceUrl(eventLogo) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt="Event logo"
|
||||
className="h-20 sm:h-24 lg:h-32 mx-auto"
|
||||
style={{
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Title */}
|
||||
{eventName && (
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
|
||||
{eventName}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Event Dates */}
|
||||
{(eventDate || expiresAt) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
|
||||
{eventDate && (
|
||||
<span className="flex items-center text-lg sm:text-xl">
|
||||
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
||||
{format(parseISO(eventDate), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
{expiresAt && (
|
||||
<span className="flex items-center text-lg sm:text-xl">
|
||||
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
||||
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
|
||||
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Section */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{remainingPhotos.map((photo) => {
|
||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative group cursor-pointer aspect-square"
|
||||
onClick={() => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photo.id);
|
||||
} else {
|
||||
onPhotoClick(actualIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPhotoClick(actualIndex);
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(photo, e);
|
||||
}}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelectionMode && (
|
||||
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
|
||||
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Maximize2, Check } from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
interface MasonryPhotoProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
photo,
|
||||
isSelected,
|
||||
isSelectionMode,
|
||||
onClick,
|
||||
onDownload,
|
||||
style
|
||||
}) => {
|
||||
const [imageHeight, setImageHeight] = useState<number>(200);
|
||||
|
||||
// Generate random heights for masonry effect
|
||||
useEffect(() => {
|
||||
const heights = [200, 250, 300, 350, 400];
|
||||
const randomHeight = heights[Math.floor(Math.random() * heights.length)];
|
||||
setImageHeight(randomHeight);
|
||||
}, [photo.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
...style,
|
||||
height: `${imageHeight}px`,
|
||||
breakInside: 'avoid'
|
||||
}}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={onDownload}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 transition-colors`}>
|
||||
{isSelected && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
Collage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [columns, setColumns] = useState(3);
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const gutter = gallerySettings.masonryGutter || 16;
|
||||
|
||||
// Calculate number of columns based on container width
|
||||
useEffect(() => {
|
||||
const updateColumns = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.offsetWidth;
|
||||
if (width < 640) setColumns(2);
|
||||
else if (width < 1024) setColumns(3);
|
||||
else if (width < 1280) setColumns(4);
|
||||
else setColumns(5);
|
||||
}
|
||||
};
|
||||
|
||||
updateColumns();
|
||||
window.addEventListener('resize', updateColumns);
|
||||
return () => window.removeEventListener('resize', updateColumns);
|
||||
}, []);
|
||||
|
||||
// Distribute photos across columns
|
||||
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
|
||||
photos.forEach((photo, index) => {
|
||||
photoColumns[index % columns].push(photo);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex gap-4"
|
||||
style={{ gap: `${gutter}px` }}
|
||||
>
|
||||
{photoColumns.map((column, columnIndex) => (
|
||||
<div
|
||||
key={columnIndex}
|
||||
className="flex-1 flex flex-col"
|
||||
style={{ gap: `${gutter}px` }}
|
||||
>
|
||||
{column.map((photo) => {
|
||||
const originalIndex = photos.findIndex(p => p.id === photo.id);
|
||||
return (
|
||||
<MasonryPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photo.id);
|
||||
} else {
|
||||
onPhotoClick(originalIndex);
|
||||
}
|
||||
}}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import React from 'react';
|
||||
import { Download, Maximize2, Check } from 'lucide-react';
|
||||
// import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
interface MosaicPhotoProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
photo,
|
||||
isSelected,
|
||||
isSelectionMode,
|
||||
onClick,
|
||||
onDownload,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={onDownload}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 transition-colors`}>
|
||||
{isSelected && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
Collage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect
|
||||
}) => {
|
||||
// const { theme } = useTheme();
|
||||
// const gallerySettings = theme.gallerySettings || {};
|
||||
// const pattern = gallerySettings.mosaicPattern || 'structured';
|
||||
|
||||
const handlePhotoClick = (index: number, photoId: number) => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photoId);
|
||||
} else {
|
||||
onPhotoClick(index);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a more structured mosaic layout
|
||||
const renderMosaicLayout = () => {
|
||||
const elements = [];
|
||||
let photoIndex = 0;
|
||||
let patternIndex = 0;
|
||||
|
||||
while (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.length - photoIndex;
|
||||
|
||||
// Choose pattern based on rotation and remaining photos
|
||||
if (patternIndex % 3 === 0 && remainingPhotos >= 3) {
|
||||
// Pattern 1: Large left, 2 small right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
className="col-span-1"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
photo={photo1}
|
||||
isSelected={selectedPhotos.has(photo1.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
||||
onDownload={(e) => onDownload(photo1, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
photo={photo2}
|
||||
isSelected={selectedPhotos.has(photo2.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
||||
onDownload={(e) => onDownload(photo2, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 1 && remainingPhotos >= 3) {
|
||||
// Pattern 2: 3 equal columns
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[250px]">
|
||||
{[0, 1, 2].map(offset => {
|
||||
const currentIndex = photoIndex + offset;
|
||||
const photo = photos[currentIndex];
|
||||
return photo ? (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
className=""
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 2 && remainingPhotos >= 3) {
|
||||
// Pattern 3: Large span-2 with 2 small on right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
photo={photo1}
|
||||
isSelected={selectedPhotos.has(photo1.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
||||
onDownload={(e) => onDownload(photo1, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
photo={photo2}
|
||||
isSelected={selectedPhotos.has(photo2.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
||||
onDownload={(e) => onDownload(photo2, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else {
|
||||
// Handle remaining photos that don't fit patterns
|
||||
break;
|
||||
}
|
||||
|
||||
patternIndex++;
|
||||
}
|
||||
|
||||
// Add remaining photos in a regular grid
|
||||
if (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.slice(photoIndex);
|
||||
elements.push(
|
||||
<div key={`remaining-${photoIndex}`} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{remainingPhotos.map((photo, idx) => {
|
||||
const index = photoIndex + idx;
|
||||
return (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(index, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
className="aspect-square"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto">
|
||||
{renderMosaicLayout()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Download, Maximize2, Check, Calendar } from 'lucide-react';
|
||||
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const grouping = gallerySettings.timelineGrouping || 'day';
|
||||
const showDates = gallerySettings.timelineShowDates !== false;
|
||||
|
||||
// Group photos by date
|
||||
const groupedPhotos = useMemo(() => {
|
||||
const groups = new Map<string, Photo[]>();
|
||||
|
||||
photos.forEach(photo => {
|
||||
const date = parseISO(photo.uploaded_at);
|
||||
let groupKey: string;
|
||||
|
||||
switch (grouping) {
|
||||
case 'week':
|
||||
const weekStart = startOfWeek(date);
|
||||
groupKey = format(weekStart, 'yyyy-MM-dd');
|
||||
// groupLabel = `Week of ${format(weekStart, 'MMM d, yyyy')}`;
|
||||
break;
|
||||
case 'month':
|
||||
const monthStart = startOfMonth(date);
|
||||
groupKey = format(monthStart, 'yyyy-MM');
|
||||
// groupLabel = format(monthStart, 'MMMM yyyy');
|
||||
break;
|
||||
default: // day
|
||||
const dayStart = startOfDay(date);
|
||||
groupKey = format(dayStart, 'yyyy-MM-dd');
|
||||
// groupLabel = format(dayStart, 'EEEE, MMMM d, yyyy');
|
||||
}
|
||||
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, []);
|
||||
}
|
||||
groups.get(groupKey)!.push(photo);
|
||||
});
|
||||
|
||||
// Convert to array and sort by date
|
||||
return Array.from(groups.entries())
|
||||
.map(([date, photos]) => ({
|
||||
date,
|
||||
label: photos[0] ? format(parseISO(photos[0].uploaded_at), grouping === 'month' ? 'MMMM yyyy' : grouping === 'week' ? "'Week of' MMM d, yyyy" : 'EEEE, MMMM d, yyyy') : date,
|
||||
photos: photos.sort((a, b) => new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime())
|
||||
}))
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}, [photos, grouping]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-neutral-300 hidden lg:block" />
|
||||
|
||||
{/* Timeline groups */}
|
||||
<div className="space-y-12">
|
||||
{groupedPhotos.map((group) => (
|
||||
<div key={group.date} className="relative">
|
||||
{/* Date marker */}
|
||||
{showDates && (
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-primary-600 rounded-full z-10">
|
||||
<Calendar className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-neutral-800">
|
||||
{group.label}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photos grid for this date */}
|
||||
<div className="lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{group.photos.map((photo) => {
|
||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative group cursor-pointer aspect-square"
|
||||
onClick={() => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photo.id);
|
||||
} else {
|
||||
onPhotoClick(actualIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
|
||||
{/* Time label */}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
{format(parseISO(photo.uploaded_at), 'h:mm a')}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPhotoClick(actualIndex);
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(photo, e);
|
||||
}}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelectionMode && (
|
||||
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
|
||||
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export { GridGalleryLayout } from './GridGalleryLayout';
|
||||
export { MasonryGalleryLayout } from './MasonryGalleryLayout';
|
||||
export { CarouselGalleryLayout } from './CarouselGalleryLayout';
|
||||
export { TimelineGalleryLayout } from './TimelineGalleryLayout';
|
||||
export { HeroGalleryLayout } from './HeroGalleryLayout';
|
||||
export { MosaicGalleryLayout } from './MosaicGalleryLayout';
|
||||
export type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
Reference in New Issue
Block a user