## 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
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
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';
|