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
@@ -13,13 +13,15 @@ interface AdminPhotoGridProps {
eventId: number;
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
onSelectionChange?: (selectedIds: number[]) => void;
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
photos,
eventId,
onPhotoClick,
onPhotosDeleted
onPhotosDeleted,
onSelectionChange
}) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
@@ -42,14 +44,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
const handleSelectAll = () => {
let newSelected: Set<number>;
if (selectedPhotos.size === photos.length) {
setSelectedPhotos(new Set());
newSelected = new Set();
} else {
setSelectedPhotos(new Set(photos.map(p => p.id)));
newSelected = new Set(photos.map(p => p.id));
}
setSelectedPhotos(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => {
@@ -91,6 +97,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onSelectionChange?.([]);
onPhotosDeleted();
} catch {
toast.error('Failed to delete photos');
@@ -114,6 +121,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
setIsSelectionMode(!isSelectionMode);
if (isSelectionMode) {
setSelectedPhotos(new Set());
onSelectionChange?.([]);
}
};
@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, RotateCcw, Eye, Code, AlertTriangle, Check } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
export const CssTemplateEditor: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeSlot, setActiveSlot] = useState(1);
const [localTemplates, setLocalTemplates] = useState<CssTemplate[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Fetch templates
const { data: templates, isLoading } = useQuery({
queryKey: ['css-templates'],
queryFn: () => cssTemplatesService.getTemplates()
});
// Update local state when templates load
useEffect(() => {
if (templates) {
setLocalTemplates(templates);
setHasChanges(false);
}
}, [templates]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
const template = localTemplates.find(t => t.slot_number === activeSlot);
if (!template) throw new Error('Template not found');
return cssTemplatesService.updateTemplate(activeSlot, {
name: template.name,
css_content: template.css_content,
is_enabled: template.is_enabled
});
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
setHasChanges(false);
if (result.warnings.length > 0) {
toast.warning(t('cssTemplates.sanitizationWarning', 'Some CSS patterns were blocked for security'));
} else {
toast.success(t('cssTemplates.saved', 'Template saved successfully'));
}
},
onError: (error: Error) => {
toast.error(error.message || t('cssTemplates.saveFailed', 'Failed to save template'));
}
});
// Reset mutation
const resetMutation = useMutation({
mutationFn: () => cssTemplatesService.resetToDefault(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
toast.success(t('cssTemplates.reset', 'Template reset to default'));
},
onError: (error: Error) => {
toast.error(error.message || t('cssTemplates.resetFailed', 'Failed to reset template'));
}
});
const activeTemplate = localTemplates.find(t => t.slot_number === activeSlot);
const updateLocalTemplate = (updates: Partial<CssTemplate>) => {
setLocalTemplates(prev =>
prev.map(t =>
t.slot_number === activeSlot ? { ...t, ...updates } : t
)
);
setHasChanges(true);
};
const handleReset = () => {
if (!confirm(t('cssTemplates.resetConfirm', 'Reset this template to the default? Your changes will be lost.'))) {
return;
}
resetMutation.mutate();
};
if (isLoading) {
return <Loading size="lg" text={t('common.loading', 'Loading...')} />;
}
return (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('cssTemplates.title', 'Custom CSS Templates')}
</h2>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-neutral-200 mb-6">
{[1, 2, 3].map(slot => {
const template = localTemplates.find(t => t.slot_number === slot);
return (
<button
key={slot}
onClick={() => setActiveSlot(slot)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeSlot === slot
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300'
}`}
>
{t('cssTemplates.template', 'Template')} {slot}
{template && (
<span className="ml-2 text-neutral-400">
({template.name})
</span>
)}
{template?.is_enabled && (
<Check className="w-3 h-3 inline ml-1 text-green-500" />
)}
</button>
);
})}
</div>
{activeTemplate && (
<div className="space-y-6">
{/* Template Name */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('cssTemplates.templateName', 'Template Name')}
</label>
<input
type="text"
value={activeTemplate.name}
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
maxLength={50}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Enable Toggle */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={activeTemplate.is_enabled}
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700">
{t('cssTemplates.enableTemplate', 'Enable this template')}
</span>
</label>
<p className="text-xs text-neutral-500 mt-1 ml-6">
{t('cssTemplates.enableHint', 'Enabled templates can be selected when creating events')}
</p>
</div>
{/* CSS Editor */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('cssTemplates.cssContent', 'CSS Content')}
</label>
<div className="relative">
<textarea
value={activeTemplate.css_content}
onChange={(e) => updateLocalTemplate({ css_content: e.target.value })}
className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-neutral-900 text-green-400"
spellCheck={false}
placeholder="/* Enter your custom CSS here */"
/>
<div className="absolute bottom-3 right-3 text-xs text-neutral-400">
{(activeTemplate.css_content?.length || 0).toLocaleString()} / 102,400 {t('common.characters', 'characters')}
</div>
</div>
<p className="text-xs text-neutral-500 mt-2">
{t('cssTemplates.cssHint', 'Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent')}
</p>
</div>
{/* Security Notice */}
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-800">
<strong>{t('cssTemplates.securityNotice', 'Security Notice')}:</strong>{' '}
{t('cssTemplates.securityText', 'CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.')}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t border-neutral-100">
<div className="flex items-center gap-3">
{activeSlot === 1 && activeTemplate.is_default && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={resetMutation.isPending}
leftIcon={<RotateCcw className="w-4 h-4" />}
>
{t('cssTemplates.resetToDefault', 'Reset to Default')}
</Button>
)}
</div>
<div className="flex items-center gap-3">
{hasChanges && (
<span className="text-sm text-amber-600">
{t('cssTemplates.unsavedChanges', 'Unsaved changes')}
</span>
)}
<Button
variant="primary"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !hasChanges}
isLoading={saveMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('cssTemplates.saveTemplate', 'Save Template')}
</Button>
</div>
</div>
{/* Last Updated */}
{activeTemplate.updated_at && (
<p className="text-xs text-neutral-400 text-right">
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
</p>
)}
</div>
)}
</div>
</Card>
);
};
export default CssTemplateEditor;
@@ -0,0 +1,315 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, AlertCircle, CheckCircle, Loader2, Type, Mail } from 'lucide-react';
import { Button, Input, Card } from '../common';
interface EventRenameDialogProps {
isOpen: boolean;
eventName: string;
eventId: number;
customerEmail?: string;
onClose: () => void;
onRename: (newName: string, resendEmail: boolean) => Promise<{
success: boolean;
data?: {
newSlug: string;
newShareLink: string;
filesRenamed: number;
};
error?: string;
}>;
onValidate: (newName: string) => Promise<{
valid: boolean;
newSlug?: string;
error?: string;
}>;
}
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
isOpen,
eventName,
eventId,
customerEmail,
onClose,
onRename,
onValidate
}) => {
const { t } = useTranslation();
const [newName, setNewName] = useState(eventName);
const [resendEmail, setResendEmail] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
newSlug?: string;
error?: string;
} | null>(null);
const [renameStatus, setRenameStatus] = useState<string | null>(null);
const [renameResult, setRenameResult] = useState<{
success: boolean;
newSlug?: string;
newShareLink?: string;
filesRenamed?: number;
error?: string;
} | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setNewName(eventName);
setResendEmail(false);
setValidationResult(null);
setRenameStatus(null);
setRenameResult(null);
}
}, [isOpen, eventName]);
// Debounced validation
useEffect(() => {
if (!isOpen || newName.trim() === eventName.trim() || newName.trim().length < 3) {
setValidationResult(null);
return;
}
const timeoutId = setTimeout(async () => {
setIsValidating(true);
try {
const result = await onValidate(newName.trim());
setValidationResult(result);
} catch (error) {
setValidationResult({ valid: false, error: 'Validation failed' });
} finally {
setIsValidating(false);
}
}, 500);
return () => clearTimeout(timeoutId);
}, [newName, eventName, isOpen, onValidate]);
const handleRename = async () => {
if (!validationResult?.valid) return;
setIsRenaming(true);
setRenameStatus(t('events.rename.validating', 'Validating new name...'));
try {
setRenameStatus(t('events.rename.renamingFiles', 'Renaming files...'));
const result = await onRename(newName.trim(), resendEmail);
if (result.success) {
setRenameStatus(t('events.rename.complete', 'Complete!'));
setRenameResult({
success: true,
newSlug: result.data?.newSlug,
newShareLink: result.data?.newShareLink,
filesRenamed: result.data?.filesRenamed
});
} else {
setRenameResult({
success: false,
error: result.error || 'Rename failed'
});
}
} catch (error: any) {
setRenameResult({
success: false,
error: error.message || 'Rename failed'
});
} finally {
setIsRenaming(false);
setRenameStatus(null);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-lg w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('events.rename.title', 'Rename Event')}
</h2>
<button
onClick={onClose}
disabled={isRenaming}
className="text-neutral-400 hover:text-neutral-600 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{renameResult?.success ? (
// Success state
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-600 flex-shrink-0" />
<div>
<p className="font-medium text-green-900">
{t('events.rename.success', 'Event renamed successfully!')}
</p>
{renameResult.filesRenamed !== undefined && renameResult.filesRenamed > 0 && (
<p className="text-sm text-green-700 mt-1">
{t('events.rename.filesRenamed', '{{count}} files updated', { count: renameResult.filesRenamed })}
</p>
)}
</div>
</div>
{renameResult.newShareLink && (
<div className="p-3 bg-neutral-50 rounded-lg">
<p className="text-sm font-medium text-neutral-700 mb-1">
{t('events.rename.newLink', 'New Gallery Link')}
</p>
<p className="text-sm text-neutral-900 break-all">{renameResult.newShareLink}</p>
</div>
)}
<div className="flex justify-end">
<Button variant="primary" onClick={onClose}>
{t('common.done', 'Done')}
</Button>
</div>
</div>
) : renameResult?.error ? (
// Error state
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-red-50 rounded-lg">
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
<div>
<p className="font-medium text-red-900">
{t('events.rename.failed', 'Rename failed')}
</p>
<p className="text-sm text-red-700 mt-1">{renameResult.error}</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRenameResult(null)}>
{t('common.retry', 'Retry')}
</Button>
<Button variant="primary" onClick={onClose}>
{t('common.close', 'Close')}
</Button>
</div>
</div>
) : isRenaming ? (
// Renaming in progress
<div className="space-y-4 py-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-10 h-10 text-primary-600 animate-spin" />
<p className="text-neutral-700 font-medium">{renameStatus}</p>
</div>
</div>
) : (
// Input form
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-600 mb-3">
{t('events.rename.currentName', 'Current name:')} <span className="font-medium">{eventName}</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.rename.newName', 'New Event Name')}
</label>
<Input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('events.rename.enterNewName', 'Enter new event name')}
leftIcon={<Type className="w-5 h-5 text-neutral-400" />}
autoFocus
/>
</div>
{/* New slug preview */}
{validationResult?.valid && validationResult.newSlug && (
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-sm text-green-800">
<span className="font-medium">{t('events.rename.newUrl', 'New URL:')}</span>{' '}
<span className="break-all">/gallery/{validationResult.newSlug}/...</span>
</p>
</div>
)}
{/* Validation status */}
{isValidating && (
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Loader2 className="w-4 h-4 animate-spin" />
{t('events.rename.checkingAvailability', 'Checking availability...')}
</div>
)}
{validationResult && !validationResult.valid && (
<div className="flex items-center gap-2 p-3 bg-red-50 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
<p className="text-sm text-red-700">{validationResult.error}</p>
</div>
)}
{/* Resend email option */}
{customerEmail && (
<div className="pt-2 border-t border-neutral-200">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={resendEmail}
onChange={(e) => setResendEmail(e.target.checked)}
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 flex items-center gap-1">
<Mail className="w-4 h-4" />
{t('events.rename.resendEmail', 'Resend invitation email with new gallery link')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.rename.emailTo', 'Send updated gallery access email to')} {customerEmail}
</p>
</div>
</label>
</div>
)}
{/* Warning */}
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<div className="flex gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-medium">{t('events.rename.warningTitle', 'Please note:')}</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>{t('events.rename.warning1', 'The gallery URL will change')}</li>
<li>{t('events.rename.warning2', 'Old URLs will automatically redirect to the new URL')}</li>
<li>{t('events.rename.warning3', 'Photo files may be renamed')}</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={handleRename}
disabled={
!validationResult?.valid ||
isValidating ||
newName.trim() === eventName.trim() ||
newName.trim().length < 3
}
>
{t('events.rename.confirm', 'Rename Event')}
</Button>
</div>
</div>
)}
</Card>
</div>
);
};
EventRenameDialog.displayName = 'EventRenameDialog';
@@ -0,0 +1,176 @@
import React, { useState } from 'react';
import { Download, FileText, FileSpreadsheet, Archive, FileJson, ChevronDown, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service';
interface PhotoExportMenuProps {
eventId: number;
selectedPhotoIds: number[];
filters?: FeedbackFilters;
disabled?: boolean;
}
const EXPORT_FORMATS = [
{
value: 'txt',
label: 'Filename List (TXT)',
description: 'Simple text list for Lightroom search',
icon: FileText
},
{
value: 'csv',
label: 'Filename List (CSV)',
description: 'Spreadsheet with metadata',
icon: FileSpreadsheet
},
{
value: 'xmp',
label: 'XMP Sidecar Files (ZIP)',
description: 'Import ratings into Lightroom/Bridge',
icon: Archive
},
{
value: 'json',
label: 'Metadata (JSON)',
description: 'Structured data for automation',
icon: FileJson
},
];
export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
eventId,
selectedPhotoIds,
filters,
disabled = false
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const exportMutation = useMutation({
mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options),
onSuccess: () => {
toast.success(t('export.success', 'Export downloaded successfully'));
setIsOpen(false);
},
onError: (error: Error) => {
toast.error(t('export.error', 'Export failed: ') + error.message);
}
});
const handleExport = (format: 'txt' | 'csv' | 'xmp' | 'json') => {
const options: ExportOptions = {
format,
options: {
filename_format: 'original',
include_rating: true,
include_label: true,
include_description: true,
include_keywords: true
}
};
// Use selected photos if any, otherwise use filters
if (selectedPhotoIds.length > 0) {
options.photo_ids = selectedPhotoIds;
} else if (filters) {
options.filter = filters;
}
exportMutation.mutate(options);
};
const hasSelection = selectedPhotoIds.length > 0;
const hasFilters = filters && (
filters.minRating !== null ||
filters.hasLikes ||
filters.hasFavorites ||
filters.hasComments
);
const isDisabled = disabled || (!hasSelection && !hasFilters);
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isDisabled || exportMutation.isPending}
className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
transition-colors
${isDisabled
? 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
}
`}
>
{exportMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t('export.button', 'Export')}
{hasSelection && (
<span className="bg-primary-100 text-primary-700 text-xs px-2 py-0.5 rounded-full">
{selectedPhotoIds.length}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isDisabled && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown Menu */}
<div className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-neutral-200 z-20">
<div className="p-2">
<p className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
{hasSelection
? t('export.exportSelected', 'Export {{count}} selected', { count: selectedPhotoIds.length })
: t('export.exportFiltered', 'Export filtered photos')
}
</p>
{EXPORT_FORMATS.map((format) => {
const Icon = format.icon;
return (
<button
key={format.value}
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
disabled={exportMutation.isPending}
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 text-left transition-colors"
>
<Icon className="w-5 h-5 text-neutral-500 mt-0.5" />
<div>
<div className="text-sm font-medium text-neutral-900">
{format.label}
</div>
<div className="text-xs text-neutral-500">
{format.description}
</div>
</div>
</button>
);
})}
</div>
</div>
</>
)}
{!hasSelection && !hasFilters && (
<p className="mt-1 text-xs text-neutral-500">
{t('export.hint', 'Select photos or apply filters to export')}
</p>
)}
</div>
);
};
export default PhotoExportMenu;
@@ -0,0 +1,202 @@
import React from 'react';
import { Star, Heart, Bookmark, MessageCircle, Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../common';
import { FeedbackFilters, FilterSummary } from '../../services/photos.service';
interface PhotoFilterPanelProps {
filters: FeedbackFilters;
onChange: (filters: FeedbackFilters) => void;
summary: FilterSummary | null;
isLoading?: boolean;
}
const RATING_OPTIONS = [
{ value: null, label: 'filter.allPhotos' },
{ value: 0.1, label: 'filter.anyRating' },
{ value: 1, label: 'filter.oneStarPlus' },
{ value: 2, label: 'filter.twoStarsPlus' },
{ value: 3, label: 'filter.threeStarsPlus' },
{ value: 4, label: 'filter.fourStarsPlus' },
{ value: 5, label: 'filter.fiveStarsOnly' },
];
export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
filters,
onChange,
summary,
isLoading = false
}) => {
const { t } = useTranslation();
const handleRatingChange = (value: number | null) => {
onChange({ ...filters, minRating: value });
};
const handleCheckboxChange = (field: 'hasLikes' | 'hasFavorites' | 'hasComments') => {
onChange({ ...filters, [field]: !filters[field] });
};
const handleLogicChange = (logic: 'AND' | 'OR') => {
onChange({ ...filters, logic });
};
const clearFilters = () => {
onChange({
minRating: null,
hasLikes: false,
hasFavorites: false,
hasComments: false,
logic: 'AND'
});
};
const hasActiveFilters = filters.minRating !== null ||
filters.hasLikes ||
filters.hasFavorites ||
filters.hasComments;
return (
<div className="bg-white rounded-lg border border-neutral-200 p-4 mb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-neutral-900 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('filter.feedbackFilters', 'Feedback Filters')}
</h3>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
leftIcon={<X className="w-3 h-3" />}
>
{t('filter.clear', 'Clear')}
</Button>
)}
</div>
<div className="space-y-4">
{/* Rating Filter */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<Star className="w-4 h-4 inline mr-1" />
{t('filter.rating', 'Rating')}
</label>
<select
value={filters.minRating ?? ''}
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
{RATING_OPTIONS.map(option => (
<option key={option.label} value={option.value ?? ''}>
{t(option.label, option.label.split('.').pop())}
</option>
))}
</select>
</div>
{/* Checkbox Filters */}
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasLikes || false}
onChange={() => handleCheckboxChange('hasLikes')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<Heart className="w-4 h-4 text-red-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasLikes', 'Has likes')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withLikes})</span>
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasFavorites || false}
onChange={() => handleCheckboxChange('hasFavorites')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<Bookmark className="w-4 h-4 text-yellow-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasFavorites', 'Has favorites')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withFavorites})</span>
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasComments || false}
onChange={() => handleCheckboxChange('hasComments')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<MessageCircle className="w-4 h-4 text-blue-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasComments', 'Has comments')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withComments})</span>
)}
</span>
</label>
</div>
{/* Logic Toggle */}
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600">{t('filter.combineWith', 'Combine with')}:</span>
<div className="flex rounded-lg border border-neutral-200 overflow-hidden">
<button
type="button"
onClick={() => handleLogicChange('AND')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'AND' || !filters.logic
? 'bg-primary-600 text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-50'
}`}
disabled={isLoading}
>
AND
</button>
<button
type="button"
onClick={() => handleLogicChange('OR')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'OR'
? 'bg-primary-600 text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-50'
}`}
disabled={isLoading}
>
OR
</button>
</div>
</div>
)}
{/* Summary */}
{summary && (
<div className="pt-2 border-t border-neutral-100 text-sm text-neutral-600">
{t('filter.showingPhotos', 'Total photos')}: {summary.total}
{summary.withRatings > 0 && (
<span className="ml-2">
| {t('filter.withRatings', 'With ratings')}: {summary.withRatings}
</span>
)}
</div>
)}
</div>
</div>
);
};
export default PhotoFilterPanel;
+4
View File
@@ -31,3 +31,7 @@ export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager';
export { EventRenameDialog } from './EventRenameDialog';
export { PhotoFilterPanel } from './PhotoFilterPanel';
export { PhotoExportMenu } from './PhotoExportMenu';
export { CssTemplateEditor } from './CssTemplateEditor';
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { cssTemplatesService } from '../services/cssTemplates.service';
/**
* Hook to load and inject custom CSS for a gallery
* @param slug - Gallery slug
* @returns Object with customCss content and loading state
*/
export function useGalleryCustomCss(slug: string) {
const [customCss, setCustomCss] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slug) {
setLoading(false);
return;
}
const loadCustomCss = async () => {
try {
setLoading(true);
setError(null);
const css = await cssTemplatesService.getGalleryCss(slug);
setCustomCss(css);
} catch (err) {
console.error('Failed to load custom CSS:', err);
setError('Failed to load custom styles');
} finally {
setLoading(false);
}
};
loadCustomCss();
}, [slug]);
// Inject CSS into document
useEffect(() => {
if (!customCss) return;
// Remove any existing custom CSS
const existingStyle = document.getElementById('gallery-custom-css');
if (existingStyle) {
existingStyle.remove();
}
// Create and inject new style element
const styleElement = document.createElement('style');
styleElement.id = 'gallery-custom-css';
styleElement.textContent = customCss;
document.head.appendChild(styleElement);
// Cleanup on unmount or when CSS changes
return () => {
const existing = document.getElementById('gallery-custom-css');
if (existing) {
existing.remove();
}
};
}, [customCss]);
return { customCss, loading, error };
}
export default useGalleryCustomCss;
+2 -1
View File
@@ -44,7 +44,8 @@
"up": "Nach oben",
"select": "Auswählen",
"selected": "Ausgewählt",
"chunk": "Teil"
"chunk": "Teil",
"optional": "optional"
},
"upload": {
"photoCategory": "Fotokategorie",
+2 -1
View File
@@ -44,7 +44,8 @@
"up": "Up",
"select": "Select",
"selected": "Selected",
"chunk": "Chunk"
"chunk": "Chunk",
"optional": "optional"
},
"upload": {
"photoCategory": "Photo Category",
+29 -8
View File
@@ -150,6 +150,16 @@ export const CreateEventPage: React.FC = () => {
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Fetch public settings for field requirements
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => settingsService.getPublicSettings()
});
// Get field requirements (default to true if not set)
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
const createMutation = useMutation({
mutationFn: eventsService.createEvent,
onSuccess: (data) => {
@@ -198,15 +208,26 @@ export const CreateEventPage: React.FC = () => {
newErrors.event_name = t('validation.eventNameRequired');
}
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
// Conditional validation based on settings
if (requireCustomerEmail) {
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
newErrors.customer_email = t('validation.invalidEmailFormat');
}
} else if (formData.customer_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
// Still validate format if value is provided, even if optional
newErrors.customer_email = t('validation.invalidEmailFormat');
}
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
if (requireAdminEmail) {
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
} else if (formData.admin_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
// Still validate format if value is provided, even if optional
newErrors.admin_email = t('validation.invalidEmailFormat');
}
@@ -392,7 +413,7 @@ export const CreateEventPage: React.FC = () => {
{/* Customer Email */}
<div>
<label htmlFor="customer_email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.hostEmail')}
{requireCustomerEmail ? t('events.hostEmail') : `${t('events.hostEmail')} (${t('common.optional')})`}
</label>
<Input
id="customer_email"
@@ -411,7 +432,7 @@ export const CreateEventPage: React.FC = () => {
{/* Admin Email */}
<div>
<label htmlFor="admin_email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.adminNotificationEmail')}
{requireAdminEmail ? t('events.adminNotificationEmail') : `${t('events.adminNotificationEmail')} (${t('common.optional')})`}
</label>
<Input
id="admin_email"
@@ -128,6 +128,17 @@ export const CreateEventPageEnhanced: React.FC = () => {
queryFn: () => settingsService.getAllSettings()
});
// Fetch public settings for field requirements
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => settingsService.getPublicSettings()
});
// Get field requirements (default to true if not set)
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
// Update default expiration days when settings are loaded
useEffect(() => {
if (settings?.general_default_expiration_days) {
@@ -184,19 +195,30 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.event_date = t('validation.eventDateRequired');
}
if (!formData.customer_name) {
// Conditional validation based on settings
if (requireCustomerName && !formData.customer_name) {
newErrors.customer_name = t('validation.hostNameRequired');
}
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
if (requireCustomerEmail) {
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
newErrors.customer_email = t('validation.invalidEmailFormat');
}
} else if (formData.customer_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
// Still validate format if value is provided, even if optional
newErrors.customer_email = t('validation.invalidEmailFormat');
}
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
if (requireAdminEmail) {
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
} else if (formData.admin_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
// Still validate format if value is provided, even if optional
newErrors.admin_email = t('validation.invalidEmailFormat');
}
@@ -470,7 +492,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('events.hostName')}
label={requireCustomerName ? t('events.hostName') : `${t('events.hostName')} (${t('common.optional')})`}
placeholder={t('events.hostNamePlaceholder')}
value={formData.customer_name}
onChange={handleInputChange('customer_name')}
@@ -480,7 +502,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
<Input
type="email"
label={t('events.hostEmail')}
label={requireCustomerEmail ? t('events.hostEmail') : `${t('events.hostEmail')} (${t('common.optional')})`}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.customer_email}
onChange={handleInputChange('customer_email')}
@@ -491,7 +513,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
<Input
type="email"
label={t('events.adminEmail')}
label={requireAdminEmail ? t('events.adminEmail') : `${t('events.adminEmail')} (${t('common.optional')})`}
placeholder={t('events.adminEmailPlaceholder')}
value={formData.admin_email}
onChange={handleInputChange('admin_email')}
+77 -17
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
import {
ArrowLeft,
ExternalLink,
Calendar,
@@ -20,20 +20,21 @@ import {
MessageSquare,
Lock,
Eye,
EyeOff
EyeOff,
Type
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../services/photos.service';
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
@@ -167,6 +168,7 @@ export const EventDetailsPage: React.FC = () => {
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
@@ -178,6 +180,16 @@ export const EventDetailsPage: React.FC = () => {
order: 'desc' as 'asc' | 'desc'
});
// Feedback filters state for export
const [feedbackFilters, setFeedbackFilters] = useState<FeedbackFilters>({
minRating: null,
hasLikes: false,
hasFavorites: false,
hasComments: false,
logic: 'AND'
});
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
queryKey: ['admin-event', id],
@@ -208,6 +220,13 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id && (activeTab === 'photos' || isEditing),
});
// Fetch filter summary for feedback filters
const { data: filterSummary } = useQuery({
queryKey: ['admin-event-filter-summary', id],
queryFn: () => photosService.getFilterSummary(parseInt(id!)),
enabled: !!id && activeTab === 'photos',
});
const mediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
photos.forEach((p: any) => {
@@ -571,6 +590,14 @@ export const EventDetailsPage: React.FC = () => {
>
{t('common.edit')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Type className="w-4 h-4" />}
onClick={() => setShowRenameDialog(true)}
>
{t('events.rename.button', 'Rename')}
</Button>
{feedbackSettings?.feedback_enabled && (
<Button
variant="outline"
@@ -1265,18 +1292,26 @@ export const EventDetailsPage: React.FC = () => {
showMediaFilter={showMediaFilter}
/>
{/* Feedback Filter Panel for Export */}
<PhotoFilterPanel
filters={feedbackFilters}
onChange={setFeedbackFilters}
summary={filterSummary || null}
isLoading={photosLoading}
/>
{/* Actions Bar */}
<div className="mb-4 flex justify-between items-center">
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(true)}
>
{t('events.uploadPhotos')}
</Button>
{event.source_mode === 'reference' && (
<div className="ml-3">
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
<div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(true)}
>
{t('events.uploadPhotos')}
</Button>
{event.source_mode === 'reference' && (
<Button
variant="outline"
size="sm"
@@ -1284,8 +1319,13 @@ export const EventDetailsPage: React.FC = () => {
>
{t('events.importExternal', 'Import from External Folder')}
</Button>
</div>
)}
)}
</div>
<PhotoExportMenu
eventId={parseInt(id!)}
selectedPhotoIds={selectedPhotoIds}
filters={feedbackFilters}
/>
</div>
{/* Photo Grid */}
@@ -1302,6 +1342,7 @@ export const EventDetailsPage: React.FC = () => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
}}
onSelectionChange={setSelectedPhotoIds}
/>
)}
@@ -1412,6 +1453,25 @@ export const EventDetailsPage: React.FC = () => {
</div>
)}
{/* Event Rename Dialog */}
<EventRenameDialog
isOpen={showRenameDialog}
eventName={event.event_name}
eventId={event.id}
customerEmail={event.customer_email}
onClose={() => setShowRenameDialog(false)}
onRename={async (newName, resendEmail) => {
const result = await eventsService.renameEvent(event.id, newName, resendEmail);
if (result.success) {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('events.rename.success', 'Event renamed successfully!'));
}
return result;
}}
onValidate={(newName) => eventsService.validateRename(event.id, newName)}
/>
</div>
);
};
+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>
);
};
@@ -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();