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:
@@ -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();
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user