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';
|
||||
|
||||
Reference in New Issue
Block a user