('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 = () => {
{archive.eventName}
- Event date: {format(parseISO(archive.eventDate), 'MMM d, yyyy')}
+ Event date: {formatDate(archive.eventDate, 'MMM d, yyyy') || 'N/A'}
@@ -266,9 +279,9 @@ export const ArchivesPage: React.FC = () => {
- {format(parseISO(archive.archivedAt), 'MMM d, yyyy')}
+ {formatDate(archive.archivedAt, 'MMM d, yyyy') || 'Processing...'}
- {format(parseISO(archive.archivedAt), 'h:mm a')}
+ {formatDate(archive.archivedAt, 'h:mm a')}
|
@@ -280,6 +293,7 @@ export const ArchivesPage: React.FC = () => {
+ {/* Details view not implemented yet
+ */}
)}
+
+ {/* Email Preview Modal */}
+ setShowPreview(false)}
+ subject={previewData.subject}
+ htmlContent={previewData.htmlContent}
+ textContent={previewData.textContent}
+ />
);
};
\ No newline at end of file
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index cb57131..b4f4722 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -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 = () => {
)}
- {/* Main Content Grid */}
-
+ {/* Tabs */}
+
+
+
+
+ {/* Tab Content */}
+ {activeTab === 'overview' && (
+
{/* Left Column - Details */}
{/* Event Information */}
@@ -361,34 +436,25 @@ export const EventDetailsPage: React.FC = () => {
Share this link with guests. They'll need the password to access the gallery.
-
-
- {/* Photo Management */}
-
-
- Photo Management
- }
- onClick={() => setShowPhotoUpload(!showPhotoUpload)}
- >
- Upload Photos
-
-
- {showPhotoUpload && (
-
- {
- queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
- toast.success('Photos uploaded successfully');
- setShowPhotoUpload(false);
- }}
- />
+ {!event.is_archived && (
+
+ }
+ onClick={() => setShowPasswordReset(true)}
+ className="w-full justify-center"
+ >
+ Reset Gallery Password
+
)}
+
+
+ {/* Photo Statistics */}
+
+ Photo Statistics
@@ -403,18 +469,22 @@ export const EventDetailsPage: React.FC = () => {
-
-
- Storage Location: /storage/events/active/{event.slug}/
-
-
- Photos are organized by categories you define.
-
+
+ Categories
+ {categories.length}
-
-
+
+ }
+ onClick={() => setActiveTab('photos')}
+ className="w-full justify-center"
+ >
+ Manage Photos
+
@@ -503,7 +573,15 @@ export const EventDetailsPage: React.FC = () => {
variant="outline"
size="sm"
leftIcon={ }
- 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 = () => {
)}
+ )}
+
+ {/* Photos Tab */}
+ {activeTab === 'photos' && (
+
+ {/* Photo Upload */}
+ {showPhotoUpload && (
+
+
+ Upload Photos
+ setShowPhotoUpload(false)}
+ >
+
+
+
+ {
+ queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
+ queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
+ toast.success('Photos uploaded successfully');
+ setShowPhotoUpload(false);
+ refetchPhotos();
+ }}
+ />
+
+ )}
+
+ {/* Photo Filters */}
+ setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
+ onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
+ onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
+ />
+
+ {/* Actions Bar */}
+ {!showPhotoUpload && (
+
+ }
+ onClick={() => setShowPhotoUpload(true)}
+ >
+ Upload Photos
+
+
+ )}
+
+ {/* Photo Grid */}
+ {photosLoading ? (
+
+
+
+ ) : (
+ setSelectedPhoto({ photo, index })}
+ onPhotosDeleted={() => {
+ refetchPhotos();
+ queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
+ }}
+ />
+ )}
+
+ {/* Photo Viewer */}
+ {selectedPhoto && (
+ setSelectedPhoto(null)}
+ onPhotoDeleted={() => {
+ refetchPhotos();
+ queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
+ setSelectedPhoto(null);
+ }}
+ categories={categories}
+ />
+ )}
+
+ )}
+
+ {/* Categories Tab */}
+ {activeTab === 'categories' && (
+
+
+
+ Photo Categories
+
+ Organize your photos into categories. Categories help guests navigate and find specific types of photos.
+
+
+
+
+
+
+
+ Tip: Categories are specific to each event. You can create custom categories like "Ceremony", "Reception", "Portraits", etc.
+
+
+
+
+ )}
+
+ {/* Password Reset Modal */}
+ {showPasswordReset && (
+ {
+ const result = await eventsService.resetPassword(event.id, sendEmail);
+ return result;
+ }}
+ onClose={() => setShowPasswordReset(false)}
+ />
+ )}
);
};
diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx
index d468f5f..f3035b5 100644
--- a/frontend/src/pages/admin/EventsListPage.tsx
+++ b/frontend/src/pages/admin/EventsListPage.tsx
@@ -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([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState(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 = () => {
{
- // Handle bulk archive
- toast.info('Bulk archive coming soon');
- }}
+ onClick={() => setShowBulkArchiveModal(true)}
>
Archive Selected
@@ -407,6 +425,15 @@ export const EventsListPage: React.FC = () => {
+
+ {/* Bulk Archive Modal */}
+ setShowBulkArchiveModal(false)}
+ onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
+ selectedEvents={filteredEvents.filter(e => selectedEvents.includes(e.id))}
+ isLoading={bulkArchiveMutation.isPending}
+ />
);
diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx
index 3b274bf..b32b283 100644
--- a/frontend/src/pages/admin/SettingsPage.tsx
+++ b/frontend/src/pages/admin/SettingsPage.tsx
@@ -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 || '',
diff --git a/frontend/src/services/analytics.service.ts b/frontend/src/services/analytics.service.ts
index 99043f2..9e3653c 100644
--- a/frontend/src/services/analytics.service.ts
+++ b/frontend/src/services/analytics.service.ts
@@ -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 ) {
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;
}
diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts
index 52bf2b0..563e880 100644
--- a/frontend/src/services/auth.service.ts
+++ b/frontend/src/services/auth.service.ts
@@ -3,11 +3,12 @@ import type { LoginResponse, GalleryAuthResponse } from '../types';
export const authService = {
// Admin authentication
- async adminLogin(credentials: { email: string; password: string }): Promise {
+ async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise {
// Backend expects 'username' field, but we accept email
const response = await api.post('/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 {
+ async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise {
const response = await api.post('/api/auth/gallery/verify', {
slug,
password,
+ recaptchaToken
});
setAuthToken(response.data.token, false);
diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts
index 833aa7f..f1ccb7f 100644
--- a/frontend/src/services/events.service.ts
+++ b/frontend/src/services/events.service.ts
@@ -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 {
const response = await api.post(`/api/events/${id}/extend`, {
@@ -87,4 +101,16 @@ export const eventsService = {
});
return response.data;
},
+
+ // Get event categories
+ async getEventCategories(eventId: number): Promise> {
+ 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;
+ },
};
\ No newline at end of file
diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts
index 009de23..0f398d7 100644
--- a/frontend/src/services/index.ts
+++ b/frontend/src/services/index.ts
@@ -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';
\ No newline at end of file
+export { cmsService } from './cms.service';
+export { notificationsService } from './notifications.service';
\ No newline at end of file
diff --git a/frontend/src/services/notifications.service.ts b/frontend/src/services/notifications.service.ts
new file mode 100644
index 0000000..58a9b99
--- /dev/null
+++ b/frontend/src/services/notifications.service.ts
@@ -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;
+ 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 {
+ const response = await api.get('/api/admin/notifications', {
+ params: { includeRead, limit }
+ });
+ return response.data;
+ },
+
+ // Mark single notification as read
+ async markAsRead(notificationId: number): Promise {
+ await api.put(`/api/admin/notifications/${notificationId}/read`);
+ },
+
+ // Mark all notifications as read
+ async markAllAsRead(): Promise {
+ 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' };
+ }
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/services/photos.service.ts b/frontend/src/services/photos.service.ts
new file mode 100644
index 0000000..24c0f94
--- /dev/null
+++ b/frontend/src/services/photos.service.ts
@@ -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 {
+ 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 {
+ await api.delete(`/api/admin/events/${eventId}/photos/${photoId}`);
+ }
+
+ async deletePhotos(eventId: number, photoIds: number[]): Promise {
+ await api.post(`/api/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
+ }
+
+ async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise {
+ await api.patch(`/api/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
+ }
+
+ async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise {
+ await api.post(`/api/admin/events/${eventId}/photos/bulk-update`, {
+ photoIds,
+ updates: { category_id: categoryId }
+ });
+ }
+
+ async downloadPhoto(eventId: number, photoId: number, filename: string): Promise {
+ 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();
\ No newline at end of file
diff --git a/storage/uploads/logos/logo-1751901737940.png b/storage/uploads/logos/logo-1751901737940.png
deleted file mode 100644
index 91e0c72..0000000
Binary files a/storage/uploads/logos/logo-1751901737940.png and /dev/null differ
diff --git a/storage/uploads/logos/logo-1751967884477.png b/storage/uploads/logos/logo-1751967884477.png
new file mode 100644
index 0000000..687b482
Binary files /dev/null and b/storage/uploads/logos/logo-1751967884477.png differ
diff --git a/test-maintenance.sh b/test-maintenance.sh
new file mode 100755
index 0000000..ccf5de2
--- /dev/null
+++ b/test-maintenance.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+# Test maintenance mode functionality
+
+echo "Testing maintenance mode implementation..."
+
+# First, let's check the current maintenance mode status
+echo -e "\n1. Checking current maintenance mode status:"
+curl -s http://localhost:3002/api/public/settings | jq '.general_maintenance_mode'
+
+# Test a public gallery endpoint
+echo -e "\n2. Testing public gallery endpoint (should get 503 if maintenance is on):"
+curl -s -o /dev/null -w "%{http_code}" http://localhost:3002/api/gallery/test-gallery/info
+
+# Test admin login (should always work)
+echo -e "\n\n3. Testing admin login endpoint (should always work):"
+curl -s -o /dev/null -w "%{http_code}" http://localhost:3002/api/admin/login
+
+echo -e "\n\nDone!"
\ No newline at end of file
|