chore: clean up codebase for production readiness
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled

- Remove all console.log/debug statements from production code
- Add NODE_ENV checks for development-only logging
- Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore)
- Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied)
- Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt)
- Update package.json to remove references to deleted scripts
- Replace console statements with logger utility in backend
- Secure error boundaries to not expose stack traces in production

This makes the codebase production-ready with no debug output or test scripts.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-24 23:19:30 +02:00
parent 827eb4819b
commit 1b4b497fdf
144 changed files with 12279 additions and 2018 deletions
+10 -4
View File
@@ -80,19 +80,25 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<Menu className="w-6 h-6" />
</button>
{/* Date display */}
<div className="hidden lg:block">
{/* Date display - hidden on small screens */}
<div className="hidden xl:block">
<p className="text-base text-neutral-700">
{format(new Date(), 'PPPP')}
</p>
</div>
</div>
{/* Center - Logo and PicPeak text */}
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-3">
{/* Center - Logo and PicPeak text - hidden on small screens to prevent overlap */}
<div className="hidden lg:flex absolute left-1/2 transform -translate-x-1/2 items-center gap-3">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-10 w-auto object-contain" />
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
</div>
{/* Mobile Logo - shown only on small screens */}
<div className="flex lg:hidden items-center gap-2 mx-auto">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
<span className="text-xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
</div>
{/* Right side actions */}
<div className="flex items-center gap-3">
+15 -7
View File
@@ -6,9 +6,10 @@ import { useSessionTimeout } from '../../hooks/useSessionTimeout';
import { AdminSidebar } from './AdminSidebar';
import { AdminHeader } from './AdminHeader';
import { MaintenanceBanner } from './MaintenanceBanner';
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading } = useAdminAuth();
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
// Handle session timeout
@@ -31,6 +32,9 @@ export const AdminLayout: React.FC = () => {
return (
<div className="h-screen bg-neutral-50 flex overflow-hidden">
{/* Mandatory Password Change Modal */}
{mustChangePassword && <MandatoryPasswordChangeModal />}
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
@@ -39,19 +43,23 @@ export const AdminLayout: React.FC = () => {
/>
)}
{/* Sidebar */}
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Sidebar - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* Header */}
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
{/* Header - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
</div>
{/* Maintenance mode banner */}
<MaintenanceBanner />
{/* Page content */}
<main id="main-content" className="flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto">
{/* Page content - disabled when password change required */}
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
<Outlet />
</main>
</div>
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package } from 'lucide-react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react';
import { toast } from 'react-toastify';
import { AdminPhoto } from '../../services/photos.service';
@@ -246,6 +246,24 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</span>
</div>
)}
{/* Feedback Indicators */}
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
<div className="absolute top-2 left-2 flex gap-1 z-10" style={{ left: isSelectionMode ? '40px' : '8px' }}>
{photo.comment_count > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
</div>
)}
{photo.average_rating > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
</div>
)}
</div>
)}
</div>
);
})}
@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, ThumbsUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { feedbackService } from '../../services/feedback.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
@@ -28,9 +30,21 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isDeleting, setIsDeleting] = useState(false);
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
const [expandedComments, setExpandedComments] = useState(false);
const queryClient = useQueryClient();
const currentPhoto = photos[currentIndex];
// Fetch feedback for current photo
const { data: feedbackData } = useQuery({
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
photoId: currentPhoto?.id.toString(),
status: 'all' // Get all comments including unapproved
}),
enabled: !!currentPhoto
});
const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
};
@@ -88,6 +102,30 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
}
};
// Mutations for feedback moderation
const moderateFeedbackMutation = useMutation({
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
feedbackService.moderateFeedback(feedbackId, action),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] });
toast.success('Feedback moderated successfully');
},
onError: () => {
toast.error('Failed to moderate feedback');
}
});
const deleteFeedbackMutation = useMutation({
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] });
toast.success('Feedback deleted successfully');
},
onError: () => {
toast.error('Failed to delete feedback');
}
});
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
@@ -256,6 +294,177 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
)}
</div>
{/* Feedback Section */}
{feedbackData && (
<div className="mt-6 pt-6 border-t border-neutral-700">
<h4 className="text-white font-medium mb-4 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Feedback & Comments
</h4>
{/* Feedback Stats */}
<div className="grid grid-cols-2 gap-3 mb-4">
{currentPhoto.average_rating > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-yellow-400 mb-1">
<Star className="w-4 h-4" fill="currentColor" />
<span className="text-white font-medium">{Number(currentPhoto.average_rating).toFixed(1)}</span>
</div>
<p className="text-xs text-neutral-400">Avg Rating</p>
</div>
)}
{currentPhoto.like_count > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-red-400 mb-1">
<Heart className="w-4 h-4" fill="currentColor" />
<span className="text-white font-medium">{currentPhoto.like_count}</span>
</div>
<p className="text-xs text-neutral-400">Likes</p>
</div>
)}
{currentPhoto.favorite_count > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-blue-400 mb-1">
<Star className="w-4 h-4" />
<span className="text-white font-medium">{currentPhoto.favorite_count}</span>
</div>
<p className="text-xs text-neutral-400">Favorites</p>
</div>
)}
{feedbackData.feedback && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-green-400 mb-1">
<MessageSquare className="w-4 h-4" />
<span className="text-white font-medium">{feedbackData.feedback.filter(f => f.feedback_type === 'comment').length}</span>
</div>
<p className="text-xs text-neutral-400">Comments</p>
</div>
)}
</div>
{/* Comments List */}
{feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && (
<div className="space-y-2">
<button
onClick={() => setExpandedComments(!expandedComments)}
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
>
{expandedComments ? 'Hide' : 'Show'} Comments ({feedbackData.feedback.filter(f => f.feedback_type === 'comment').length})
</button>
{expandedComments && (
<div className="space-y-3 max-h-64 overflow-y-auto">
{feedbackData.feedback
.filter(f => f.feedback_type === 'comment')
.map((comment) => (
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<p className="text-sm font-medium text-white">
{comment.guest_name || 'Anonymous'}
</p>
<p className="text-xs text-neutral-400">
{format(new Date(comment.created_at), 'MMM d, yyyy h:mm a')}
</p>
</div>
{/* Comment Status Badge */}
<div className="flex items-center gap-1">
{!comment.is_approved && !comment.is_hidden && (
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Pending
</span>
)}
{comment.is_approved && !comment.is_hidden && (
<span className="text-xs bg-green-500/20 text-green-400 px-2 py-1 rounded flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Approved
</span>
)}
{comment.is_hidden && (
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded flex items-center gap-1">
<XCircle className="w-3 h-3" />
Hidden
</span>
)}
</div>
</div>
<p className="text-sm text-neutral-300 mb-3">
{comment.comment_text}
</p>
{/* Moderation Actions */}
<div className="flex gap-2">
{!comment.is_approved && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'approve'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
>
Approve
</button>
)}
{!comment.is_hidden && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'hide'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded"
>
Hide
</button>
)}
{comment.is_hidden && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'approve'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
>
Unhide
</button>
)}
<button
onClick={() => {
if (confirm('Are you sure you want to delete this comment?')) {
deleteFeedbackMutation.mutate(comment.id.toString());
}
}}
disabled={deleteFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 text-white rounded"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* No feedback message */}
{(!feedbackData.feedback || feedbackData.feedback.length === 0) && (
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
)}
</div>
)}
{/* Navigation info */}
<div className="mt-6 pt-6 border-t border-neutral-700">
<p className="text-neutral-400 text-sm text-center">
@@ -2,10 +2,12 @@ import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
export const CategoryManager: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
@@ -24,12 +26,12 @@ export const CategoryManager: React.FC = () => {
categoriesService.createCategory({ name, is_global: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category created successfully');
toast.success(t('categories.categoryCreatedSuccess'));
setNewCategoryName('');
setIsAdding(false);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to create category');
toast.error(error.response?.data?.error || t('categories.failedToCreateCategory'));
},
});
@@ -39,12 +41,12 @@ export const CategoryManager: React.FC = () => {
categoriesService.updateCategory(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category updated successfully');
toast.success(t('toast.categoryUpdated'));
setEditingId(null);
setEditingName('');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to update category');
toast.error(error.response?.data?.error || t('toast.saveError'));
},
});
@@ -53,10 +55,10 @@ export const CategoryManager: React.FC = () => {
mutationFn: categoriesService.deleteCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success('Category deleted successfully');
toast.success(t('categories.categoryDeletedSuccess'));
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to delete category');
toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory'));
},
});
@@ -73,7 +75,7 @@ export const CategoryManager: React.FC = () => {
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
if (window.confirm(t('categories.deleteConfirm', { name: category.name }))) {
deleteMutation.mutate(category.id);
}
};
@@ -99,7 +101,7 @@ export const CategoryManager: React.FC = () => {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-neutral-900">Photo Categories</h3>
<h3 className="text-lg font-semibold text-neutral-900">{t('categories.title')}</h3>
{!isAdding && (
<Button
variant="primary"
@@ -107,7 +109,7 @@ export const CategoryManager: React.FC = () => {
onClick={() => setIsAdding(true)}
leftIcon={<Plus className="w-4 h-4" />}
>
Add Category
{t('categories.addCategory')}
</Button>
)}
</div>
@@ -120,7 +122,7 @@ export const CategoryManager: React.FC = () => {
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Category name"
placeholder={t('categories.categoryName')}
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
@@ -133,7 +135,7 @@ export const CategoryManager: React.FC = () => {
{createMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Create'
t('common.save')
)}
</Button>
<Button
@@ -144,7 +146,7 @@ export const CategoryManager: React.FC = () => {
setNewCategoryName('');
}}
>
Cancel
{t('common.cancel')}
</Button>
</div>
)}
@@ -153,7 +155,7 @@ export const CategoryManager: React.FC = () => {
<div className="space-y-2">
{categories.length === 0 ? (
<p className="text-neutral-500 text-center py-8">
No categories yet. Create your first category to organize photos.
{t('categories.noCategoriesYet')}
</p>
) : (
categories.map((category) => (
@@ -183,7 +185,7 @@ export const CategoryManager: React.FC = () => {
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Save'
t('common.save')
)}
</Button>
<Button
@@ -191,7 +193,7 @@ export const CategoryManager: React.FC = () => {
size="sm"
onClick={cancelEdit}
>
Cancel
{t('common.cancel')}
</Button>
</div>
) : (
@@ -204,14 +206,14 @@ export const CategoryManager: React.FC = () => {
<button
onClick={() => startEdit(category)}
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
title="Edit category"
title={t('common.edit')}
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(category)}
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete category"
title={t('common.delete')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
@@ -0,0 +1,341 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Shield, Eye, Lock, AlertTriangle, Info } from 'lucide-react';
import { Button, Card, Toggle, Select, Input, Textarea } from '../common';
import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify';
interface ProtectionSettings {
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
default_image_quality: number;
enable_devtools_protection: boolean;
max_image_requests_per_minute: number;
suspicious_activity_threshold: number;
enable_canvas_rendering: boolean;
default_fragmentation_level: number;
enable_overlay_protection: boolean;
protection_warning_message: string;
}
export const ImageProtectionSettings: React.FC = () => {
const { t } = useTranslation();
const [settings, setSettings] = useState<ProtectionSettings>({
default_protection_level: 'standard',
default_image_quality: 85,
enable_devtools_protection: true,
max_image_requests_per_minute: 30,
suspicious_activity_threshold: 10,
enable_canvas_rendering: false,
default_fragmentation_level: 3,
enable_overlay_protection: true,
protection_warning_message: 'Images in this gallery are protected from unauthorized download.'
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setIsLoading(true);
const response = await settingsService.getSettings();
// Map settings from API response
const protectionSettings: ProtectionSettings = {
default_protection_level: response.default_protection_level || 'standard',
default_image_quality: parseInt(response.default_image_quality) || 85,
enable_devtools_protection: response.enable_devtools_protection !== false,
max_image_requests_per_minute: parseInt(response.max_image_requests_per_minute) || 30,
suspicious_activity_threshold: parseInt(response.suspicious_activity_threshold) || 10,
enable_canvas_rendering: response.enable_canvas_rendering === true,
default_fragmentation_level: parseInt(response.default_fragmentation_level) || 3,
enable_overlay_protection: response.enable_overlay_protection !== false,
protection_warning_message: response.protection_warning_message || settings.protection_warning_message
};
setSettings(protectionSettings);
} catch (error) {
console.error('Failed to load protection settings:', error);
toast.error('Failed to load protection settings');
} finally {
setIsLoading(false);
}
};
const saveSettings = async () => {
try {
setIsSaving(true);
// Convert settings to API format
const apiSettings = Object.entries(settings).reduce((acc, [key, value]) => {
acc[key] = typeof value === 'boolean' ? value : value.toString();
return acc;
}, {} as Record<string, string | boolean>);
await settingsService.updateSettings(apiSettings);
toast.success('Protection settings saved successfully');
} catch (error) {
console.error('Failed to save protection settings:', error);
toast.error('Failed to save protection settings');
} finally {
setIsSaving(false);
}
};
const updateSetting = (key: keyof ProtectionSettings, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const protectionLevels = [
{ value: 'basic', label: 'Basic - Minimal protection, best performance' },
{ value: 'standard', label: 'Standard - Balanced protection and performance' },
{ value: 'enhanced', label: 'Enhanced - Strong protection with good performance' },
{ value: 'maximum', label: 'Maximum - Strongest protection, may impact performance' }
];
const getProtectionLevelIcon = (level: string) => {
switch (level) {
case 'basic': return <Eye className="w-4 h-4 text-green-500" />;
case 'standard': return <Shield className="w-4 h-4 text-blue-500" />;
case 'enhanced': return <Lock className="w-4 h-4 text-orange-500" />;
case 'maximum': return <AlertTriangle className="w-4 h-4 text-red-500" />;
default: return <Shield className="w-4 h-4 text-gray-500" />;
}
};
const getProtectionLevelDescription = (level: string) => {
switch (level) {
case 'basic':
return 'Prevents drag/drop and basic right-click. Good for public galleries.';
case 'standard':
return 'Adds keyboard shortcut blocking and user selection prevention.';
case 'enhanced':
return 'Includes DevTools detection, rate limiting, and overlay protection.';
case 'maximum':
return 'Canvas rendering, image fragmentation, and comprehensive monitoring.';
default:
return '';
}
};
if (isLoading) {
return (
<Card className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
</Card>
);
}
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<Shield className="w-6 h-6 text-blue-500" />
<div>
<h2 className="text-xl font-semibold text-gray-900">Image Protection Settings</h2>
<p className="text-sm text-gray-600">Configure security measures for photo galleries</p>
</div>
</div>
<div className="space-y-6">
{/* Protection Level */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Default Protection Level
</label>
<Select
value={settings.default_protection_level}
onChange={(value) => updateSetting('default_protection_level', value as any)}
options={protectionLevels}
className="w-full"
/>
<div className="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-2 mb-1">
{getProtectionLevelIcon(settings.default_protection_level)}
<span className="font-medium text-sm capitalize">
{settings.default_protection_level} Protection
</span>
</div>
<p className="text-sm text-gray-600">
{getProtectionLevelDescription(settings.default_protection_level)}
</p>
</div>
</div>
{/* Image Quality */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Default Image Quality ({settings.default_image_quality}%)
</label>
<input
type="range"
min="30"
max="100"
step="5"
value={settings.default_image_quality}
onChange={(e) => updateSetting('default_image_quality', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Lower quality = Better protection</span>
<span>Higher quality = Better image</span>
</div>
</div>
{/* DevTools Protection */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
DevTools Protection
</label>
<p className="text-xs text-gray-500">
Detect and respond to browser developer tools
</p>
</div>
<Toggle
checked={settings.enable_devtools_protection}
onChange={(checked) => updateSetting('enable_devtools_protection', checked)}
/>
</div>
{/* Canvas Rendering */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
Canvas Rendering
</label>
<p className="text-xs text-gray-500">
Render images on canvas instead of img tags (stronger protection)
</p>
</div>
<Toggle
checked={settings.enable_canvas_rendering}
onChange={(checked) => updateSetting('enable_canvas_rendering', checked)}
/>
</div>
{/* Fragmentation Level */}
{settings.enable_canvas_rendering && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image Fragmentation Level ({settings.default_fragmentation_level})
</label>
<input
type="range"
min="1"
max="5"
step="1"
value={settings.default_fragmentation_level}
onChange={(e) => updateSetting('default_fragmentation_level', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Low fragmentation</span>
<span>High fragmentation</span>
</div>
</div>
)}
{/* Rate Limiting */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Max Requests Per Minute
</label>
<Input
type="number"
min="5"
max="100"
value={settings.max_image_requests_per_minute}
onChange={(e) => updateSetting('max_image_requests_per_minute', parseInt(e.target.value))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Suspicious Activity Threshold
</label>
<Input
type="number"
min="3"
max="50"
value={settings.suspicious_activity_threshold}
onChange={(e) => updateSetting('suspicious_activity_threshold', parseInt(e.target.value))}
/>
</div>
</div>
{/* Overlay Protection */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
Overlay Protection
</label>
<p className="text-xs text-gray-500">
Add transparent overlays to prevent easy screenshot extraction
</p>
</div>
<Toggle
checked={settings.enable_overlay_protection}
onChange={(checked) => updateSetting('enable_overlay_protection', checked)}
/>
</div>
{/* Warning Message */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Protection Warning Message
</label>
<Textarea
value={settings.protection_warning_message}
onChange={(e) => updateSetting('protection_warning_message', e.target.value)}
placeholder="Message shown when protection is triggered"
rows={3}
/>
</div>
</div>
{/* Warning Box */}
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<h4 className="font-medium text-amber-800 mb-1">Important Notes</h4>
<ul className="text-sm text-amber-700 space-y-1">
<li> Higher protection levels may impact page performance</li>
<li> Canvas rendering disables browser image caching</li>
<li> Maximum protection may cause accessibility issues</li>
<li> Test thoroughly with your target browsers</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 flex justify-end gap-3">
<Button
variant="outline"
onClick={loadSettings}
disabled={isSaving}
>
Reset
</Button>
<Button
variant="primary"
onClick={saveSettings}
loading={isSaving}
>
Save Settings
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,239 @@
import React, { useState } from 'react';
import { Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../common';
import { adminService } from '../../services/admin.service';
import { useAdminAuth } from '../../contexts';
export const MandatoryPasswordChangeModal: React.FC = () => {
const { t } = useTranslation();
const { updatePasswordChanged } = useAdminAuth();
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const changePasswordMutation = useMutation({
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success(t('mandatoryPasswordChange.success'));
updatePasswordChanged();
// Reset form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
},
onError: (error: any) => {
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t('passwordChange.failed'));
}
}
});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.currentPassword) {
newErrors.currentPassword = t('passwordChange.currentRequired');
}
if (!formData.newPassword) {
newErrors.newPassword = t('passwordChange.newRequired');
} else if (formData.newPassword.length < 12) {
newErrors.newPassword = t('mandatoryPasswordChange.minLengthError');
} else {
// Check for character types
if (!/[a-z]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainLowercase');
} else if (!/[A-Z]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainUppercase');
} else if (!/[0-9]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainNumbersError');
} else if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainSpecialError');
}
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = t('passwordChange.confirmRequired');
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = t('passwordChange.noMatch');
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = t('passwordChange.mustBeDifferent');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
changePasswordMutation.mutate({
currentPassword: formData.currentPassword,
newPassword: formData.newPassword
});
};
const handleInputChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user types
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="mb-6 text-center">
<div className="mx-auto w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center mb-4">
<AlertCircle className="w-6 h-6 text-amber-600" />
</div>
<h2 className="text-xl font-semibold text-neutral-900 mb-2">{t('mandatoryPasswordChange.title')}</h2>
<p className="text-sm text-neutral-600">
{t('mandatoryPasswordChange.description')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.currentPassword')}
</label>
<div className="relative">
<Input
id="currentPassword"
type={showPasswords.current ? 'text' : 'password'}
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={errors.currentPassword}
placeholder={t('passwordChange.currentPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.current ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.newPassword')}
</label>
<div className="relative">
<Input
id="newPassword"
type={showPasswords.new ? 'text' : 'password'}
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={errors.newPassword}
placeholder={t('passwordChange.newPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.new ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirmPassword"
type={showPasswords.confirm ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.confirm ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Password Requirements */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">{t('passwordChange.requirements')}</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>{t('mandatoryPasswordChange.minLength')}</li>
<li>{t('mandatoryPasswordChange.mustContainUpperLower')}</li>
<li>{t('mandatoryPasswordChange.mustContainNumbers')}</li>
<li>{t('mandatoryPasswordChange.mustContainSpecial')}</li>
<li>{t('passwordChange.mustDiffer')}</li>
</ul>
</div>
</div>
</div>
{/* Action Button */}
<div className="pt-2">
<Button
type="submit"
variant="primary"
className="w-full"
isLoading={changePasswordMutation.isPending}
>
{t('passwordChange.title')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { X, Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../common';
import { adminService } from '../../services/admin.service';
@@ -12,6 +13,7 @@ interface PasswordChangeModalProps {
}
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
const { t } = useTranslation();
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
@@ -27,7 +29,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
const changePasswordMutation = useMutation({
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success('Password changed successfully');
toast.success(t('passwordChange.success'));
onClose();
// Reset form
setFormData({
@@ -41,7 +43,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error('Failed to change password');
toast.error(t('passwordChange.failed'));
}
}
});
@@ -50,23 +52,23 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
const newErrors: Record<string, string> = {};
if (!formData.currentPassword) {
newErrors.currentPassword = 'Current password is required';
newErrors.currentPassword = t('passwordChange.currentRequired');
}
if (!formData.newPassword) {
newErrors.newPassword = 'New password is required';
newErrors.newPassword = t('passwordChange.newRequired');
} else if (formData.newPassword.length < 6) {
newErrors.newPassword = 'Password must be at least 6 characters';
newErrors.newPassword = t('passwordChange.minLengthError');
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = 'Please confirm your new password';
newErrors.confirmPassword = t('passwordChange.confirmRequired');
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
newErrors.confirmPassword = t('passwordChange.noMatch');
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = 'New password must be different from current password';
newErrors.newPassword = t('passwordChange.mustBeDifferent');
}
setErrors(newErrors);
@@ -101,7 +103,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Change Password</h2>
<h2 className="text-xl font-semibold text-neutral-900">{t('passwordChange.title')}</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
@@ -114,7 +116,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
Current Password
{t('passwordChange.currentPassword')}
</label>
<div className="relative">
<Input
@@ -123,7 +125,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={errors.currentPassword}
placeholder="Enter current password"
placeholder={t('passwordChange.currentPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
@@ -142,7 +144,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
New Password
{t('passwordChange.newPassword')}
</label>
<div className="relative">
<Input
@@ -151,7 +153,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={errors.newPassword}
placeholder="Enter new password"
placeholder={t('passwordChange.newPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
@@ -170,7 +172,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
Confirm New Password
{t('passwordChange.confirmPassword')}
</label>
<div className="relative">
<Input
@@ -179,7 +181,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder="Confirm new password"
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
@@ -200,10 +202,10 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">Password Requirements:</p>
<p className="font-medium">{t('passwordChange.requirements')}</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>At least 6 characters long</li>
<li>Must be different from current password</li>
<li>{t('passwordChange.minLength')}</li>
<li>{t('passwordChange.mustDiffer')}</li>
</ul>
</div>
</div>
@@ -216,14 +218,14 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
variant="outline"
onClick={onClose}
>
Cancel
{t('passwordChange.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={changePasswordMutation.isPending}
>
Change Password
{t('passwordChange.title')}
</Button>
</div>
</form>
@@ -6,11 +6,11 @@ interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>;
selectedCategory: number | null | undefined;
searchTerm: string;
sortBy: 'date' | 'name' | 'size';
sortBy: 'date' | 'name' | 'size' | 'rating';
sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void;
onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size', order: 'asc' | 'desc') => void;
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
}
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
@@ -63,12 +63,13 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<div className="flex items-center gap-2">
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size', sortOrder)}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="size">Sort by Size</option>
<option value="rating">Sort by Rating</option>
</select>
<button
@@ -106,7 +106,6 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
});
totalUploaded += chunk.length;
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name));
@@ -0,0 +1,388 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Plus,
Trash2,
Shield,
AlertTriangle,
XCircle,
Edit2,
Save,
X,
Search
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Card, Button, Input, Loading } from '../common';
import { feedbackService } from '../../services/feedback.service';
interface WordFilter {
id: number;
word: string;
severity: 'low' | 'moderate' | 'high' | 'block';
is_active: boolean;
created_at: string;
updated_at: string;
}
export const WordFilterManager: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [newWord, setNewWord] = useState('');
const [newSeverity, setNewSeverity] = useState<'low' | 'moderate' | 'high' | 'block'>('moderate');
const [searchTerm, setSearchTerm] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editWord, setEditWord] = useState('');
const [editSeverity, setEditSeverity] = useState<'low' | 'moderate' | 'high' | 'block'>('moderate');
// Fetch word filters
const { data: filters = [], isLoading } = useQuery({
queryKey: ['word-filters'],
queryFn: () => feedbackService.getWordFilters()
});
// Add word filter mutation
const addMutation = useMutation({
mutationFn: (data: { word: string; severity: string }) =>
feedbackService.addWordFilter(data.word, data.severity),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
toast.success(t('settings.moderation.filterAdded', 'Word filter added successfully'));
setNewWord('');
setNewSeverity('moderate');
},
onError: (error: any) => {
if (error.response?.status === 409) {
toast.error(t('settings.moderation.filterExists', 'This word filter already exists'));
} else {
toast.error(t('settings.moderation.addError', 'Failed to add word filter'));
}
}
});
// Update word filter mutation
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: number; updates: Partial<WordFilter> }) =>
feedbackService.updateWordFilter(id, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
toast.success(t('settings.moderation.filterUpdated', 'Word filter updated successfully'));
setEditingId(null);
},
onError: () => {
toast.error(t('settings.moderation.updateError', 'Failed to update word filter'));
}
});
// Delete word filter mutation
const deleteMutation = useMutation({
mutationFn: (id: number) => feedbackService.deleteWordFilter(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
toast.success(t('settings.moderation.filterDeleted', 'Word filter deleted successfully'));
},
onError: () => {
toast.error(t('settings.moderation.deleteError', 'Failed to delete word filter'));
}
});
const handleAdd = () => {
if (!newWord.trim()) {
toast.error(t('settings.moderation.wordRequired', 'Please enter a word to filter'));
return;
}
addMutation.mutate({ word: newWord.trim(), severity: newSeverity });
};
const handleEdit = (filter: WordFilter) => {
setEditingId(filter.id);
setEditWord(filter.word);
setEditSeverity(filter.severity);
};
const handleSaveEdit = () => {
if (!editWord.trim()) {
toast.error(t('settings.moderation.wordRequired', 'Please enter a word to filter'));
return;
}
if (editingId) {
updateMutation.mutate({
id: editingId,
updates: { word: editWord.trim(), severity: editSeverity }
});
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditWord('');
setEditSeverity('moderate');
};
const handleToggleActive = (filter: WordFilter) => {
updateMutation.mutate({
id: filter.id,
updates: { is_active: !filter.is_active }
});
};
const handleDelete = (id: number) => {
if (confirm(t('settings.moderation.confirmDelete', 'Are you sure you want to delete this word filter?'))) {
deleteMutation.mutate(id);
}
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'low':
return <Shield className="w-4 h-4 text-blue-500" />;
case 'moderate':
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
case 'high':
return <XCircle className="w-4 h-4 text-orange-500" />;
case 'block':
return <XCircle className="w-4 h-4 text-red-600" />;
default:
return <Shield className="w-4 h-4 text-gray-500" />;
}
};
const getSeverityBadgeClass = (severity: string) => {
switch (severity) {
case 'low':
return 'bg-blue-100 text-blue-800';
case 'moderate':
return 'bg-yellow-100 text-yellow-800';
case 'high':
return 'bg-orange-100 text-orange-800';
case 'block':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const filteredFilters = filters.filter((filter: WordFilter) =>
filter.word.toLowerCase().includes(searchTerm.toLowerCase())
);
if (isLoading) {
return (
<Card>
<div className="p-6">
<Loading text={t('settings.moderation.loading', 'Loading word filters...')} />
</div>
</Card>
);
}
return (
<>
<Card>
<div className="p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
{t('settings.moderation.wordFilters', 'Word Filters')}
</h2>
<p className="text-sm text-neutral-600">
{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}
</p>
</div>
{/* Add new filter */}
<div className="mb-6 p-4 bg-neutral-50 rounded-lg">
<h3 className="text-sm font-medium text-neutral-900 mb-3">
{t('settings.moderation.addFilter', 'Add New Filter')}
</h3>
<div className="flex gap-3">
<Input
type="text"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
placeholder={t('settings.moderation.enterWord', 'Enter word to filter')}
className="flex-1"
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
/>
<select
value={newSeverity}
onChange={(e) => setNewSeverity(e.target.value as any)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
<option value="high">{t('settings.moderation.severityHigh', 'High')}</option>
<option value="block">{t('settings.moderation.severityBlock', 'Block')}</option>
</select>
<Button
variant="primary"
leftIcon={<Plus className="w-4 h-4" />}
onClick={handleAdd}
isLoading={addMutation.isPending}
>
{t('common.add', 'Add')}
</Button>
</div>
</div>
{/* Search */}
<div className="mb-4">
<Input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('settings.moderation.searchFilters', 'Search filters...')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
/>
</div>
{/* Filters list */}
<div className="space-y-2">
{filteredFilters.length === 0 ? (
<div className="text-center py-8 text-neutral-500">
{searchTerm ?
t('settings.moderation.noMatchingFilters', 'No matching filters found') :
t('settings.moderation.noFilters', 'No word filters configured yet')
}
</div>
) : (
filteredFilters.map((filter: WordFilter) => (
<div
key={filter.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
filter.is_active ? 'border-neutral-200 bg-white' : 'border-neutral-100 bg-neutral-50 opacity-60'
}`}
>
{editingId === filter.id ? (
<>
<div className="flex items-center gap-3 flex-1">
<Input
type="text"
value={editWord}
onChange={(e) => setEditWord(e.target.value)}
className="flex-1 max-w-xs"
/>
<select
value={editSeverity}
onChange={(e) => setEditSeverity(e.target.value as any)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
<option value="high">{t('settings.moderation.severityHigh', 'High')}</option>
<option value="block">{t('settings.moderation.severityBlock', 'Block')}</option>
</select>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSaveEdit}
isLoading={updateMutation.isPending}
>
{t('common.save', 'Save')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<X className="w-4 h-4" />}
onClick={handleCancelEdit}
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={filter.is_active}
onChange={() => handleToggleActive(filter)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="font-medium text-neutral-900">{filter.word}</span>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
{getSeverityIcon(filter.severity)}
{filter.severity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
leftIcon={<Edit2 className="w-4 h-4" />}
onClick={() => handleEdit(filter)}
>
{t('common.edit', 'Edit')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => handleDelete(filter.id)}
isLoading={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{t('common.delete', 'Delete')}
</Button>
</div>
</>
)}
</div>
))
)}
</div>
</div>
</Card>
{/* Severity explanation */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">
{t('settings.moderation.severityLevels', 'Severity Levels')}
</h3>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-3">
{getSeverityIcon('low')}
<div>
<span className="font-medium text-neutral-900">{t('settings.moderation.severityLow', 'Low')}: </span>
<span className="text-neutral-600">
{t('settings.moderation.lowDescription', 'Word is flagged for review but not automatically blocked')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('moderate')}
<div>
<span className="font-medium text-neutral-900">{t('settings.moderation.severityModerate', 'Moderate')}: </span>
<span className="text-neutral-600">
{t('settings.moderation.moderateDescription', 'Comment requires manual approval before being visible')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('high')}
<div>
<span className="font-medium text-neutral-900">{t('settings.moderation.severityHigh', 'High')}: </span>
<span className="text-neutral-600">
{t('settings.moderation.highDescription', 'Comment is automatically hidden and requires admin review')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('block')}
<div>
<span className="font-medium text-neutral-900">{t('settings.moderation.severityBlock', 'Block')}: </span>
<span className="text-neutral-600">
{t('settings.moderation.blockDescription', 'Comment is rejected immediately and cannot be submitted')}
</span>
</div>
</div>
</div>
</div>
</Card>
</>
);
};
+2 -1
View File
@@ -28,4 +28,5 @@ export { BackupConfiguration } from './BackupConfiguration';
export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager';