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:
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -44,7 +44,8 @@
|
||||
"up": "Nach oben",
|
||||
"select": "Auswählen",
|
||||
"selected": "Ausgewählt",
|
||||
"chunk": "Teil"
|
||||
"chunk": "Teil",
|
||||
"optional": "optional"
|
||||
},
|
||||
"upload": {
|
||||
"photoCategory": "Fotokategorie",
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"up": "Up",
|
||||
"select": "Select",
|
||||
"selected": "Selected",
|
||||
"chunk": "Chunk"
|
||||
"chunk": "Chunk",
|
||||
"optional": "optional"
|
||||
},
|
||||
"upload": {
|
||||
"photoCategory": "Photo Category",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -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