feat: implement 4 new features with bug fixes and refactoring plan

## Features Implemented

### 1. Event Rename Functionality
- Add EventRenameDialog component with live slug preview
- Create eventRenameService for safe event renaming
- Add slug_redirects table for old URL redirects
- Support optional email notification on rename
- Fix date formatting in slug (YYYY-MM-DD format)

### 2. Optional Event Contact Fields
- Add settings to make customer name/email/admin email optional
- Create migration for field requirement settings
- Update CreateEventPage forms to show "(optional)" labels
- Fix boolean parsing in publicSettings.js

### 3. Photo Filtering & Export
- Add PhotoFilterPanel with rating/likes/favorites/comments filters
- Create PhotoExportMenu with ZIP/metadata/XMP export options
- Add photoExportService with Lightroom XMP sidecar generation
- Create photoFilterBuilder utility for query construction
- Wire up photo selection to export button via onSelectionChange

### 4. Custom CSS Gallery Templates
- Add CssTemplateEditor component with 3 template slots
- Create cssSanitizer utility blocking XSS vectors
- Add gallery CSS endpoint for template delivery
- Integrate Custom CSS tab into Settings page
- Include default "Elegant Dark" template

## Bug Fixes
- Fix event rename date formatting (was showing full Date string)
- Fix common.optional translation key missing in locales
- Fix photo export button staying disabled when photos selected
- Fix authService import missing in SettingsPage

## Documentation
- Add comprehensive REFACTORING_PLAN.md for codebase improvement
- Add test specification documents for all features
- Add feature documentation for CSS templates

## Database Migrations
- 049_add_slug_redirects.js
- 050_add_optional_event_fields_settings.js
- 051_add_photo_filter_indexes.js
- 052_add_css_templates.js
This commit is contained in:
Paul Nothaft
2026-01-02 09:56:19 +01:00
parent 64ceb20431
commit 77a4bfd499
38 changed files with 7989 additions and 58 deletions
@@ -0,0 +1,92 @@
import { api } from '../config/api';
export interface CssTemplate {
id: number;
slot_number: number;
name: string;
css_content: string;
is_enabled: boolean;
is_default: boolean;
created_at: string;
updated_at: string;
}
export interface CssTemplateUpdate {
name?: string;
css_content?: string;
is_enabled?: boolean;
}
export interface EnabledTemplate {
id: number;
name: string;
slot_number: number;
}
class CssTemplatesService {
/**
* Get all CSS templates
*/
async getTemplates(): Promise<CssTemplate[]> {
const response = await api.get('/admin/css-templates');
return response.data.templates;
}
/**
* Get a specific template by slot number
*/
async getTemplate(slotNumber: number): Promise<CssTemplate> {
const response = await api.get(`/admin/css-templates/${slotNumber}`);
return response.data.template;
}
/**
* Get only enabled templates (for event form dropdown)
*/
async getEnabledTemplates(): Promise<EnabledTemplate[]> {
const response = await api.get('/admin/css-templates/enabled');
return response.data.templates;
}
/**
* Update a template
*/
async updateTemplate(
slotNumber: number,
updates: CssTemplateUpdate
): Promise<{ template: CssTemplate; warnings: string[] }> {
const response = await api.put(`/admin/css-templates/${slotNumber}`, updates);
return {
template: response.data.template,
warnings: response.data.sanitization_warnings || []
};
}
/**
* Reset template 1 to default
*/
async resetToDefault(): Promise<CssTemplate> {
const response = await api.post('/admin/css-templates/1/reset');
return response.data.template;
}
/**
* Get CSS template for a gallery (public endpoint)
*/
async getGalleryCss(slug: string): Promise<string | null> {
try {
const response = await api.get(`/gallery/${slug}/css-template`, {
responseType: 'text'
});
if (response.status === 204) {
return null;
}
return response.data;
} catch (error) {
console.error('Failed to load gallery CSS:', error);
return null;
}
}
}
export const cssTemplatesService = new CssTemplatesService();
+30
View File
@@ -160,4 +160,34 @@ export const eventsService = {
const response = await api.post(`/admin/events/${eventId}/resend-email`);
return response.data;
},
// Validate rename
async validateRename(eventId: number, newEventName: string): Promise<{
valid: boolean;
newSlug?: string;
error?: string;
}> {
const response = await api.post(`/admin/events/${eventId}/validate-rename`, { newEventName });
return response.data;
},
// Rename event
async renameEvent(eventId: number, newEventName: string, resendEmail: boolean = false): Promise<{
success: boolean;
message?: string;
data?: {
eventId: number;
oldName: string;
newName: string;
oldSlug: string;
newSlug: string;
newShareLink: string;
emailSent: boolean;
filesRenamed: number;
};
error?: string;
}> {
const response = await api.post(`/admin/events/${eventId}/rename`, { newEventName, resendEmail });
return response.data;
},
};
+128
View File
@@ -199,6 +199,134 @@ class PhotosService {
shouldUseChunkedUpload(fileSize: number): boolean {
return fileSize > 100 * 1024 * 1024; // 100MB threshold
}
// ============================================
// Photo Filtering & Export Methods
// ============================================
async getFilteredPhotos(
eventId: number,
filters: FeedbackFilters
): Promise<FilteredPhotosResponse> {
const params = new URLSearchParams();
if (filters.minRating !== undefined && filters.minRating !== null) {
params.append('min_rating', filters.minRating.toString());
}
if (filters.hasLikes) params.append('has_likes', 'true');
if (filters.hasFavorites) params.append('has_favorites', 'true');
if (filters.hasComments) params.append('has_comments', 'true');
if (filters.categoryId) params.append('category_id', filters.categoryId.toString());
if (filters.logic) params.append('logic', filters.logic);
if (filters.sort) params.append('sort', filters.sort);
if (filters.order) params.append('order', filters.order);
if (filters.page) params.append('page', filters.page.toString());
if (filters.limit) params.append('limit', filters.limit.toString());
const queryString = params.toString();
const url = `/admin/photo-export/${eventId}/filtered${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url);
return response.data.data;
}
async getFilterSummary(eventId: number): Promise<FilterSummary> {
const response = await api.get(`/admin/photo-export/${eventId}/filter-summary`);
return response.data.data;
}
async exportPhotos(
eventId: number,
options: ExportOptions
): Promise<void> {
const response = await api.post(
`/admin/photo-export/${eventId}/export`,
options,
{ responseType: 'blob' }
);
// Get filename from Content-Disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = `export_${Date.now()}`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Download the file
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);
}
async getExportFormats(): Promise<ExportFormat[]> {
const response = await api.get('/admin/photo-export/export-formats');
return response.data.data;
}
}
// Types for filtering and export
export interface FeedbackFilters {
minRating?: number | null;
maxRating?: number | null;
hasLikes?: boolean;
minLikes?: number;
hasFavorites?: boolean;
minFavorites?: number;
hasComments?: boolean;
categoryId?: number;
logic?: 'AND' | 'OR';
sort?: 'rating' | 'likes' | 'favorites' | 'date' | 'filename';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface FilterSummary {
total: number;
withRatings: number;
withLikes: number;
withFavorites: number;
withComments: number;
}
export interface FilteredPhotosResponse {
photos: AdminPhoto[];
pagination: {
total: number;
filtered: number;
page: number;
limit: number;
pages: number;
};
summary: FilterSummary;
}
export interface ExportOptions {
photo_ids?: number[];
filter?: FeedbackFilters;
format: 'txt' | 'csv' | 'xmp' | 'json';
options?: {
filename_format?: 'original' | 'picpeak';
separator?: 'newline' | 'comma' | 'semicolon';
include_rating?: boolean;
include_label?: boolean;
include_description?: boolean;
include_keywords?: boolean;
};
}
export interface ExportFormat {
value: string;
label: string;
description: string;
}
export const photosService = new PhotosService();