feat: implement 4 new features with bug fixes and refactoring plan

## Features Implemented

### 1. Event Rename Functionality
- Add EventRenameDialog component with live slug preview
- Create eventRenameService for safe event renaming
- Add slug_redirects table for old URL redirects
- Support optional email notification on rename
- Fix date formatting in slug (YYYY-MM-DD format)

### 2. Optional Event Contact Fields
- Add settings to make customer name/email/admin email optional
- Create migration for field requirement settings
- Update CreateEventPage forms to show "(optional)" labels
- Fix boolean parsing in publicSettings.js

### 3. Photo Filtering & Export
- Add PhotoFilterPanel with rating/likes/favorites/comments filters
- Create PhotoExportMenu with ZIP/metadata/XMP export options
- Add photoExportService with Lightroom XMP sidecar generation
- Create photoFilterBuilder utility for query construction
- Wire up photo selection to export button via onSelectionChange

### 4. Custom CSS Gallery Templates
- Add CssTemplateEditor component with 3 template slots
- Create cssSanitizer utility blocking XSS vectors
- Add gallery CSS endpoint for template delivery
- Integrate Custom CSS tab into Settings page
- Include default "Elegant Dark" template

## Bug Fixes
- Fix event rename date formatting (was showing full Date string)
- Fix common.optional translation key missing in locales
- Fix photo export button staying disabled when photos selected
- Fix authService import missing in SettingsPage

## Documentation
- Add comprehensive REFACTORING_PLAN.md for codebase improvement
- Add test specification documents for all features
- Add feature documentation for CSS templates

## Database Migrations
- 049_add_slug_redirects.js
- 050_add_optional_event_fields_settings.js
- 051_add_photo_filter_indexes.js
- 052_add_css_templates.js
This commit is contained in:
Paul Nothaft
2026-01-02 09:56:19 +01:00
parent 64ceb20431
commit 77a4bfd499
38 changed files with 7989 additions and 58 deletions
@@ -13,13 +13,15 @@ interface AdminPhotoGridProps {
eventId: number;
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
onSelectionChange?: (selectedIds: number[]) => void;
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
photos,
eventId,
onPhotoClick,
onPhotosDeleted
onPhotosDeleted,
onSelectionChange
}) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
@@ -42,14 +44,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
const handleSelectAll = () => {
let newSelected: Set<number>;
if (selectedPhotos.size === photos.length) {
setSelectedPhotos(new Set());
newSelected = new Set();
} else {
setSelectedPhotos(new Set(photos.map(p => p.id)));
newSelected = new Set(photos.map(p => p.id));
}
setSelectedPhotos(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => {
@@ -91,6 +97,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onSelectionChange?.([]);
onPhotosDeleted();
} catch {
toast.error('Failed to delete photos');
@@ -114,6 +121,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
setIsSelectionMode(!isSelectionMode);
if (isSelectionMode) {
setSelectedPhotos(new Set());
onSelectionChange?.([]);
}
};
@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, RotateCcw, Eye, Code, AlertTriangle, Check } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
export const CssTemplateEditor: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeSlot, setActiveSlot] = useState(1);
const [localTemplates, setLocalTemplates] = useState<CssTemplate[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Fetch templates
const { data: templates, isLoading } = useQuery({
queryKey: ['css-templates'],
queryFn: () => cssTemplatesService.getTemplates()
});
// Update local state when templates load
useEffect(() => {
if (templates) {
setLocalTemplates(templates);
setHasChanges(false);
}
}, [templates]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
const template = localTemplates.find(t => t.slot_number === activeSlot);
if (!template) throw new Error('Template not found');
return cssTemplatesService.updateTemplate(activeSlot, {
name: template.name,
css_content: template.css_content,
is_enabled: template.is_enabled
});
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
setHasChanges(false);
if (result.warnings.length > 0) {
toast.warning(t('cssTemplates.sanitizationWarning', 'Some CSS patterns were blocked for security'));
} else {
toast.success(t('cssTemplates.saved', 'Template saved successfully'));
}
},
onError: (error: Error) => {
toast.error(error.message || t('cssTemplates.saveFailed', 'Failed to save template'));
}
});
// Reset mutation
const resetMutation = useMutation({
mutationFn: () => cssTemplatesService.resetToDefault(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
toast.success(t('cssTemplates.reset', 'Template reset to default'));
},
onError: (error: Error) => {
toast.error(error.message || t('cssTemplates.resetFailed', 'Failed to reset template'));
}
});
const activeTemplate = localTemplates.find(t => t.slot_number === activeSlot);
const updateLocalTemplate = (updates: Partial<CssTemplate>) => {
setLocalTemplates(prev =>
prev.map(t =>
t.slot_number === activeSlot ? { ...t, ...updates } : t
)
);
setHasChanges(true);
};
const handleReset = () => {
if (!confirm(t('cssTemplates.resetConfirm', 'Reset this template to the default? Your changes will be lost.'))) {
return;
}
resetMutation.mutate();
};
if (isLoading) {
return <Loading size="lg" text={t('common.loading', 'Loading...')} />;
}
return (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('cssTemplates.title', 'Custom CSS Templates')}
</h2>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-neutral-200 mb-6">
{[1, 2, 3].map(slot => {
const template = localTemplates.find(t => t.slot_number === slot);
return (
<button
key={slot}
onClick={() => setActiveSlot(slot)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeSlot === slot
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300'
}`}
>
{t('cssTemplates.template', 'Template')} {slot}
{template && (
<span className="ml-2 text-neutral-400">
({template.name})
</span>
)}
{template?.is_enabled && (
<Check className="w-3 h-3 inline ml-1 text-green-500" />
)}
</button>
);
})}
</div>
{activeTemplate && (
<div className="space-y-6">
{/* Template Name */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('cssTemplates.templateName', 'Template Name')}
</label>
<input
type="text"
value={activeTemplate.name}
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
maxLength={50}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Enable Toggle */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={activeTemplate.is_enabled}
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700">
{t('cssTemplates.enableTemplate', 'Enable this template')}
</span>
</label>
<p className="text-xs text-neutral-500 mt-1 ml-6">
{t('cssTemplates.enableHint', 'Enabled templates can be selected when creating events')}
</p>
</div>
{/* CSS Editor */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('cssTemplates.cssContent', 'CSS Content')}
</label>
<div className="relative">
<textarea
value={activeTemplate.css_content}
onChange={(e) => updateLocalTemplate({ css_content: e.target.value })}
className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-neutral-900 text-green-400"
spellCheck={false}
placeholder="/* Enter your custom CSS here */"
/>
<div className="absolute bottom-3 right-3 text-xs text-neutral-400">
{(activeTemplate.css_content?.length || 0).toLocaleString()} / 102,400 {t('common.characters', 'characters')}
</div>
</div>
<p className="text-xs text-neutral-500 mt-2">
{t('cssTemplates.cssHint', 'Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent')}
</p>
</div>
{/* Security Notice */}
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-800">
<strong>{t('cssTemplates.securityNotice', 'Security Notice')}:</strong>{' '}
{t('cssTemplates.securityText', 'CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.')}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t border-neutral-100">
<div className="flex items-center gap-3">
{activeSlot === 1 && activeTemplate.is_default && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={resetMutation.isPending}
leftIcon={<RotateCcw className="w-4 h-4" />}
>
{t('cssTemplates.resetToDefault', 'Reset to Default')}
</Button>
)}
</div>
<div className="flex items-center gap-3">
{hasChanges && (
<span className="text-sm text-amber-600">
{t('cssTemplates.unsavedChanges', 'Unsaved changes')}
</span>
)}
<Button
variant="primary"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !hasChanges}
isLoading={saveMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('cssTemplates.saveTemplate', 'Save Template')}
</Button>
</div>
</div>
{/* Last Updated */}
{activeTemplate.updated_at && (
<p className="text-xs text-neutral-400 text-right">
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
</p>
)}
</div>
)}
</div>
</Card>
);
};
export default CssTemplateEditor;
@@ -0,0 +1,315 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, AlertCircle, CheckCircle, Loader2, Type, Mail } from 'lucide-react';
import { Button, Input, Card } from '../common';
interface EventRenameDialogProps {
isOpen: boolean;
eventName: string;
eventId: number;
customerEmail?: string;
onClose: () => void;
onRename: (newName: string, resendEmail: boolean) => Promise<{
success: boolean;
data?: {
newSlug: string;
newShareLink: string;
filesRenamed: number;
};
error?: string;
}>;
onValidate: (newName: string) => Promise<{
valid: boolean;
newSlug?: string;
error?: string;
}>;
}
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
isOpen,
eventName,
eventId,
customerEmail,
onClose,
onRename,
onValidate
}) => {
const { t } = useTranslation();
const [newName, setNewName] = useState(eventName);
const [resendEmail, setResendEmail] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
newSlug?: string;
error?: string;
} | null>(null);
const [renameStatus, setRenameStatus] = useState<string | null>(null);
const [renameResult, setRenameResult] = useState<{
success: boolean;
newSlug?: string;
newShareLink?: string;
filesRenamed?: number;
error?: string;
} | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setNewName(eventName);
setResendEmail(false);
setValidationResult(null);
setRenameStatus(null);
setRenameResult(null);
}
}, [isOpen, eventName]);
// Debounced validation
useEffect(() => {
if (!isOpen || newName.trim() === eventName.trim() || newName.trim().length < 3) {
setValidationResult(null);
return;
}
const timeoutId = setTimeout(async () => {
setIsValidating(true);
try {
const result = await onValidate(newName.trim());
setValidationResult(result);
} catch (error) {
setValidationResult({ valid: false, error: 'Validation failed' });
} finally {
setIsValidating(false);
}
}, 500);
return () => clearTimeout(timeoutId);
}, [newName, eventName, isOpen, onValidate]);
const handleRename = async () => {
if (!validationResult?.valid) return;
setIsRenaming(true);
setRenameStatus(t('events.rename.validating', 'Validating new name...'));
try {
setRenameStatus(t('events.rename.renamingFiles', 'Renaming files...'));
const result = await onRename(newName.trim(), resendEmail);
if (result.success) {
setRenameStatus(t('events.rename.complete', 'Complete!'));
setRenameResult({
success: true,
newSlug: result.data?.newSlug,
newShareLink: result.data?.newShareLink,
filesRenamed: result.data?.filesRenamed
});
} else {
setRenameResult({
success: false,
error: result.error || 'Rename failed'
});
}
} catch (error: any) {
setRenameResult({
success: false,
error: error.message || 'Rename failed'
});
} finally {
setIsRenaming(false);
setRenameStatus(null);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-lg w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('events.rename.title', 'Rename Event')}
</h2>
<button
onClick={onClose}
disabled={isRenaming}
className="text-neutral-400 hover:text-neutral-600 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{renameResult?.success ? (
// Success state
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-600 flex-shrink-0" />
<div>
<p className="font-medium text-green-900">
{t('events.rename.success', 'Event renamed successfully!')}
</p>
{renameResult.filesRenamed !== undefined && renameResult.filesRenamed > 0 && (
<p className="text-sm text-green-700 mt-1">
{t('events.rename.filesRenamed', '{{count}} files updated', { count: renameResult.filesRenamed })}
</p>
)}
</div>
</div>
{renameResult.newShareLink && (
<div className="p-3 bg-neutral-50 rounded-lg">
<p className="text-sm font-medium text-neutral-700 mb-1">
{t('events.rename.newLink', 'New Gallery Link')}
</p>
<p className="text-sm text-neutral-900 break-all">{renameResult.newShareLink}</p>
</div>
)}
<div className="flex justify-end">
<Button variant="primary" onClick={onClose}>
{t('common.done', 'Done')}
</Button>
</div>
</div>
) : renameResult?.error ? (
// Error state
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-red-50 rounded-lg">
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
<div>
<p className="font-medium text-red-900">
{t('events.rename.failed', 'Rename failed')}
</p>
<p className="text-sm text-red-700 mt-1">{renameResult.error}</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRenameResult(null)}>
{t('common.retry', 'Retry')}
</Button>
<Button variant="primary" onClick={onClose}>
{t('common.close', 'Close')}
</Button>
</div>
</div>
) : isRenaming ? (
// Renaming in progress
<div className="space-y-4 py-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-10 h-10 text-primary-600 animate-spin" />
<p className="text-neutral-700 font-medium">{renameStatus}</p>
</div>
</div>
) : (
// Input form
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-600 mb-3">
{t('events.rename.currentName', 'Current name:')} <span className="font-medium">{eventName}</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.rename.newName', 'New Event Name')}
</label>
<Input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('events.rename.enterNewName', 'Enter new event name')}
leftIcon={<Type className="w-5 h-5 text-neutral-400" />}
autoFocus
/>
</div>
{/* New slug preview */}
{validationResult?.valid && validationResult.newSlug && (
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-sm text-green-800">
<span className="font-medium">{t('events.rename.newUrl', 'New URL:')}</span>{' '}
<span className="break-all">/gallery/{validationResult.newSlug}/...</span>
</p>
</div>
)}
{/* Validation status */}
{isValidating && (
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Loader2 className="w-4 h-4 animate-spin" />
{t('events.rename.checkingAvailability', 'Checking availability...')}
</div>
)}
{validationResult && !validationResult.valid && (
<div className="flex items-center gap-2 p-3 bg-red-50 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
<p className="text-sm text-red-700">{validationResult.error}</p>
</div>
)}
{/* Resend email option */}
{customerEmail && (
<div className="pt-2 border-t border-neutral-200">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={resendEmail}
onChange={(e) => setResendEmail(e.target.checked)}
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 flex items-center gap-1">
<Mail className="w-4 h-4" />
{t('events.rename.resendEmail', 'Resend invitation email with new gallery link')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.rename.emailTo', 'Send updated gallery access email to')} {customerEmail}
</p>
</div>
</label>
</div>
)}
{/* Warning */}
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<div className="flex gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-medium">{t('events.rename.warningTitle', 'Please note:')}</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>{t('events.rename.warning1', 'The gallery URL will change')}</li>
<li>{t('events.rename.warning2', 'Old URLs will automatically redirect to the new URL')}</li>
<li>{t('events.rename.warning3', 'Photo files may be renamed')}</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={handleRename}
disabled={
!validationResult?.valid ||
isValidating ||
newName.trim() === eventName.trim() ||
newName.trim().length < 3
}
>
{t('events.rename.confirm', 'Rename Event')}
</Button>
</div>
</div>
)}
</Card>
</div>
);
};
EventRenameDialog.displayName = 'EventRenameDialog';
@@ -0,0 +1,176 @@
import React, { useState } from 'react';
import { Download, FileText, FileSpreadsheet, Archive, FileJson, ChevronDown, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service';
interface PhotoExportMenuProps {
eventId: number;
selectedPhotoIds: number[];
filters?: FeedbackFilters;
disabled?: boolean;
}
const EXPORT_FORMATS = [
{
value: 'txt',
label: 'Filename List (TXT)',
description: 'Simple text list for Lightroom search',
icon: FileText
},
{
value: 'csv',
label: 'Filename List (CSV)',
description: 'Spreadsheet with metadata',
icon: FileSpreadsheet
},
{
value: 'xmp',
label: 'XMP Sidecar Files (ZIP)',
description: 'Import ratings into Lightroom/Bridge',
icon: Archive
},
{
value: 'json',
label: 'Metadata (JSON)',
description: 'Structured data for automation',
icon: FileJson
},
];
export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
eventId,
selectedPhotoIds,
filters,
disabled = false
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const exportMutation = useMutation({
mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options),
onSuccess: () => {
toast.success(t('export.success', 'Export downloaded successfully'));
setIsOpen(false);
},
onError: (error: Error) => {
toast.error(t('export.error', 'Export failed: ') + error.message);
}
});
const handleExport = (format: 'txt' | 'csv' | 'xmp' | 'json') => {
const options: ExportOptions = {
format,
options: {
filename_format: 'original',
include_rating: true,
include_label: true,
include_description: true,
include_keywords: true
}
};
// Use selected photos if any, otherwise use filters
if (selectedPhotoIds.length > 0) {
options.photo_ids = selectedPhotoIds;
} else if (filters) {
options.filter = filters;
}
exportMutation.mutate(options);
};
const hasSelection = selectedPhotoIds.length > 0;
const hasFilters = filters && (
filters.minRating !== null ||
filters.hasLikes ||
filters.hasFavorites ||
filters.hasComments
);
const isDisabled = disabled || (!hasSelection && !hasFilters);
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isDisabled || exportMutation.isPending}
className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
transition-colors
${isDisabled
? 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
}
`}
>
{exportMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t('export.button', 'Export')}
{hasSelection && (
<span className="bg-primary-100 text-primary-700 text-xs px-2 py-0.5 rounded-full">
{selectedPhotoIds.length}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isDisabled && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown Menu */}
<div className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-neutral-200 z-20">
<div className="p-2">
<p className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
{hasSelection
? t('export.exportSelected', 'Export {{count}} selected', { count: selectedPhotoIds.length })
: t('export.exportFiltered', 'Export filtered photos')
}
</p>
{EXPORT_FORMATS.map((format) => {
const Icon = format.icon;
return (
<button
key={format.value}
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
disabled={exportMutation.isPending}
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 text-left transition-colors"
>
<Icon className="w-5 h-5 text-neutral-500 mt-0.5" />
<div>
<div className="text-sm font-medium text-neutral-900">
{format.label}
</div>
<div className="text-xs text-neutral-500">
{format.description}
</div>
</div>
</button>
);
})}
</div>
</div>
</>
)}
{!hasSelection && !hasFilters && (
<p className="mt-1 text-xs text-neutral-500">
{t('export.hint', 'Select photos or apply filters to export')}
</p>
)}
</div>
);
};
export default PhotoExportMenu;
@@ -0,0 +1,202 @@
import React from 'react';
import { Star, Heart, Bookmark, MessageCircle, Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../common';
import { FeedbackFilters, FilterSummary } from '../../services/photos.service';
interface PhotoFilterPanelProps {
filters: FeedbackFilters;
onChange: (filters: FeedbackFilters) => void;
summary: FilterSummary | null;
isLoading?: boolean;
}
const RATING_OPTIONS = [
{ value: null, label: 'filter.allPhotos' },
{ value: 0.1, label: 'filter.anyRating' },
{ value: 1, label: 'filter.oneStarPlus' },
{ value: 2, label: 'filter.twoStarsPlus' },
{ value: 3, label: 'filter.threeStarsPlus' },
{ value: 4, label: 'filter.fourStarsPlus' },
{ value: 5, label: 'filter.fiveStarsOnly' },
];
export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
filters,
onChange,
summary,
isLoading = false
}) => {
const { t } = useTranslation();
const handleRatingChange = (value: number | null) => {
onChange({ ...filters, minRating: value });
};
const handleCheckboxChange = (field: 'hasLikes' | 'hasFavorites' | 'hasComments') => {
onChange({ ...filters, [field]: !filters[field] });
};
const handleLogicChange = (logic: 'AND' | 'OR') => {
onChange({ ...filters, logic });
};
const clearFilters = () => {
onChange({
minRating: null,
hasLikes: false,
hasFavorites: false,
hasComments: false,
logic: 'AND'
});
};
const hasActiveFilters = filters.minRating !== null ||
filters.hasLikes ||
filters.hasFavorites ||
filters.hasComments;
return (
<div className="bg-white rounded-lg border border-neutral-200 p-4 mb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-neutral-900 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('filter.feedbackFilters', 'Feedback Filters')}
</h3>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
leftIcon={<X className="w-3 h-3" />}
>
{t('filter.clear', 'Clear')}
</Button>
)}
</div>
<div className="space-y-4">
{/* Rating Filter */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<Star className="w-4 h-4 inline mr-1" />
{t('filter.rating', 'Rating')}
</label>
<select
value={filters.minRating ?? ''}
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
{RATING_OPTIONS.map(option => (
<option key={option.label} value={option.value ?? ''}>
{t(option.label, option.label.split('.').pop())}
</option>
))}
</select>
</div>
{/* Checkbox Filters */}
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasLikes || false}
onChange={() => handleCheckboxChange('hasLikes')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<Heart className="w-4 h-4 text-red-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasLikes', 'Has likes')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withLikes})</span>
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasFavorites || false}
onChange={() => handleCheckboxChange('hasFavorites')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<Bookmark className="w-4 h-4 text-yellow-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasFavorites', 'Has favorites')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withFavorites})</span>
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasComments || false}
onChange={() => handleCheckboxChange('hasComments')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<MessageCircle className="w-4 h-4 text-blue-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasComments', 'Has comments')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withComments})</span>
)}
</span>
</label>
</div>
{/* Logic Toggle */}
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600">{t('filter.combineWith', 'Combine with')}:</span>
<div className="flex rounded-lg border border-neutral-200 overflow-hidden">
<button
type="button"
onClick={() => handleLogicChange('AND')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'AND' || !filters.logic
? 'bg-primary-600 text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-50'
}`}
disabled={isLoading}
>
AND
</button>
<button
type="button"
onClick={() => handleLogicChange('OR')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'OR'
? 'bg-primary-600 text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-50'
}`}
disabled={isLoading}
>
OR
</button>
</div>
</div>
)}
{/* Summary */}
{summary && (
<div className="pt-2 border-t border-neutral-100 text-sm text-neutral-600">
{t('filter.showingPhotos', 'Total photos')}: {summary.total}
{summary.withRatings > 0 && (
<span className="ml-2">
| {t('filter.withRatings', 'With ratings')}: {summary.withRatings}
</span>
)}
</div>
)}
</div>
</div>
);
};
export default PhotoFilterPanel;
+4
View File
@@ -31,3 +31,7 @@ export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager';
export { EventRenameDialog } from './EventRenameDialog';
export { PhotoFilterPanel } from './PhotoFilterPanel';
export { PhotoExportMenu } from './PhotoExportMenu';
export { CssTemplateEditor } from './CssTemplateEditor';