Fix brand theme application and add comprehensive translations
- Fixed theme not being reflected on gallery and admin login pages - Created GlobalThemeProvider to apply themes globally - Updated gallery and admin login pages to use dynamic CSS variables - Added complete translations for all admin sections in English and German: - Notifications management - Event view and creation - Photo upload functionality - Category management - Archive page view - Analytics dashboard - Branding and theme settings - System settings - CMS page management - Email configuration - Fixed admin photo management display issues - Fixed photo upload category assignment - Added password reset functionality for galleries - Improved error handling and user feedback 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+95
-65
@@ -5,7 +5,7 @@ import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { analyticsService } from './services/analytics.service';
|
||||
|
||||
import { GalleryAuthProvider } from './contexts';
|
||||
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { GalleryPage } from './pages/GalleryPage';
|
||||
import { PreviewPage } from './pages/gallery/PreviewPage';
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
} from './pages/admin';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
@@ -37,82 +39,110 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
function App() {
|
||||
// Initialize Umami Analytics
|
||||
// Initialize Umami Analytics based on settings
|
||||
useEffect(() => {
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
const initializeAnalytics = async () => {
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
if (umamiUrl && umamiWebsiteId) {
|
||||
try {
|
||||
// Fetch public settings to check if analytics is enabled
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
|
||||
const settings = await response.json();
|
||||
|
||||
// Only initialize if analytics is enabled in settings
|
||||
if (settings.enable_analytics !== false) {
|
||||
analyticsService.initialize({
|
||||
websiteId: umamiWebsiteId,
|
||||
hostUrl: umamiUrl,
|
||||
autoTrack: true,
|
||||
doNotTrack: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings for analytics:', error);
|
||||
// Initialize analytics anyway if settings fetch fails
|
||||
analyticsService.initialize({
|
||||
websiteId: umamiWebsiteId,
|
||||
hostUrl: umamiUrl,
|
||||
autoTrack: true,
|
||||
doNotTrack: true
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (umamiUrl && umamiWebsiteId) {
|
||||
analyticsService.initialize({
|
||||
websiteId: umamiWebsiteId,
|
||||
hostUrl: umamiUrl,
|
||||
autoTrack: true,
|
||||
doNotTrack: true
|
||||
});
|
||||
}
|
||||
initializeAnalytics();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<DynamicFavicon />
|
||||
<Router>
|
||||
<SkipLink />
|
||||
<Routes>
|
||||
{/* Public gallery routes */}
|
||||
<Route path="/gallery/preview" element={<PreviewPage />} />
|
||||
<Route path="/gallery/:slug/:token?" element={
|
||||
<GalleryAuthProvider>
|
||||
<GalleryPage />
|
||||
</GalleryAuthProvider>
|
||||
} />
|
||||
<MaintenanceProvider>
|
||||
<ThemeProvider>
|
||||
<GlobalThemeProvider>
|
||||
<DynamicFavicon />
|
||||
<Router>
|
||||
<MaintenanceWrapper>
|
||||
<SkipLink />
|
||||
<Routes>
|
||||
{/* Public gallery routes */}
|
||||
<Route path="/gallery/preview" element={<PreviewPage />} />
|
||||
<Route path="/gallery/:slug/:token?" element={
|
||||
<GalleryAuthProvider>
|
||||
<GalleryPage />
|
||||
</GalleryAuthProvider>
|
||||
} />
|
||||
|
||||
{/* Admin routes - wrap with AdminAuthProvider */}
|
||||
<Route path="/admin" element={<AdminAuthWrapper />}>
|
||||
<Route path="login" element={<AdminLoginPage />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="dashboard" element={<AdminDashboard />} />
|
||||
<Route path="events" element={<EventsListPage />} />
|
||||
<Route path="events/new" element={<CreateEventPage />} />
|
||||
<Route path="events/:id" element={<EventDetailsPage />} />
|
||||
<Route path="archives" element={<ArchivesPage />} />
|
||||
<Route path="email" element={<EmailConfigPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="cms" element={<CMSPage />} />
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
{/* Admin routes - wrap with AdminAuthProvider */}
|
||||
<Route path="/admin" element={<AdminAuthWrapper />}>
|
||||
<Route path="login" element={<AdminLoginPage />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="dashboard" element={<AdminDashboard />} />
|
||||
<Route path="events" element={<EventsListPage />} />
|
||||
<Route path="events/new" element={<CreateEventPage />} />
|
||||
<Route path="events/:id" element={<EventDetailsPage />} />
|
||||
<Route path="archives" element={<ArchivesPage />} />
|
||||
<Route path="email" element={<EmailConfigPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="cms" element={<CMSPage />} />
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Public legal pages */}
|
||||
<Route path="/impressum" element={<LegalPage />} />
|
||||
<Route path="/datenschutz" element={<LegalPage />} />
|
||||
<Route path="/:slug" element={<LegalPage />} />
|
||||
{/* Public legal pages */}
|
||||
<Route path="/impressum" element={<LegalPage />} />
|
||||
<Route path="/datenschutz" element={<LegalPage />} />
|
||||
<Route path="/:slug" element={<LegalPage />} />
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
||||
</Routes>
|
||||
</MaintenanceWrapper>
|
||||
</Router>
|
||||
|
||||
{/* Offline indicator */}
|
||||
<OfflineIndicator />
|
||||
{/* Offline indicator */}
|
||||
<OfflineIndicator />
|
||||
|
||||
{/* Toast notifications */}
|
||||
<ToastContainer
|
||||
position="bottom-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
/>
|
||||
</ThemeProvider>
|
||||
{/* Toast notifications */}
|
||||
<ToastContainer
|
||||
position="bottom-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
/>
|
||||
</GlobalThemeProvider>
|
||||
</ThemeProvider>
|
||||
</MaintenanceProvider>
|
||||
</QueryClientProvider>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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('/api/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Apply global theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (!themeAppliedRef.current && settingsData?.theme_config) {
|
||||
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';
|
||||
|
||||
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('/api/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 */}
|
||||
{(settings?.branding_logo_url || settings?.branding_company_name) && (
|
||||
<div className="bg-white border-b border-neutral-200 py-4">
|
||||
<div className="container">
|
||||
<div className="flex items-center justify-center">
|
||||
{settings.branding_logo_url ? (
|
||||
<img
|
||||
src={settings.branding_logo_url.startsWith('http')
|
||||
? settings.branding_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`
|
||||
}
|
||||
alt={settings.branding_company_name || 'Company Logo'}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="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('/api/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,93 @@
|
||||
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) {
|
||||
console.error('Failed to load image:', src, err);
|
||||
// Log more details about the error
|
||||
if (err.response) {
|
||||
console.error('Response status:', err.response.status);
|
||||
console.error('Response headers:', err.response.headers);
|
||||
if (err.response.data instanceof Blob) {
|
||||
// Try to read error message from blob
|
||||
try {
|
||||
const text = await err.response.data.text();
|
||||
console.error('Response data:', text);
|
||||
} catch (e) {
|
||||
console.error('Could not read blob data');
|
||||
}
|
||||
} else {
|
||||
console.error('Response data:', err.response.data);
|
||||
}
|
||||
}
|
||||
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} />;
|
||||
};
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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;
|
||||
@@ -20,6 +23,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
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);
|
||||
@@ -32,21 +36,33 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
// Mock notifications
|
||||
const notifications = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'warning',
|
||||
message: '3 events expiring in the next 7 days',
|
||||
time: new Date(),
|
||||
// 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('All notifications marked as read');
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'success',
|
||||
message: 'Wedding Smith-Jones archived successfully',
|
||||
time: new Date(Date.now() - 3600000),
|
||||
});
|
||||
|
||||
// Clear old notifications mutation
|
||||
const clearOldMutation = useMutation({
|
||||
mutationFn: notificationsService.clearOldNotifications,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
toast.success(`Cleared ${data.deletedCount} old notifications`);
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const notifications = notificationsData?.notifications || [];
|
||||
const unreadCount = notificationsData?.unreadCount || 0;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
|
||||
@@ -79,35 +95,80 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
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" />
|
||||
{notifications.length > 0 && (
|
||||
{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-80 bg-white rounded-lg shadow-lg border border-neutral-200 py-2">
|
||||
<div className="px-4 py-2 border-b border-neutral-100">
|
||||
<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="Mark all as read"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => clearOldMutation.mutate()}
|
||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||
title="Clear old notifications"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Clear old
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="px-4 py-3 hover:bg-neutral-50 cursor-pointer"
|
||||
>
|
||||
<p className="text-sm text-neutral-900">{notification.message}</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{format(notification.time, 'h:mm a')}
|
||||
</p>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
No notifications
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-neutral-100">
|
||||
<button className="text-sm text-primary-600 hover:text-primary-700">
|
||||
{t('admin.viewAllNotifications')}
|
||||
</button>
|
||||
) : (
|
||||
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">
|
||||
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
|
||||
</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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,17 @@ 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 (
|
||||
@@ -41,6 +46,9 @@ export const AdminLayout: React.FC = () => {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* 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">
|
||||
|
||||
@@ -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,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,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,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,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>
|
||||
);
|
||||
};
|
||||
@@ -61,9 +61,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
try {
|
||||
const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
// Don't set Content-Type header - axios will set it with the boundary
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
|
||||
@@ -7,4 +7,12 @@ export { AdminAuthWrapper } from './AdminAuthWrapper';
|
||||
export { PhotoUpload } from './PhotoUpload';
|
||||
export { CategoryManager } from './CategoryManager';
|
||||
export { EventCategoryManager } from './EventCategoryManager';
|
||||
export { CMSEditor } from './CMSEditor';
|
||||
export { CMSEditor } from './CMSEditor';
|
||||
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';
|
||||
@@ -54,8 +54,12 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Fetching authenticated image:', imageUrl);
|
||||
const response = await fetch(imageUrl, {
|
||||
// Prepend API URL for absolute paths
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
const fullImageUrl = imageUrl.startsWith('/') ? `${apiUrl}${imageUrl}` : imageUrl;
|
||||
|
||||
console.log('Fetching authenticated image:', fullImageUrl);
|
||||
const response = await fetch(fullImageUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
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(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/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;
|
||||
@@ -15,4 +15,5 @@ export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
|
||||
export { SkipLink } from './SkipLink';
|
||||
export { DynamicFavicon } from './DynamicFavicon';
|
||||
export { LanguageSelector } from './LanguageSelector';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export { ReCaptcha } from './ReCaptcha';
|
||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, LanguageSelector } from '../common';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
|
||||
interface GalleryLayoutProps {
|
||||
@@ -56,7 +56,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{brandingSettings?.logo_url && (
|
||||
<div className="pr-4 border-r border-neutral-200">
|
||||
<img
|
||||
src={brandingSettings.logo_url}
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}`}
|
||||
alt={brandingSettings.company_name || 'Company Logo'}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
@@ -94,7 +94,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{headerExtra}
|
||||
<LanguageSelector />
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -231,14 +231,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
{/* Welcome Message */}
|
||||
{event.welcome_message && (
|
||||
<div className="mt-6">
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||
<p className="text-primary-900">{event.welcome_message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -5,6 +5,13 @@ import Cookies from 'js-cookie';
|
||||
export const ADMIN_TOKEN_KEY = 'admin_token';
|
||||
export const GALLERY_TOKEN_KEY = 'gallery_token';
|
||||
|
||||
// Maintenance mode callback
|
||||
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
|
||||
|
||||
export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => {
|
||||
maintenanceModeCallback = callback;
|
||||
};
|
||||
|
||||
// Create axios instance
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001',
|
||||
@@ -42,6 +49,19 @@ api.interceptors.request.use(
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle maintenance mode (503)
|
||||
if (error.response?.status === 503) {
|
||||
const isAdminRoute = error.config?.url?.includes('/admin');
|
||||
const hasAdminAuth = error.config?.headers?.Authorization?.startsWith('Bearer ');
|
||||
|
||||
// Only trigger maintenance mode for non-admin routes or unauthenticated admin routes
|
||||
if (!isAdminRoute || !hasAdminAuth) {
|
||||
if (maintenanceModeCallback) {
|
||||
maintenanceModeCallback(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Clear tokens on unauthorized
|
||||
Cookies.remove(ADMIN_TOKEN_KEY);
|
||||
|
||||
@@ -16,7 +16,7 @@ interface GalleryEvent {
|
||||
interface GalleryAuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
event: GalleryEvent | null;
|
||||
login: (slug: string, password: string) => Promise<void>;
|
||||
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -62,11 +62,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (slug: string, password: string) => {
|
||||
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password);
|
||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||
setEvent(response.event);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { setMaintenanceModeCallback } from '../config/api';
|
||||
|
||||
interface MaintenanceContextType {
|
||||
isMaintenanceMode: boolean;
|
||||
setMaintenanceMode: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const MaintenanceContext = createContext<MaintenanceContextType | undefined>(undefined);
|
||||
|
||||
export const useMaintenanceMode = () => {
|
||||
const context = useContext(MaintenanceContext);
|
||||
if (!context) {
|
||||
throw new Error('useMaintenanceMode must be used within MaintenanceProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface MaintenanceProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
|
||||
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
|
||||
|
||||
// Check maintenance mode status on mount
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings-maintenance'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
|
||||
if (response.status === 503) {
|
||||
setIsMaintenanceMode(true);
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
// If we can't reach the server, don't assume maintenance mode
|
||||
return null;
|
||||
}
|
||||
},
|
||||
staleTime: 30 * 1000, // Check every 30 seconds
|
||||
refetchInterval: 30 * 1000,
|
||||
});
|
||||
|
||||
// Update maintenance mode based on settings
|
||||
useEffect(() => {
|
||||
if (settings?.maintenance_mode !== undefined) {
|
||||
setIsMaintenanceMode(settings.maintenance_mode);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Set up the callback for API interceptor
|
||||
useEffect(() => {
|
||||
setMaintenanceModeCallback((enabled: boolean) => {
|
||||
setIsMaintenanceMode(enabled);
|
||||
});
|
||||
|
||||
return () => {
|
||||
setMaintenanceModeCallback(null as any);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setMaintenanceMode = (enabled: boolean) => {
|
||||
setIsMaintenanceMode(enabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={{ isMaintenanceMode, setMaintenanceMode }}>
|
||||
{children}
|
||||
</MaintenanceContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext';
|
||||
export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
|
||||
export { ThemeProvider, useTheme, PRESET_THEMES } from './ThemeContext';
|
||||
export type { ThemeConfig, EventTheme } from './ThemeContext';
|
||||
export type { ThemeConfig, EventTheme } from './ThemeContext';
|
||||
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useAdminAuth } from '../contexts';
|
||||
import { api } from '../config/api';
|
||||
|
||||
// Hook to handle session timeout
|
||||
export const useSessionTimeout = () => {
|
||||
const { logout } = useAdminAuth();
|
||||
|
||||
const handleSessionTimeout = useCallback((error: any) => {
|
||||
if (error?.response?.data?.code === 'SESSION_TIMEOUT') {
|
||||
// Clear local auth state
|
||||
logout();
|
||||
// Redirect to login with message
|
||||
window.location.href = '/admin/login?session=expired';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [logout]);
|
||||
|
||||
useEffect(() => {
|
||||
// Add response interceptor to handle session timeout
|
||||
const interceptor = api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (handleSessionTimeout(error)) {
|
||||
// Don't propagate the error if it was a session timeout
|
||||
return Promise.reject(new Error('Session expired'));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up interceptor on unmount
|
||||
return () => {
|
||||
api.interceptors.response.eject(interceptor);
|
||||
};
|
||||
}, [handleSessionTimeout]);
|
||||
|
||||
return { handleSessionTimeout };
|
||||
};
|
||||
@@ -22,7 +22,25 @@
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"uploaded": "Hochgeladen",
|
||||
"photo": "Foto",
|
||||
"photos": "Fotos"
|
||||
"photos": "Fotos",
|
||||
"restore": "Wiederherstellen",
|
||||
"actions": "Aktionen",
|
||||
"refresh": "Aktualisieren",
|
||||
"preview": "Vorschau",
|
||||
"processing": "Wird verarbeitet...",
|
||||
"upload": "Hochladen"
|
||||
},
|
||||
"upload": {
|
||||
"photoCategory": "Fotokategorie",
|
||||
"noCategory": "Keine Kategorie",
|
||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei)",
|
||||
"selectedFiles": "Ausgewählte Dateien",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"uploadComplete": "Upload abgeschlossen!",
|
||||
"uploadFailed": "Upload fehlgeschlagen",
|
||||
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -34,6 +52,46 @@
|
||||
"emailSettings": "E-Mail-Einstellungen",
|
||||
"cmsPages": "CMS-Seiten"
|
||||
},
|
||||
"archives": {
|
||||
"title": "Archive",
|
||||
"subtitle": "Archivierte Fotogalerien verwalten",
|
||||
"loadingArchives": "Archive werden geladen...",
|
||||
"totalArchives": "Gesamtarchive",
|
||||
"storageUsed": "Genutzter Speicher",
|
||||
"totalPhotos": "Gesamtfotos",
|
||||
"avgArchiveSize": "Durchschn. Archivgröße",
|
||||
"searchPlaceholder": "Archive durchsuchen...",
|
||||
"allTypes": "Alle Typen",
|
||||
"wedding": "Hochzeit",
|
||||
"birthday": "Geburtstag",
|
||||
"corporate": "Geschäftlich",
|
||||
"other": "Andere",
|
||||
"sortByDate": "Nach Datum sortieren",
|
||||
"sortByName": "Nach Name sortieren",
|
||||
"sortBySize": "Nach Größe sortieren",
|
||||
"tableHeaders": {
|
||||
"event": "Veranstaltung",
|
||||
"type": "Typ",
|
||||
"archivedDate": "Archivierungsdatum",
|
||||
"size": "Größe",
|
||||
"photos": "Fotos",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
"noArchivesFound": "Keine Archive gefunden",
|
||||
"eventDateNA": "Veranstaltungsdatum: N/A",
|
||||
"processing": "Wird verarbeitet...",
|
||||
"download": "Herunterladen",
|
||||
"restore": "Wiederherstellen",
|
||||
"delete": "Löschen",
|
||||
"showing": "Zeige {{from}} bis {{to}} von {{total}} Archiven",
|
||||
"page": "Seite {{current}} von {{total}}",
|
||||
"storageManagement": "Speicherverwaltung",
|
||||
"storageInfo": "Archive werden dauerhaft gespeichert, es sei denn, sie werden manuell gelöscht. Erwägen Sie die Implementierung einer Aufbewahrungsrichtlinie zur Verwaltung der Speicherkosten.",
|
||||
"confirmRestore": "Sind Sie sicher, dass Sie dieses Archiv wiederherstellen möchten? Die Veranstaltung wird wieder aktiv.",
|
||||
"confirmDelete": "Sind Sie sicher, dass Sie dieses Archiv dauerhaft löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"restoreSuccess": "Archiv erfolgreich wiederhergestellt",
|
||||
"deleteSuccess": "Archiv dauerhaft gelöscht"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
"password": "Passwort",
|
||||
@@ -120,17 +178,78 @@
|
||||
"uploadPhotos": "Fotos hochladen",
|
||||
"archiveEvent": "Veranstaltung archivieren",
|
||||
"archiveConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung archivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"extendExpiration": "Um {{days}} Tage verlängern"
|
||||
"extendExpiration": "Um {{days}} Tage verlängern",
|
||||
"backToEvents": "Zurück zu Veranstaltungen",
|
||||
"loadingEventDetails": "Veranstaltungsdetails werden geladen...",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"eventExpired": "Diese Veranstaltung ist abgelaufen",
|
||||
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
|
||||
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
|
||||
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
||||
"overview": "Übersicht",
|
||||
"photos": "Fotos",
|
||||
"categories": "Kategorien",
|
||||
"eventInformation": "Veranstaltungsinformationen",
|
||||
"welcomeMessage": "Willkommensnachricht",
|
||||
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
|
||||
"created": "Erstellt",
|
||||
"expires": "Läuft ab",
|
||||
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
||||
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
|
||||
"photoStatistics": "Fotostatistiken",
|
||||
"managePhotos": "Fotos verwalten",
|
||||
"actions": "Aktionen",
|
||||
"archivingInfo": "Beim Archivieren wird eine ZIP-Datei aller Fotos erstellt und die Galerie aus dem öffentlichen Zugriff entfernt.",
|
||||
"statistics": "Statistiken",
|
||||
"views": "Aufrufe",
|
||||
"downloads": "Downloads",
|
||||
"uniqueVisitors": "Eindeutige Besucher",
|
||||
"noStatistics": "Noch keine Statistiken verfügbar",
|
||||
"archiveStatus": "Archivstatus",
|
||||
"archivedOn": "Archiviert am",
|
||||
"downloadArchive": "Archiv herunterladen",
|
||||
"loadingPhotos": "Fotos werden geladen...",
|
||||
"photoCategories": "Fotokategorien",
|
||||
"organizeCategoriesInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
|
||||
"categoriesTip": "Tipp: Kategorien sind spezifisch für jede Veranstaltung. Sie können auch globale Kategorien in den Einstellungen erstellen.",
|
||||
"contactInformation": "Kontaktinformationen",
|
||||
"hostEmailHelp": "Erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf",
|
||||
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
||||
"securityAccess": "Sicherheit & Zugriff",
|
||||
"galleryPassword": "Galerie-Passwort",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"showPasswords": "Passwörter anzeigen",
|
||||
"gallerySettings": "Galerie-Einstellungen",
|
||||
"colorTheme": "Farbthema",
|
||||
"galleryExpiresIn": "Galerie läuft ab in",
|
||||
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
|
||||
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
|
||||
"processingRequest": "Ihre Anfrage wird verarbeitet...",
|
||||
"eventTypeWedding": "Hochzeit",
|
||||
"eventTypeBirthday": "Geburtstag",
|
||||
"eventTypeCorporate": "Geschäftlich",
|
||||
"eventTypeOther": "Andere",
|
||||
"days30": "30 Tage",
|
||||
"days60": "60 Tage",
|
||||
"days90": "90 Tage",
|
||||
"days365": "1 Jahr",
|
||||
"createNewEvent": "Neue Veranstaltung erstellen",
|
||||
"setupNewGallery": "Richten Sie eine neue Fotogalerie für Ihre Veranstaltung ein",
|
||||
"adminNotificationEmail": "Admin-Benachrichtigungs-E-Mail"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
"subtitle": "Systemweite Einstellungen und Präferenzen konfigurieren",
|
||||
"loadingSettings": "Einstellungen werden geladen...",
|
||||
"general": {
|
||||
"title": "Allgemein",
|
||||
"siteConfiguration": "Website-Konfiguration",
|
||||
"siteUrl": "Website-URL",
|
||||
"siteUrlHelp": "Wird für die Generierung von Galerielinks in E-Mails verwendet",
|
||||
"defaultExpiration": "Standardablauf (Tage)",
|
||||
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
||||
"maxFileSize": "Max. Dateigröße (MB)",
|
||||
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||
"featureToggles": "Funktionsschalter",
|
||||
@@ -139,7 +258,10 @@
|
||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||
"language": "Sprache",
|
||||
"saveSettings": "Allgemeine Einstellungen speichern"
|
||||
"defaultLanguage": "Standardsprache",
|
||||
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
|
||||
"saveSettings": "Allgemeine Einstellungen speichern",
|
||||
"saveGeneralSettings": "Allgemeine Einstellungen speichern"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicher",
|
||||
@@ -150,23 +272,31 @@
|
||||
"storageUsage": "Speichernutzung",
|
||||
"storageByEvent": "Speicher nach Veranstaltung",
|
||||
"storageManagement": "Speicherverwaltung",
|
||||
"storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien."
|
||||
"storageManagementHelp": "Erwägen Sie, alte Veranstaltungen zu archivieren oder zu löschen, um Speicherplatz freizugeben. Archivierte Veranstaltungen sind komprimiert und benötigen weniger Speicher als aktive Galerien.",
|
||||
"noEventsUsingStorage": "Keine Veranstaltungen verwenden Speicher",
|
||||
"unlimited": "Unbegrenzt"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicherheit",
|
||||
"passwordSettings": "Passworteinstellungen",
|
||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
||||
"minPasswordLength": "Minimale Passwortlänge",
|
||||
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
||||
"sessionAuth": "Sitzung & Authentifizierung",
|
||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
||||
"maxLoginAttempts": "Max. Anmeldeversuche",
|
||||
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche vor Sperrung",
|
||||
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
|
||||
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
||||
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
||||
"siteKey": "Site-Schlüssel",
|
||||
"siteKeyHelp": "Ihr reCAPTCHA v2 Site-Schlüssel (öffentlich)",
|
||||
"secretKey": "Geheimer Schlüssel",
|
||||
"secretKeyHelp": "Ihr reCAPTCHA v2 Geheimschlüssel (privat halten)",
|
||||
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
|
||||
"saveSettings": "Sicherheitseinstellungen speichern"
|
||||
"saveSettings": "Sicherheitseinstellungen speichern",
|
||||
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien",
|
||||
@@ -175,32 +305,63 @@
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding & Anpassung",
|
||||
"title": "Branding & Themen",
|
||||
"titleFull": "Branding & Anpassung",
|
||||
"subtitle": "Passen Sie das Aussehen Ihrer Galerien an",
|
||||
"loadingBranding": "Branding-Einstellungen werden geladen...",
|
||||
"companyInfo": "Unternehmensinformationen",
|
||||
"companyName": "Unternehmensname",
|
||||
"companyNameHelp": "Wird in Galerie-Headern und E-Mails angezeigt",
|
||||
"companyTagline": "Unternehmens-Slogan",
|
||||
"companyTaglineHelp": "Eine kurze Beschreibung Ihres Unternehmens",
|
||||
"supportEmail": "Support-E-Mail",
|
||||
"supportEmailHelp": "Kontakt-E-Mail für Gäste-Support",
|
||||
"footerText": "Fußzeilentext",
|
||||
"footerTextHelp": "Wird am unteren Rand der Galerien angezeigt",
|
||||
"logo": "Logo",
|
||||
"currentLogo": "Aktuelles Logo",
|
||||
"uploadLogo": "Logo hochladen",
|
||||
"removeLogo": "Logo entfernen",
|
||||
"logoHelp": "Empfohlene Größe: 200x60px, PNG oder JPEG",
|
||||
"favicon": "Favicon",
|
||||
"currentFavicon": "Aktuelles Favicon",
|
||||
"uploadFavicon": "Favicon hochladen",
|
||||
"removeFavicon": "Favicon entfernen",
|
||||
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
|
||||
"watermark": "Wasserzeichen",
|
||||
"watermarkSettings": "Wasserzeichen-Einstellungen",
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"enableWatermarks": "Wasserzeichen aktivieren",
|
||||
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
|
||||
"watermarkLogo": "Wasserzeichen-Logo",
|
||||
"currentWatermark": "Aktuelles Wasserzeichen",
|
||||
"uploadWatermarkLogo": "Wasserzeichen-Logo hochladen",
|
||||
"watermarkPosition": "Wasserzeichen-Position",
|
||||
"topLeft": "Oben Links",
|
||||
"topRight": "Oben Rechts",
|
||||
"center": "Mitte",
|
||||
"bottomLeft": "Unten Links",
|
||||
"bottomRight": "Unten Rechts",
|
||||
"watermarkOpacity": "Wasserzeichen-Transparenz",
|
||||
"watermarkSize": "Wasserzeichen-Größe",
|
||||
"theme": "Theme",
|
||||
"galleryTheme": "Galerie-Theme",
|
||||
"themeCustomization": "Theme-Anpassung",
|
||||
"selectPreset": "Vorgefertigtes Theme auswählen",
|
||||
"colors": "Farben",
|
||||
"primaryColor": "Primärfarbe",
|
||||
"secondaryColor": "Sekundärfarbe",
|
||||
"accentColor": "Akzentfarbe",
|
||||
"backgroundColor": "Hintergrundfarbe",
|
||||
"textColor": "Textfarbe",
|
||||
"customCSS": "Benutzerdefiniertes CSS",
|
||||
"preview": "Vorschau",
|
||||
"previewInNewTab": "Vorschau in neuem Tab",
|
||||
"reset": "Zurücksetzen",
|
||||
"saveChanges": "Änderungen speichern"
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"applyLivePreview": "Änderungen sofort anwenden (Live-Vorschau)",
|
||||
"eventSpecificThemes": "Veranstaltungsspezifische Themen",
|
||||
"eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin-Panel",
|
||||
@@ -214,6 +375,10 @@
|
||||
"storagePercent": "{{percent}}% von {{limit}}",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||
"markAsRead": "Als gelesen markieren",
|
||||
"markAllAsRead": "Alle als gelesen markieren",
|
||||
"notificationSettings": "Benachrichtigungseinstellungen",
|
||||
"changePassword": "Passwort ändern",
|
||||
"loadingDashboard": "Dashboard wird geladen...",
|
||||
"activeEvents": "Aktive Veranstaltungen",
|
||||
@@ -238,6 +403,8 @@
|
||||
"notFound": "Nicht gefunden",
|
||||
"galleryNotFound": "Galerie nicht gefunden",
|
||||
"galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.",
|
||||
"galleryArchived": "Galerie archiviert",
|
||||
"galleryArchivedMessage": "Diese Galerie wurde archiviert und ist nicht mehr zugänglich. Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
|
||||
"unauthorized": "Nicht autorisiert",
|
||||
"forbidden": "Verboten",
|
||||
"serverError": "Serverfehler",
|
||||
@@ -280,5 +447,93 @@
|
||||
"pageUpdated": "Seite erfolgreich aktualisiert",
|
||||
"archiveRestored": "Archiv erfolgreich wiederhergestellt",
|
||||
"archiveDeleted": "Archiv dauerhaft gelöscht"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics Dashboard",
|
||||
"titleSimple": "Analytik",
|
||||
"subtitle": "Galerie-Performance und Besucherengagement verfolgen",
|
||||
"detailedSubtitle": "Detaillierte Analysen mit Umami",
|
||||
"loadingAnalytics": "Analytik wird geladen...",
|
||||
"showSummaryView": "Zusammenfassungsansicht anzeigen",
|
||||
"fullDashboard": "Vollständiges Dashboard",
|
||||
"refresh": "Aktualisieren",
|
||||
"last7Days": "Letzte 7 Tage",
|
||||
"last30Days": "Letzte 30 Tage",
|
||||
"last90Days": "Letzte 90 Tage",
|
||||
"pageViews": "Seitenaufrufe",
|
||||
"uniqueVisitors": "Eindeutige Besucher",
|
||||
"totalDownloads": "Gesamte Downloads",
|
||||
"topGallery": "Top-Galerie",
|
||||
"topPages": "Top-Seiten",
|
||||
"views": "Aufrufe",
|
||||
"visitors": "eindeutige Besucher",
|
||||
"topDownloadsByGallery": "Top-Downloads nach Galerie",
|
||||
"deviceBreakdown": "Geräteaufschlüsselung",
|
||||
"desktop": "Desktop",
|
||||
"mobile": "Mobil",
|
||||
"tablet": "Tablet",
|
||||
"storageUsage": "Speichernutzung",
|
||||
"used": "Verwendet",
|
||||
"of": "von",
|
||||
"totalPhotos": "Gesamte Fotos",
|
||||
"activeEvents": "Aktive Veranstaltungen",
|
||||
"notConfigured": "Umami Analytics nicht konfiguriert",
|
||||
"configureInstructions": "Um echte Analysedaten zu sehen, konfigurieren Sie Umami in Ihren Umgebungsvariablen und Admin-Panel-Einstellungen.",
|
||||
"noData": "Keine Daten verfügbar",
|
||||
"percentChange": "{{percent}}% gegenüber letztem Zeitraum"
|
||||
},
|
||||
"email": {
|
||||
"title": "E-Mail-Konfiguration",
|
||||
"subtitle": "E-Mail-Einstellungen für Benachrichtigungen konfigurieren",
|
||||
"loadingSettings": "E-Mail-Einstellungen werden geladen...",
|
||||
"smtpConfiguration": "SMTP-Konfiguration",
|
||||
"smtpHost": "SMTP-Host",
|
||||
"smtpHostHelp": "Ihr E-Mail-Server-Hostname",
|
||||
"smtpPort": "SMTP-Port",
|
||||
"smtpPortHelp": "Normalerweise 587 für TLS, 465 für SSL, 25 für unverschlüsselt",
|
||||
"smtpSecure": "SSL/TLS verwenden",
|
||||
"smtpSecureHelp": "Für sichere E-Mail-Übertragung aktivieren",
|
||||
"smtpUsername": "SMTP-Benutzername",
|
||||
"smtpUsernameHelp": "Ihr E-Mail-Konto-Benutzername",
|
||||
"smtpPassword": "SMTP-Passwort",
|
||||
"smtpPasswordHelp": "Ihr E-Mail-Konto-Passwort",
|
||||
"fromDetails": "Absenderdetails",
|
||||
"fromEmail": "Absender-E-Mail",
|
||||
"fromEmailHelp": "E-Mail-Adresse, die als Absender erscheint",
|
||||
"fromName": "Absendername",
|
||||
"fromNameHelp": "Name, der als Absender erscheint",
|
||||
"testConfiguration": "Konfiguration testen",
|
||||
"testEmail": "Test-E-Mail-Adresse",
|
||||
"testEmailHelp": "Senden Sie eine Test-E-Mail zur Überprüfung der Einstellungen",
|
||||
"sendTestEmail": "Test-E-Mail senden",
|
||||
"saveConfiguration": "Konfiguration speichern",
|
||||
"emailTemplates": "E-Mail-Vorlagen",
|
||||
"templateVariables": "Verfügbare Variablen",
|
||||
"previewTemplate": "Vorlage anzeigen"
|
||||
},
|
||||
"cms": {
|
||||
"title": "CMS-Seiten",
|
||||
"subtitle": "Rechtliche und informative Seiten verwalten",
|
||||
"loadingPages": "Seiten werden geladen...",
|
||||
"pages": "Seiten",
|
||||
"previewLinks": "Vorschau-Links",
|
||||
"englishVersion": "Englische Version",
|
||||
"germanVersion": "Deutsche Version",
|
||||
"editPage": "{{page}} bearbeiten",
|
||||
"pageTitle": "Seitentitel",
|
||||
"pageContent": "Seiteninhalt",
|
||||
"pageTitlePlaceholder": "Seitentitel eingeben...",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"lastUpdated": "Zuletzt aktualisiert:",
|
||||
"impressum": "Impressum",
|
||||
"datenschutz": "Datenschutzerklärung",
|
||||
"pageUpdated": "Seite erfolgreich aktualisiert"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Systemwartung",
|
||||
"message": "Wir führen derzeit geplante Wartungsarbeiten durch, um unseren Service zu verbessern. Wir sind in Kürze wieder online.",
|
||||
"expectedCompletion": "Voraussichtliche Fertigstellung:",
|
||||
"checkBackLater": "Bitte schauen Sie später wieder vorbei",
|
||||
"urgentMatters": "Bei dringenden Anliegen kontaktieren Sie bitte"
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,25 @@
|
||||
"uploading": "Uploading...",
|
||||
"uploaded": "Uploaded",
|
||||
"photo": "photo",
|
||||
"photos": "photos"
|
||||
"photos": "photos",
|
||||
"restore": "Restore",
|
||||
"actions": "Actions",
|
||||
"refresh": "Refresh",
|
||||
"preview": "Preview",
|
||||
"processing": "Processing...",
|
||||
"upload": "Upload"
|
||||
},
|
||||
"upload": {
|
||||
"photoCategory": "Photo Category",
|
||||
"noCategory": "No category",
|
||||
"eventSpecific": "(Event specific)",
|
||||
"clickToUpload": "Click to upload or drag and drop",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file)",
|
||||
"selectedFiles": "Selected files",
|
||||
"uploading": "Uploading...",
|
||||
"uploadComplete": "Upload complete!",
|
||||
"uploadFailed": "Upload failed",
|
||||
"someFilesFailed": "Some files failed to upload"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -34,6 +52,46 @@
|
||||
"emailSettings": "Email Settings",
|
||||
"cmsPages": "CMS Pages"
|
||||
},
|
||||
"archives": {
|
||||
"title": "Archives",
|
||||
"subtitle": "Manage archived photo galleries",
|
||||
"loadingArchives": "Loading archives...",
|
||||
"totalArchives": "Total Archives",
|
||||
"storageUsed": "Storage Used",
|
||||
"totalPhotos": "Total Photos",
|
||||
"avgArchiveSize": "Avg Archive Size",
|
||||
"searchPlaceholder": "Search archives...",
|
||||
"allTypes": "All Types",
|
||||
"wedding": "Wedding",
|
||||
"birthday": "Birthday",
|
||||
"corporate": "Corporate",
|
||||
"other": "Other",
|
||||
"sortByDate": "Sort by Date",
|
||||
"sortByName": "Sort by Name",
|
||||
"sortBySize": "Sort by Size",
|
||||
"tableHeaders": {
|
||||
"event": "Event",
|
||||
"type": "Type",
|
||||
"archivedDate": "Archived Date",
|
||||
"size": "Size",
|
||||
"photos": "Photos",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"noArchivesFound": "No archives found",
|
||||
"eventDateNA": "Event date: N/A",
|
||||
"processing": "Processing...",
|
||||
"download": "Download",
|
||||
"restore": "Restore",
|
||||
"delete": "Delete",
|
||||
"showing": "Showing {{from}} to {{to}} of {{total}} archives",
|
||||
"page": "Page {{current}} of {{total}}",
|
||||
"storageManagement": "Storage Management",
|
||||
"storageInfo": "Archives are stored permanently unless manually deleted. Consider implementing a retention policy to manage storage costs.",
|
||||
"confirmRestore": "Are you sure you want to restore this archive? The event will become active again.",
|
||||
"confirmDelete": "Are you sure you want to permanently delete this archive? This action cannot be undone.",
|
||||
"restoreSuccess": "Archive restored successfully",
|
||||
"deleteSuccess": "Archive deleted permanently"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"password": "Password",
|
||||
@@ -102,12 +160,15 @@
|
||||
"events": {
|
||||
"title": "Events",
|
||||
"createEvent": "Create Event",
|
||||
"createNewEvent": "Create New Event",
|
||||
"setupNewGallery": "Set up a new photo gallery for your event",
|
||||
"eventDetails": "Event Details",
|
||||
"eventName": "Event Name",
|
||||
"eventType": "Event Type",
|
||||
"eventDate": "Event Date",
|
||||
"hostEmail": "Host Email",
|
||||
"adminEmail": "Admin Email",
|
||||
"adminNotificationEmail": "Admin Notification Email",
|
||||
"expirationDate": "Expiration Date",
|
||||
"active": "Active",
|
||||
"archived": "Archived",
|
||||
@@ -120,17 +181,75 @@
|
||||
"uploadPhotos": "Upload Photos",
|
||||
"archiveEvent": "Archive Event",
|
||||
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
|
||||
"extendExpiration": "Extend {{days}} Days"
|
||||
"extendExpiration": "Extend {{days}} Days",
|
||||
"backToEvents": "Back to Events",
|
||||
"loadingEventDetails": "Loading event details...",
|
||||
"saveChanges": "Save Changes",
|
||||
"eventExpired": "This event has expired",
|
||||
"eventExpiresIn": "This event expires in {{days}} days",
|
||||
"guestsNoAccess": "Guests can no longer access the gallery. Consider archiving this event.",
|
||||
"warningEmailsSent": "Warning emails have been sent to the host.",
|
||||
"overview": "Overview",
|
||||
"photos": "Photos",
|
||||
"categories": "Categories",
|
||||
"eventInformation": "Event Information",
|
||||
"welcomeMessage": "Welcome Message",
|
||||
"noWelcomeMessage": "No welcome message set",
|
||||
"created": "Created",
|
||||
"expires": "Expires",
|
||||
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
||||
"resetGalleryPassword": "Reset Gallery Password",
|
||||
"photoStatistics": "Photo Statistics",
|
||||
"managePhotos": "Manage Photos",
|
||||
"actions": "Actions",
|
||||
"archivingInfo": "Archiving will create a ZIP file of all photos and remove the gallery from public access.",
|
||||
"statistics": "Statistics",
|
||||
"views": "Views",
|
||||
"downloads": "Downloads",
|
||||
"uniqueVisitors": "Unique Visitors",
|
||||
"noStatistics": "No statistics available yet",
|
||||
"archiveStatus": "Archive Status",
|
||||
"archivedOn": "Archived On",
|
||||
"downloadArchive": "Download Archive",
|
||||
"loadingPhotos": "Loading photos...",
|
||||
"photoCategories": "Photo Categories",
|
||||
"organizeCategoriesInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
|
||||
"categoriesTip": "Tip: Categories are specific to each event. You can also create global categories in Settings.",
|
||||
"contactInformation": "Contact Information",
|
||||
"hostEmailHelp": "Will receive gallery creation and expiration notifications",
|
||||
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
||||
"securityAccess": "Security & Access",
|
||||
"galleryPassword": "Gallery Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"showPasswords": "Show passwords",
|
||||
"gallerySettings": "Gallery Settings",
|
||||
"colorTheme": "Color Theme",
|
||||
"galleryExpiresIn": "Gallery Expires In",
|
||||
"galleryWillExpireOn": "Gallery will expire on {{date}}",
|
||||
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
|
||||
"processingRequest": "Processing your request...",
|
||||
"eventTypeWedding": "Wedding",
|
||||
"eventTypeBirthday": "Birthday",
|
||||
"eventTypeCorporate": "Corporate",
|
||||
"eventTypeOther": "Other",
|
||||
"days30": "30 days",
|
||||
"days60": "60 days",
|
||||
"days90": "90 days",
|
||||
"days365": "1 year"
|
||||
},
|
||||
"settings": {
|
||||
"title": "System Settings",
|
||||
"subtitle": "Configure system-wide settings and preferences",
|
||||
"loadingSettings": "Loading settings...",
|
||||
"general": {
|
||||
"title": "General",
|
||||
"siteConfiguration": "Site Configuration",
|
||||
"siteUrl": "Site URL",
|
||||
"siteUrlHelp": "Used for generating gallery links in emails",
|
||||
"defaultExpiration": "Default Expiration (days)",
|
||||
"defaultExpirationHelp": "How long galleries remain active by default",
|
||||
"maxFileSize": "Max File Size (MB)",
|
||||
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
||||
"allowedFileTypes": "Allowed File Types",
|
||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||
"featureToggles": "Feature Toggles",
|
||||
@@ -139,7 +258,10 @@
|
||||
"enableRegistration": "Allow self-registration for admins",
|
||||
"maintenanceMode": "Enable maintenance mode",
|
||||
"language": "Language",
|
||||
"saveSettings": "Save General Settings"
|
||||
"defaultLanguage": "Default Language",
|
||||
"defaultLanguageHelp": "Language shown to guests before login",
|
||||
"saveSettings": "Save General Settings",
|
||||
"saveGeneralSettings": "Save General Settings"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
@@ -150,23 +272,31 @@
|
||||
"storageUsage": "Storage Usage",
|
||||
"storageByEvent": "Storage by Event",
|
||||
"storageManagement": "Storage Management",
|
||||
"storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries."
|
||||
"storageManagementHelp": "Consider archiving or deleting old events to free up storage space. Archived events are compressed and use less storage than active galleries.",
|
||||
"noEventsUsingStorage": "No events using storage",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"passwordSettings": "Password Settings",
|
||||
"requirePassword": "Require password for all galleries",
|
||||
"minPasswordLength": "Minimum Password Length",
|
||||
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
||||
"sessionAuth": "Session & Authentication",
|
||||
"sessionTimeout": "Session Timeout (minutes)",
|
||||
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
||||
"maxLoginAttempts": "Max Login Attempts",
|
||||
"maxLoginAttemptsHelp": "Maximum failed login attempts before lockout",
|
||||
"enable2FA": "Enable two-factor authentication for admins",
|
||||
"recaptchaSettings": "reCAPTCHA Settings",
|
||||
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
||||
"siteKey": "Site Key",
|
||||
"siteKeyHelp": "Your reCAPTCHA v2 site key (public)",
|
||||
"secretKey": "Secret Key",
|
||||
"secretKeyHelp": "Your reCAPTCHA v2 secret key (keep private)",
|
||||
"recaptchaHelp": "Get your reCAPTCHA keys from",
|
||||
"saveSettings": "Save Security Settings"
|
||||
"saveSettings": "Save Security Settings",
|
||||
"saveSecuritySettings": "Save Security Settings"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
@@ -174,33 +304,98 @@
|
||||
"aboutText": "Global categories are available for all events. You can also create event-specific categories when editing individual events. Categories help organize photos and allow guests to filter photos by type in the gallery view."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics Dashboard",
|
||||
"titleSimple": "Analytics",
|
||||
"subtitle": "Track gallery performance and visitor engagement",
|
||||
"detailedSubtitle": "Detailed analytics powered by Umami",
|
||||
"loadingAnalytics": "Loading analytics...",
|
||||
"showSummaryView": "Show Summary View",
|
||||
"fullDashboard": "Full Dashboard",
|
||||
"refresh": "Refresh",
|
||||
"last7Days": "Last 7 days",
|
||||
"last30Days": "Last 30 days",
|
||||
"last90Days": "Last 90 days",
|
||||
"pageViews": "Page Views",
|
||||
"uniqueVisitors": "Unique Visitors",
|
||||
"totalDownloads": "Total Downloads",
|
||||
"topGallery": "Top Gallery",
|
||||
"topPages": "Top Pages",
|
||||
"views": "views",
|
||||
"visitors": "unique visitors",
|
||||
"topDownloadsByGallery": "Top Downloads by Gallery",
|
||||
"deviceBreakdown": "Device Breakdown",
|
||||
"desktop": "Desktop",
|
||||
"mobile": "Mobile",
|
||||
"tablet": "Tablet",
|
||||
"storageUsage": "Storage Usage",
|
||||
"used": "Used",
|
||||
"of": "of",
|
||||
"totalPhotos": "Total Photos",
|
||||
"activeEvents": "Active Events",
|
||||
"notConfigured": "Umami Analytics Not Configured",
|
||||
"configureInstructions": "To see real analytics data, configure Umami in your environment variables and admin panel settings.",
|
||||
"noData": "No data available",
|
||||
"percentChange": "{{percent}}% from last period"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding & Customization",
|
||||
"title": "Branding & Themes",
|
||||
"titleFull": "Branding & Customization",
|
||||
"subtitle": "Customize the look and feel of your galleries",
|
||||
"loadingBranding": "Loading branding settings...",
|
||||
"companyInfo": "Company Information",
|
||||
"companyName": "Company Name",
|
||||
"companyNameHelp": "Displayed in gallery headers and emails",
|
||||
"companyTagline": "Company Tagline",
|
||||
"companyTaglineHelp": "A short description of your business",
|
||||
"supportEmail": "Support Email",
|
||||
"supportEmailHelp": "Contact email for guest support",
|
||||
"footerText": "Footer Text",
|
||||
"footerTextHelp": "Displayed at the bottom of galleries",
|
||||
"logo": "Logo",
|
||||
"currentLogo": "Current logo",
|
||||
"uploadLogo": "Upload Logo",
|
||||
"removeLogo": "Remove Logo",
|
||||
"logoHelp": "Recommended size: 200x60px, PNG or JPEG",
|
||||
"favicon": "Favicon",
|
||||
"currentFavicon": "Current favicon",
|
||||
"uploadFavicon": "Upload Favicon",
|
||||
"removeFavicon": "Remove Favicon",
|
||||
"faviconHelp": "PNG or ICO format, recommended size: 32x32px",
|
||||
"watermark": "Watermark",
|
||||
"watermarkSettings": "Watermark Settings",
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"enableWatermarks": "Enable Watermarks",
|
||||
"watermarkHelp": "Add your company name as a watermark on downloaded photos",
|
||||
"watermarkLogo": "Watermark Logo",
|
||||
"currentWatermark": "Current watermark",
|
||||
"uploadWatermarkLogo": "Upload Watermark Logo",
|
||||
"watermarkPosition": "Watermark Position",
|
||||
"topLeft": "Top Left",
|
||||
"topRight": "Top Right",
|
||||
"center": "Center",
|
||||
"bottomLeft": "Bottom Left",
|
||||
"bottomRight": "Bottom Right",
|
||||
"watermarkOpacity": "Watermark Opacity",
|
||||
"watermarkSize": "Watermark Size",
|
||||
"theme": "Theme",
|
||||
"galleryTheme": "Gallery Theme",
|
||||
"themeCustomization": "Theme Customization",
|
||||
"selectPreset": "Select a preset theme",
|
||||
"colors": "Colors",
|
||||
"primaryColor": "Primary Color",
|
||||
"secondaryColor": "Secondary Color",
|
||||
"accentColor": "Accent Color",
|
||||
"backgroundColor": "Background Color",
|
||||
"textColor": "Text Color",
|
||||
"customCSS": "Custom CSS",
|
||||
"preview": "Preview",
|
||||
"previewInNewTab": "Preview in New Tab",
|
||||
"reset": "Reset",
|
||||
"saveChanges": "Save Changes"
|
||||
"saveChanges": "Save Changes",
|
||||
"applyLivePreview": "Apply changes immediately (Live Preview)",
|
||||
"eventSpecificThemes": "Event-Specific Themes",
|
||||
"eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Panel",
|
||||
@@ -214,6 +409,10 @@
|
||||
"storagePercent": "{{percent}}% of {{limit}}",
|
||||
"notifications": "Notifications",
|
||||
"viewAllNotifications": "View all notifications",
|
||||
"noNotifications": "No new notifications",
|
||||
"markAsRead": "Mark as read",
|
||||
"markAllAsRead": "Mark all as read",
|
||||
"notificationSettings": "Notification Settings",
|
||||
"changePassword": "Change Password",
|
||||
"loadingDashboard": "Loading dashboard...",
|
||||
"activeEvents": "Active Events",
|
||||
@@ -238,6 +437,8 @@
|
||||
"notFound": "Not Found",
|
||||
"galleryNotFound": "Gallery Not Found",
|
||||
"galleryNotFoundMessage": "This gallery does not exist or has been removed.",
|
||||
"galleryArchived": "Gallery Archived",
|
||||
"galleryArchivedMessage": "This gallery has been archived and is no longer accessible. Please contact the event organizer if you need access to these photos.",
|
||||
"unauthorized": "Unauthorized",
|
||||
"forbidden": "Forbidden",
|
||||
"serverError": "Server Error",
|
||||
@@ -280,5 +481,59 @@
|
||||
"pageUpdated": "Page updated successfully",
|
||||
"archiveRestored": "Archive restored successfully",
|
||||
"archiveDeleted": "Archive deleted permanently"
|
||||
},
|
||||
"email": {
|
||||
"title": "Email Configuration",
|
||||
"subtitle": "Configure email settings for notifications",
|
||||
"loadingSettings": "Loading email settings...",
|
||||
"smtpConfiguration": "SMTP Configuration",
|
||||
"smtpHost": "SMTP Host",
|
||||
"smtpHostHelp": "Your email server hostname",
|
||||
"smtpPort": "SMTP Port",
|
||||
"smtpPortHelp": "Usually 587 for TLS, 465 for SSL, 25 for unencrypted",
|
||||
"smtpSecure": "Use SSL/TLS",
|
||||
"smtpSecureHelp": "Enable for secure email transmission",
|
||||
"smtpUsername": "SMTP Username",
|
||||
"smtpUsernameHelp": "Your email account username",
|
||||
"smtpPassword": "SMTP Password",
|
||||
"smtpPasswordHelp": "Your email account password",
|
||||
"fromDetails": "From Details",
|
||||
"fromEmail": "From Email",
|
||||
"fromEmailHelp": "Email address that appears as sender",
|
||||
"fromName": "From Name",
|
||||
"fromNameHelp": "Name that appears as sender",
|
||||
"testConfiguration": "Test Configuration",
|
||||
"testEmail": "Test Email Address",
|
||||
"testEmailHelp": "Send a test email to verify settings",
|
||||
"sendTestEmail": "Send Test Email",
|
||||
"saveConfiguration": "Save Configuration",
|
||||
"emailTemplates": "Email Templates",
|
||||
"templateVariables": "Available Variables",
|
||||
"previewTemplate": "Preview Template"
|
||||
},
|
||||
"cms": {
|
||||
"title": "CMS Pages",
|
||||
"subtitle": "Manage legal and informational pages",
|
||||
"loadingPages": "Loading pages...",
|
||||
"pages": "Pages",
|
||||
"previewLinks": "Preview Links",
|
||||
"englishVersion": "English Version",
|
||||
"germanVersion": "German Version",
|
||||
"editPage": "Edit {{page}}",
|
||||
"pageTitle": "Page Title",
|
||||
"pageContent": "Page Content",
|
||||
"pageTitlePlaceholder": "Enter page title...",
|
||||
"saveChanges": "Save Changes",
|
||||
"lastUpdated": "Last updated:",
|
||||
"impressum": "Legal Notice",
|
||||
"datenschutz": "Privacy Policy",
|
||||
"pageUpdated": "Page updated successfully"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "System Maintenance",
|
||||
"message": "We're currently performing scheduled maintenance to improve our service. We'll be back online shortly.",
|
||||
"expectedCompletion": "Expected completion time:",
|
||||
"checkBackLater": "Please check back later",
|
||||
"urgentMatters": "For urgent matters, please contact"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { format, differenceInDays, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Card, CardContent, Input, Button, Loading } from '../components/common';
|
||||
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
|
||||
import { useGalleryAuth } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { GalleryView } from '../components/gallery';
|
||||
@@ -19,6 +19,7 @@ export const GalleryPage: React.FC = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// Fetch gallery info (public data)
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||
@@ -55,7 +56,7 @@ export const GalleryPage: React.FC = () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
await login(slug!, password);
|
||||
await login(slug!, password, recaptchaToken);
|
||||
|
||||
// Track successful password entry
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
@@ -78,7 +79,7 @@ export const GalleryPage: React.FC = () => {
|
||||
// Show loading state
|
||||
if (isLoadingInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loading size="lg" text={t('gallery.loading')} />
|
||||
</div>
|
||||
@@ -88,14 +89,18 @@ export const GalleryPage: React.FC = () => {
|
||||
|
||||
// Show error state
|
||||
if (infoError) {
|
||||
// Check if it's an archived gallery error
|
||||
const errorMessage = (infoError as any)?.response?.data?.error;
|
||||
const isArchived = errorMessage?.includes('archived');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Logo at top */}
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={settingsData.branding_logo_url}
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
@@ -106,9 +111,11 @@ export const GalleryPage: React.FC = () => {
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')}
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
{t('errors.galleryNotFoundMessage')}
|
||||
{t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -140,13 +147,13 @@ export const GalleryPage: React.FC = () => {
|
||||
// Show expired state
|
||||
if (galleryInfo?.is_expired) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Logo at top */}
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={settingsData.branding_logo_url}
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
@@ -198,26 +205,26 @@ export const GalleryPage: React.FC = () => {
|
||||
|
||||
// Show login form
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-neutral-50 to-sand-100">
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
{settingsData?.branding_logo_url ? (
|
||||
<img
|
||||
src={settingsData.branding_logo_url}
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-20 w-auto object-contain mx-auto mb-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
|
||||
<Camera className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold text-neutral-900 mb-2">
|
||||
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text, #171717)' }}>
|
||||
{galleryInfo?.event_name}
|
||||
</h1>
|
||||
<div className="flex items-center justify-center text-neutral-600 text-sm">
|
||||
<div className="flex items-center justify-center text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{format(parseISO(galleryInfo!.event_date), 'MMMM d, yyyy')}
|
||||
</div>
|
||||
@@ -256,6 +263,11 @@ export const GalleryPage: React.FC = () => {
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<ReCaptcha
|
||||
onChange={setRecaptchaToken}
|
||||
onExpired={() => setRecaptchaToken(null)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
@@ -277,19 +289,19 @@ export const GalleryPage: React.FC = () => {
|
||||
{/* Legal Links */}
|
||||
<div className="text-center mt-6">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<a
|
||||
href="/impressum"
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</a>
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<a
|
||||
href="/datenschutz"
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { MaintenanceMode } from '../components/MaintenanceMode';
|
||||
|
||||
export const MaintenancePage: React.FC = () => {
|
||||
return <MaintenanceMode />;
|
||||
};
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Button, Input, Card } from '../../components/common';
|
||||
import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
import { authService } from '../../services/auth.service';
|
||||
import { getAuthToken } from '../../config/api';
|
||||
import { getAuthToken, api } from '../../config/api';
|
||||
|
||||
export const AdminLoginPage: React.FC = () => {
|
||||
const { isAuthenticated, login } = useAdminAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
@@ -19,6 +21,24 @@ export const AdminLoginPage: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['admin-login-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Check for session expired message
|
||||
useEffect(() => {
|
||||
if (searchParams.get('session') === 'expired') {
|
||||
toast.info('Your session has expired. Please log in again.');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Redirect if already authenticated or login successful
|
||||
if (isAuthenticated || loginSuccess) {
|
||||
@@ -55,7 +75,10 @@ export const AdminLoginPage: React.FC = () => {
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const response = await authService.adminLogin(formData);
|
||||
const response = await authService.adminLogin({
|
||||
...formData,
|
||||
recaptchaToken
|
||||
});
|
||||
login(response.token, response.user);
|
||||
toast.success('Login successful!');
|
||||
setLoginSuccess(true);
|
||||
@@ -93,15 +116,23 @@ export const AdminLoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-neutral-100 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-600 rounded-full mb-4">
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Admin Login</h1>
|
||||
<p className="text-neutral-600 mt-2">Sign in to manage your photo galleries</p>
|
||||
{settingsData?.branding_logo_url ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto mb-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>Admin Login</h1>
|
||||
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>Sign in to manage your photo galleries</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
@@ -178,6 +209,12 @@ export const AdminLoginPage: React.FC = () => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
<ReCaptcha
|
||||
onChange={setRecaptchaToken}
|
||||
onExpired={() => setRecaptchaToken(null)}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -192,10 +229,14 @@ export const AdminLoginPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-neutral-600 mt-8">
|
||||
<p className="text-center text-sm mt-8" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
Need help? Contact{' '}
|
||||
<a href="mailto:support@example.com" className="text-primary-600 hover:text-primary-700">
|
||||
support@example.com
|
||||
<a
|
||||
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
|
||||
className="hover:underline"
|
||||
style={{ color: 'var(--color-primary, #5C8762)' }}
|
||||
>
|
||||
{settingsData?.branding_support_email || 'support@example.com'}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -9,26 +9,36 @@ import {
|
||||
AlertCircle,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Eye,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { format, parseISO, isValid } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const ArchivesPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const navigate = useNavigate();
|
||||
// const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Helper function to safely format dates
|
||||
const formatDate = (dateString: string | null | undefined, formatStr: string): string => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = parseISO(dateString);
|
||||
return isValid(date) ? format(date, formatStr) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch archives from API
|
||||
const { data: archivesData, isLoading } = useQuery({
|
||||
queryKey: ['admin-archives', currentPage],
|
||||
@@ -54,7 +64,9 @@ export const ArchivesPage: React.FC = () => {
|
||||
return b.archiveSize - a.archiveSize;
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.archivedAt).getTime() - new Date(a.archivedAt).getTime();
|
||||
const dateA = a.archivedAt ? new Date(a.archivedAt).getTime() : 0;
|
||||
const dateB = b.archivedAt ? new Date(b.archivedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -107,9 +119,10 @@ export const ArchivesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (archive: typeof archives[0]) => {
|
||||
navigate(`/admin/archives/${archive.id}`);
|
||||
};
|
||||
// Details view not implemented yet
|
||||
// const handleViewDetails = (archive: typeof archives[0]) => {
|
||||
// navigate(`/admin/archives/${archive.id}`);
|
||||
// };
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -257,7 +270,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{archive.eventName}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Event date: {format(parseISO(archive.eventDate), 'MMM d, yyyy')}
|
||||
Event date: {formatDate(archive.eventDate, 'MMM d, yyyy') || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
@@ -266,9 +279,9 @@ export const ArchivesPage: React.FC = () => {
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
||||
<div>
|
||||
<p>{format(parseISO(archive.archivedAt), 'MMM d, yyyy')}</p>
|
||||
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || 'Processing...'}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{format(parseISO(archive.archivedAt), 'h:mm a')}
|
||||
{formatDate(archive.archivedAt, 'h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
@@ -280,6 +293,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Details view not implemented yet
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -288,6 +302,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
*/}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
|
||||
|
||||
@@ -87,6 +88,12 @@ export const EmailConfigPage: React.FC = () => {
|
||||
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<{ subject: string; htmlContent: string; textContent?: string }>({
|
||||
subject: '',
|
||||
htmlContent: '',
|
||||
textContent: ''
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// SMTP Configuration state
|
||||
@@ -205,6 +212,35 @@ export const EmailConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = async () => {
|
||||
if (!selectedTemplateKey || !editedTemplate) return;
|
||||
|
||||
// Generate sample data based on the template
|
||||
const sampleData: Record<string, string> = {
|
||||
event_name: 'John & Jane Wedding',
|
||||
event_date: 'December 25, 2024',
|
||||
password: 'wedding2024',
|
||||
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
|
||||
expiration_date: 'January 25, 2025',
|
||||
welcome_message: 'Thank you for celebrating our special day with us!',
|
||||
days_remaining: '30',
|
||||
admin_email: 'admin@example.com',
|
||||
host_email: 'host@example.com'
|
||||
};
|
||||
|
||||
try {
|
||||
const preview = await emailService.previewTemplate(selectedTemplateKey, sampleData);
|
||||
setPreviewData({
|
||||
subject: preview.subject,
|
||||
htmlContent: preview.body_html,
|
||||
textContent: preview.body_text
|
||||
});
|
||||
setShowPreview(true);
|
||||
} catch (error) {
|
||||
toast.error('Failed to preview template');
|
||||
}
|
||||
};
|
||||
|
||||
const renderVariableHelp = () => {
|
||||
const variables = editedTemplate.variables || [];
|
||||
return (
|
||||
@@ -479,15 +515,25 @@ export const EmailConfigPage: React.FC = () => {
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">Edit Template</h3>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveTemplate}
|
||||
isLoading={saveTemplateMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviewTemplate}
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveTemplate}
|
||||
isLoading={saveTemplateMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -533,6 +579,15 @@ export const EmailConfigPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Preview Modal */}
|
||||
<EmailPreviewModal
|
||||
isOpen={showPreview}
|
||||
onClose={() => setShowPreview(false)}
|
||||
subject={previewData.subject}
|
||||
htmlContent={previewData.htmlContent}
|
||||
textContent={previewData.textContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,16 +14,20 @@ import {
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
Upload
|
||||
Upload,
|
||||
Image,
|
||||
Key
|
||||
} from 'lucide-react';
|
||||
import { format, parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { PhotoUpload, EventCategoryManager } from '../../components/admin';
|
||||
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { photosService, AdminPhoto } from '../../services/photos.service';
|
||||
|
||||
export const EventDetailsPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -45,6 +49,17 @@ export const EventDetailsPage: React.FC = () => {
|
||||
});
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
|
||||
// Photo filters state
|
||||
const [photoFilters, setPhotoFilters] = useState({
|
||||
category_id: undefined as number | null | undefined,
|
||||
search: '',
|
||||
sort: 'date' as 'date' | 'name' | 'size',
|
||||
order: 'desc' as 'asc' | 'desc'
|
||||
});
|
||||
|
||||
// Fetch event details
|
||||
const { data: event, isLoading: eventLoading } = useQuery({
|
||||
@@ -61,6 +76,23 @@ export const EventDetailsPage: React.FC = () => {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Fetch photos when on photos tab
|
||||
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
|
||||
queryKey: ['admin-event-photos', id, photoFilters],
|
||||
queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters),
|
||||
enabled: !!id && activeTab === 'photos',
|
||||
});
|
||||
|
||||
// Fetch categories for the event
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['admin-event-categories', id],
|
||||
queryFn: async () => {
|
||||
const response = await eventsService.getEventCategories(parseInt(id!));
|
||||
return response || [];
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => eventsService.updateEvent(parseInt(id!), data),
|
||||
@@ -257,8 +289,51 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-neutral-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'overview'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('photos')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeTab === 'photos'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
Photos
|
||||
{event.photo_count && event.photo_count > 0 && (
|
||||
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 rounded-full">
|
||||
{event.photo_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'categories'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Categories
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Event Information */}
|
||||
@@ -361,34 +436,25 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<p className="text-sm text-neutral-600 mt-2">
|
||||
Share this link with guests. They'll need the password to access the gallery.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Photo Management */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">Photo Management</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowPhotoUpload(!showPhotoUpload)}
|
||||
>
|
||||
Upload Photos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPhotoUpload && (
|
||||
<div className="mb-4">
|
||||
<PhotoUpload
|
||||
eventId={parseInt(id!)}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
toast.success('Photos uploaded successfully');
|
||||
setShowPhotoUpload(false);
|
||||
}}
|
||||
/>
|
||||
{!event.is_archived && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Key className="w-4 h-4" />}
|
||||
onClick={() => setShowPasswordReset(true)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
Reset Gallery Password
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Photo Statistics */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Photo Statistics</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
@@ -403,18 +469,22 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Storage Location:</strong> /storage/events/active/{event.slug}/
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Photos are organized by categories you define.
|
||||
</p>
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">Categories</span>
|
||||
<span className="text-sm font-medium">{categories.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-neutral-200">
|
||||
<EventCategoryManager eventId={parseInt(id!)} />
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Image className="w-4 h-4" />}
|
||||
onClick={() => setActiveTab('photos')}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
Manage Photos
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -503,7 +573,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={() => toast.info('Archive download coming soon')}
|
||||
onClick={async () => {
|
||||
try {
|
||||
toast.info(`Downloading ${event.event_name} archive...`);
|
||||
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
|
||||
toast.success('Download started');
|
||||
} catch (error) {
|
||||
toast.error('Failed to download archive');
|
||||
}
|
||||
}}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
Download Archive
|
||||
@@ -514,6 +592,133 @@ export const EventDetailsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photos Tab */}
|
||||
{activeTab === 'photos' && (
|
||||
<div>
|
||||
{/* Photo Upload */}
|
||||
{showPhotoUpload && (
|
||||
<Card padding="md" className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">Upload Photos</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPhotoUpload(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<PhotoUpload
|
||||
eventId={parseInt(id!)}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
|
||||
toast.success('Photos uploaded successfully');
|
||||
setShowPhotoUpload(false);
|
||||
refetchPhotos();
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Photo Filters */}
|
||||
<PhotoFilters
|
||||
categories={categories}
|
||||
selectedCategory={photoFilters.category_id}
|
||||
searchTerm={photoFilters.search}
|
||||
sortBy={photoFilters.sort}
|
||||
sortOrder={photoFilters.order}
|
||||
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
|
||||
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
|
||||
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
|
||||
/>
|
||||
|
||||
{/* Actions Bar */}
|
||||
{!showPhotoUpload && (
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowPhotoUpload(true)}
|
||||
>
|
||||
Upload Photos
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo Grid */}
|
||||
{photosLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loading size="lg" text="Loading photos..." />
|
||||
</div>
|
||||
) : (
|
||||
<AdminPhotoGrid
|
||||
photos={photos}
|
||||
eventId={parseInt(id!)}
|
||||
onPhotoClick={(photo, index) => setSelectedPhoto({ photo, index })}
|
||||
onPhotosDeleted={() => {
|
||||
refetchPhotos();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Photo Viewer */}
|
||||
{selectedPhoto && (
|
||||
<AdminPhotoViewer
|
||||
photos={photos}
|
||||
initialIndex={selectedPhoto.index}
|
||||
eventId={parseInt(id!)}
|
||||
onClose={() => setSelectedPhoto(null)}
|
||||
onPhotoDeleted={() => {
|
||||
refetchPhotos();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
setSelectedPhoto(null);
|
||||
}}
|
||||
categories={categories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories Tab */}
|
||||
{activeTab === 'categories' && (
|
||||
<div>
|
||||
<Card padding="md">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">Photo Categories</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Organize your photos into categories. Categories help guests navigate and find specific types of photos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EventCategoryManager
|
||||
eventId={parseInt(id!)}
|
||||
/>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Tip:</strong> Categories are specific to each event. You can create custom categories like "Ceremony", "Reception", "Portraits", etc.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Reset Modal */}
|
||||
{showPasswordReset && (
|
||||
<PasswordResetModal
|
||||
eventName={event.event_name}
|
||||
onConfirm={async (sendEmail) => {
|
||||
const result = await eventsService.resetPassword(event.id, sendEmail);
|
||||
return result;
|
||||
}}
|
||||
onClose={() => setShowPasswordReset(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import { format, parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
|
||||
import { BulkArchiveModal } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import type { Event } from '../../types';
|
||||
@@ -28,6 +29,7 @@ export const EventsListPage: React.FC = () => {
|
||||
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
|
||||
// const [showFilters, setShowFilters] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
||||
|
||||
// Get filter from URL
|
||||
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
|
||||
@@ -63,6 +65,25 @@ export const EventsListPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Bulk archive mutation
|
||||
const bulkArchiveMutation = useMutation({
|
||||
mutationFn: eventsService.bulkArchiveEvents,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
setSelectedEvents([]);
|
||||
setShowBulkArchiveModal(false);
|
||||
|
||||
if (data.results.failed.length === 0) {
|
||||
toast.success(`Successfully archived ${data.results.successful.length} events`);
|
||||
} else {
|
||||
toast.warning(`Archived ${data.results.successful.length} events, ${data.results.failed.length} failed`);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to archive events');
|
||||
},
|
||||
});
|
||||
|
||||
// Filter and search events
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!data?.events) return [];
|
||||
@@ -237,10 +258,7 @@ export const EventsListPage: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Handle bulk archive
|
||||
toast.info('Bulk archive coming soon');
|
||||
}}
|
||||
onClick={() => setShowBulkArchiveModal(true)}
|
||||
>
|
||||
Archive Selected
|
||||
</Button>
|
||||
@@ -407,6 +425,15 @@ export const EventsListPage: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bulk Archive Modal */}
|
||||
<BulkArchiveModal
|
||||
isOpen={showBulkArchiveModal}
|
||||
onClose={() => setShowBulkArchiveModal(false)}
|
||||
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
|
||||
selectedEvents={filteredEvents.filter(e => selectedEvents.includes(e.id))}
|
||||
isLoading={bulkArchiveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next';
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general');
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// Fetch settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
@@ -60,6 +60,11 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
// Set the language if it's different from current
|
||||
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
|
||||
i18n.changeLanguage(settings.general_default_language);
|
||||
}
|
||||
|
||||
// Extract general settings
|
||||
setGeneralSettings({
|
||||
site_url: settings.general_site_url || '',
|
||||
|
||||
@@ -65,10 +65,15 @@ class AnalyticsService {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
// Check if analytics is initialized
|
||||
isInitialized() {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
// Track custom events
|
||||
track(eventName: string, eventData?: Record<string, any>) {
|
||||
if (!this.initialized || !window.umami) {
|
||||
console.warn('Umami Analytics not initialized');
|
||||
// Silently ignore if not initialized
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,7 +84,7 @@ class AnalyticsService {
|
||||
// Track page views manually
|
||||
trackPageView(url?: string, referrer?: string) {
|
||||
if (!this.initialized || !window.umami) {
|
||||
console.warn('Umami Analytics not initialized');
|
||||
// Silently ignore if not initialized
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { LoginResponse, GalleryAuthResponse } from '../types';
|
||||
|
||||
export const authService = {
|
||||
// Admin authentication
|
||||
async adminLogin(credentials: { email: string; password: string }): Promise<LoginResponse> {
|
||||
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
|
||||
// Backend expects 'username' field, but we accept email
|
||||
const response = await api.post<LoginResponse>('/api/auth/admin/login', {
|
||||
username: credentials.email,
|
||||
password: credentials.password
|
||||
password: credentials.password,
|
||||
recaptchaToken: credentials.recaptchaToken
|
||||
});
|
||||
|
||||
setAuthToken(response.data.token, true);
|
||||
@@ -20,10 +21,11 @@ export const authService = {
|
||||
},
|
||||
|
||||
// Gallery authentication
|
||||
async verifyGalleryPassword(slug: string, password: string): Promise<GalleryAuthResponse> {
|
||||
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||
const response = await api.post<GalleryAuthResponse>('/api/auth/gallery/verify', {
|
||||
slug,
|
||||
password,
|
||||
recaptchaToken
|
||||
});
|
||||
|
||||
setAuthToken(response.data.token, false);
|
||||
|
||||
@@ -80,6 +80,20 @@ export const eventsService = {
|
||||
await api.post(`/api/admin/events/${id}/archive`);
|
||||
},
|
||||
|
||||
// Bulk archive events (admin)
|
||||
async bulkArchiveEvents(eventIds: number[]): Promise<{
|
||||
message: string;
|
||||
results: {
|
||||
successful: Array<{ id: number; name: string }>;
|
||||
failed: Array<{ id: number; name: string; error: string }>;
|
||||
};
|
||||
}> {
|
||||
const response = await api.post('/api/admin/events/bulk-archive', {
|
||||
eventIds,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Extend event expiration (admin)
|
||||
async extendExpiration(id: number, days: number): Promise<Event> {
|
||||
const response = await api.post<Event>(`/api/events/${id}/extend`, {
|
||||
@@ -87,4 +101,16 @@ export const eventsService = {
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get event categories
|
||||
async getEventCategories(eventId: number): Promise<Array<{ id: number; name: string; slug: string }>> {
|
||||
const response = await api.get(`/api/admin/categories/event/${eventId}`);
|
||||
return response.data || [];
|
||||
},
|
||||
|
||||
// Reset event password
|
||||
async resetPassword(eventId: number, sendEmail: boolean = true): Promise<{ message: string; newPassword: string; emailSent: boolean }> {
|
||||
const response = await api.post(`/api/admin/events/${eventId}/reset-password`, { sendEmail });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -6,4 +6,5 @@ export { analyticsService } from './analytics.service';
|
||||
export { archiveService } from './archive.service';
|
||||
export { emailService } from './email.service';
|
||||
export { settingsService } from './settings.service';
|
||||
export { cmsService } from './cms.service';
|
||||
export { cmsService } from './cms.service';
|
||||
export { notificationsService } from './notifications.service';
|
||||
@@ -0,0 +1,101 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
type: string;
|
||||
actorType: string;
|
||||
actorName: string;
|
||||
eventName?: string;
|
||||
eventId?: number;
|
||||
metadata: Record<string, any>;
|
||||
createdAt: string;
|
||||
readAt?: string;
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationsResponse {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export const notificationsService = {
|
||||
// Get notifications
|
||||
async getNotifications(includeRead: boolean = false, limit: number = 20): Promise<NotificationsResponse> {
|
||||
const response = await api.get('/api/admin/notifications', {
|
||||
params: { includeRead, limit }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Mark single notification as read
|
||||
async markAsRead(notificationId: number): Promise<void> {
|
||||
await api.put(`/api/admin/notifications/${notificationId}/read`);
|
||||
},
|
||||
|
||||
// Mark all notifications as read
|
||||
async markAllAsRead(): Promise<void> {
|
||||
await api.put('/api/admin/notifications/read-all');
|
||||
},
|
||||
|
||||
// Clear old notifications
|
||||
async clearOldNotifications(): Promise<{ deletedCount: number }> {
|
||||
const response = await api.delete('/api/admin/notifications/clear-old');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Format notification message
|
||||
formatNotificationMessage(notification: Notification): string {
|
||||
switch (notification.type) {
|
||||
case 'event_created':
|
||||
return `New event "${notification.eventName}" was created`;
|
||||
case 'event_archived':
|
||||
return `Event "${notification.eventName}" was archived`;
|
||||
case 'photos_uploaded':
|
||||
return `${notification.metadata.count || 0} photos uploaded to "${notification.eventName}"`;
|
||||
case 'event_expiring':
|
||||
return `Event "${notification.eventName}" expires in ${notification.metadata.days || 0} days`;
|
||||
case 'event_expired':
|
||||
return `Event "${notification.eventName}" has expired`;
|
||||
case 'password_changed':
|
||||
return `Password changed by ${notification.actorName}`;
|
||||
case 'settings_updated':
|
||||
return `${notification.metadata.type || 'System'} settings updated`;
|
||||
case 'email_template_updated':
|
||||
return `Email template "${notification.metadata.template}" updated`;
|
||||
case 'bulk_download':
|
||||
return `${notification.metadata.count || 0} photos downloaded from "${notification.eventName}"`;
|
||||
case 'storage_warning':
|
||||
return `Storage usage at ${notification.metadata.percentage || 0}%`;
|
||||
default:
|
||||
return notification.metadata.message || 'System notification';
|
||||
}
|
||||
},
|
||||
|
||||
// Get notification icon and color
|
||||
getNotificationStyle(type: string): { icon: string; color: string } {
|
||||
switch (type) {
|
||||
case 'event_created':
|
||||
return { icon: 'Calendar', color: 'text-blue-600' };
|
||||
case 'event_archived':
|
||||
return { icon: 'Archive', color: 'text-green-600' };
|
||||
case 'photos_uploaded':
|
||||
return { icon: 'Image', color: 'text-purple-600' };
|
||||
case 'event_expiring':
|
||||
return { icon: 'AlertCircle', color: 'text-amber-600' };
|
||||
case 'event_expired':
|
||||
return { icon: 'Clock', color: 'text-red-600' };
|
||||
case 'password_changed':
|
||||
return { icon: 'Lock', color: 'text-indigo-600' };
|
||||
case 'settings_updated':
|
||||
return { icon: 'Settings', color: 'text-gray-600' };
|
||||
case 'email_template_updated':
|
||||
return { icon: 'Mail', color: 'text-teal-600' };
|
||||
case 'bulk_download':
|
||||
return { icon: 'Download', color: 'text-cyan-600' };
|
||||
case 'storage_warning':
|
||||
return { icon: 'Database', color: 'text-orange-600' };
|
||||
default:
|
||||
return { icon: 'Bell', color: 'text-gray-600' };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface AdminPhoto {
|
||||
id: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
url: string;
|
||||
thumbnail_url: string | null;
|
||||
type: string;
|
||||
category_id: number | null;
|
||||
category_name: string | null;
|
||||
category_slug: string | null;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
view_count?: number;
|
||||
download_count?: number;
|
||||
}
|
||||
|
||||
export interface PhotoFilters {
|
||||
category_id?: number | null;
|
||||
type?: string;
|
||||
search?: string;
|
||||
sort?: 'date' | 'name' | 'size';
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
class PhotosService {
|
||||
async getEventPhotos(eventId: number, filters?: PhotoFilters): Promise<AdminPhoto[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
if (filters.category_id !== undefined) {
|
||||
params.append('category_id', filters.category_id?.toString() || '');
|
||||
}
|
||||
if (filters.type) params.append('type', filters.type);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.sort) params.append('sort', filters.sort);
|
||||
if (filters.order) params.append('order', filters.order);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/api/admin/events/${eventId}/photos${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await api.get(url);
|
||||
|
||||
// Return photos as-is, URLs are already relative API paths
|
||||
return response.data.photos;
|
||||
}
|
||||
|
||||
async deletePhoto(eventId: number, photoId: number): Promise<void> {
|
||||
await api.delete(`/api/admin/events/${eventId}/photos/${photoId}`);
|
||||
}
|
||||
|
||||
async deletePhotos(eventId: number, photoIds: number[]): Promise<void> {
|
||||
await api.post(`/api/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
|
||||
}
|
||||
|
||||
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> {
|
||||
await api.patch(`/api/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
|
||||
}
|
||||
|
||||
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
|
||||
await api.post(`/api/admin/events/${eventId}/photos/bulk-update`, {
|
||||
photoIds,
|
||||
updates: { category_id: categoryId }
|
||||
});
|
||||
}
|
||||
|
||||
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
|
||||
const response = await api.get(`/api/admin/events/${eventId}/photos/${photoId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
export const photosService = new PhotosService();
|
||||
Reference in New Issue
Block a user