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:
2025-07-08 17:07:40 +02:00
parent 2012b0bab9
commit d594d00227
79 changed files with 4570 additions and 329 deletions
+7 -2
View File
@@ -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;
}
+5 -3
View File
@@ -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);
+26
View File
@@ -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;
},
};
+2 -1
View File
@@ -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' };
}
}
};
+93
View File
@@ -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();