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
+171 -1
View File
@@ -19,9 +19,11 @@ import { toast } from 'react-toastify';
import { Button, Card, Input, Loading } from '../../components/common';
import { CategoryManager } from '../../components/admin/CategoryManager';
import { WordFilterManager } from '../../components/admin/WordFilterManager';
import { CssTemplateEditor } from '../../components/admin/CssTemplateEditor';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
import { adminService } from '../../services/admin.service';
import { authService } from '../../services/auth.service';
import { useTranslation } from 'react-i18next';
import { useAdminAuth } from '../../contexts';
@@ -58,7 +60,7 @@ const toNumber = (value: unknown, defaultValue: number): number => {
};
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'events' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation' | 'styling'>('general');
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { updateUserProfile } = useAdminAuth();
@@ -141,6 +143,13 @@ export const SettingsPage: React.FC = () => {
umami_share_url: ''
});
// Event creation settings state
const [eventSettings, setEventSettings] = useState({
event_require_customer_name: true,
event_require_customer_email: true,
event_require_admin_email: true
});
const [softLimitGb, setSoftLimitGb] = useState<number | ''>('');
const [softLimitDirty, setSoftLimitDirty] = useState(false);
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
@@ -203,6 +212,13 @@ export const SettingsPage: React.FC = () => {
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''
});
// Extract event creation settings
setEventSettings({
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
});
}
}, [settings, i18n]);
@@ -335,6 +351,25 @@ export const SettingsPage: React.FC = () => {
}
});
const saveEventSettingsMutation = useMutation({
mutationFn: async () => {
// Convert to the format expected by the API
const settingsData: Record<string, any> = {};
Object.entries(eventSettings).forEach(([key, value]) => {
settingsData[key] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const updateAdminProfileMutation = useMutation({
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
onSuccess: (updatedUser) => {
@@ -550,6 +585,16 @@ export const SettingsPage: React.FC = () => {
>
{t('settings.general.title')}
</button>
<button
onClick={() => setActiveTab('events')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'events'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.events.title', 'Event Creation')}
</button>
<button
onClick={() => setActiveTab('status')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
@@ -600,6 +645,16 @@ export const SettingsPage: React.FC = () => {
>
{t('settings.moderation.title', 'Moderation')}
</button>
<button
onClick={() => setActiveTab('styling')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'styling'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.styling.title', 'Custom CSS')}
</button>
</nav>
</div>
@@ -880,6 +935,114 @@ export const SettingsPage: React.FC = () => {
</div>
)}
{/* Event Creation Settings Tab */}
{activeTab === 'events' && (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
{t('settings.events.requiredFields', 'Required Fields')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('settings.events.requiredFieldsDescription', 'Configure which contact fields are required when creating new events.')}
</p>
<div className="space-y-4">
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_customer_name}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_name: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireCustomerName', 'Require customer name')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireCustomerNameHelp', 'Customer name must be provided for new events')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_customer_email}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_email: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireCustomerEmail', 'Require customer email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireCustomerEmailHelp', 'Customer email must be provided for new events')}
</p>
{!eventSettings.event_require_customer_email && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.customerEmailWarning', 'Required for sending gallery invitations')}
</p>
)}
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_admin_email}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_admin_email: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireAdminEmail', 'Require admin email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireAdminEmailHelp', 'Admin email must be provided for new events')}
</p>
{!eventSettings.event_require_admin_email && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.adminEmailWarning', 'Required for receiving event notifications')}
</p>
)}
</div>
</label>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveEventSettingsMutation.mutate()}
isLoading={saveEventSettingsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.events.saveSettings', 'Save Event Settings')}
</Button>
</div>
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.events.noteTitle', 'Note')}</p>
<p>
{t('settings.events.noteText', 'These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields.')}
</p>
</div>
</div>
</Card>
</div>
)}
{/* System Status Tab */}
{activeTab === 'status' && (
<div className="space-y-6">
@@ -1664,6 +1827,13 @@ export const SettingsPage: React.FC = () => {
<WordFilterManager />
</div>
)}
{/* Custom CSS Templates Tab */}
{activeTab === 'styling' && (
<div className="space-y-6">
<CssTemplateEditor />
</div>
)}
</div>
);
};