diff --git a/frontend/src/AutoUrlManager.jsx b/frontend/src/AutoUrlManager.jsx index d79e437..630d371 100644 --- a/frontend/src/AutoUrlManager.jsx +++ b/frontend/src/AutoUrlManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import api from './lib/api.js'; import { IconPlus, @@ -6,28 +6,43 @@ import { IconDownload, IconAlertCircle, IconCheck, - IconLoader, IconUpload, IconDeviceFloppy, IconInfoCircle, IconCircleCheck, - IconCopy + IconCopy, + IconSearch, + IconLink, + IconHash, + IconWorld, + IconFileExport, + IconSortAscending, + IconSortDescending, + IconX } from '@tabler/icons-react'; import PageHeader from './components/PageHeader.jsx'; -import PageHeaderActions from './components/PageHeaderActions.jsx'; -import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; +import TableSkeleton from './components/TableSkeleton.jsx'; import EmptyState from './components/EmptyState.jsx'; import ImportModal from './components/ImportModal.jsx'; +import ErrorAlert from './components/ErrorAlert.jsx'; +import LastSaved from './components/LastSaved.jsx'; function AutoUrlManager() { const [urls, setUrls] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [processing, setProcessing] = useState(false); - const [message, setMessage] = useState(''); - const [messageType, setMessageType] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); const [touched, setTouched] = useState({}); const [importOpen, setImportOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [filterValid, setFilterValid] = useState('all'); // 'all' | 'valid' | 'invalid' + const [sortField, setSortField] = useState(null); // 'url' | 'community' | null + const [sortDir, setSortDir] = useState('asc'); // 'asc' | 'desc' + const [lastSaved, setLastSaved] = useState(null); + const [selectedRows, setSelectedRows] = useState(new Set()); + const searchInputRef = useRef(null); useEffect(() => { fetchUrls(); @@ -36,12 +51,12 @@ function AutoUrlManager() { const fetchUrls = async () => { try { setLoading(true); + setError(''); const response = await api.get('/auto-urls'); - setUrls(response.data); - } catch (error) { - console.error('Error fetching URLs:', error); - setMessage('Ошибка при загрузке URL-адресов'); - setMessageType('error'); + setUrls(response.data || []); + } catch (err) { + console.error('Error fetching URLs:', err); + setError('Ошибка при загрузке URL-адресов'); } finally { setLoading(false); } @@ -89,26 +104,27 @@ function AutoUrlManager() { const saveUrls = async () => { try { setSaving(true); - setMessage(''); + setError(''); + setSuccess(''); // Validate URLs const validUrls = urls .filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)) .map(u => ({ url: u.url.trim(), community: String(u.community).trim() })); if (validUrls.length === 0) { - setMessage('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)'); - setMessageType('error'); + setError('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)'); return; } await api.post('/auto-urls', { urls: validUrls }); setUrls(validUrls); - setMessage('URL-адреса сохранены успешно'); - setMessageType('success'); - } catch (error) { - console.error('Error saving URLs:', error); - setMessage('Ошибка при сохранении URL-адресов'); - setMessageType('error'); + setLastSaved(new Date().toISOString()); + setSuccess('URL-адреса сохранены успешно'); + setSelectedRows(new Set()); + setTimeout(() => setSuccess(''), 3000); + } catch (err) { + console.error('Error saving URLs:', err); + setError('Ошибка при сохранении URL-адресов'); } finally { setSaving(false); } @@ -117,15 +133,15 @@ function AutoUrlManager() { const processUrls = async () => { try { setProcessing(true); - setMessage(''); + setError(''); + setSuccess(''); const response = await api.post('/auto-urls/process'); - setMessage(response.data.message); - setMessageType('success'); - } catch (error) { - console.error('Error processing URLs:', error); - setMessage(error.response?.data || 'Ошибка при обработке URL-адресов'); - setMessageType('error'); + setSuccess(response.data?.message || 'URL-адреса успешно обработаны'); + setTimeout(() => setSuccess(''), 5000); + } catch (err) { + console.error('Error processing URLs:', err); + setError(err.response?.data?.message || err.response?.data || 'Ошибка при обработке URL-адресов'); } finally { setProcessing(false); } @@ -144,23 +160,127 @@ function AutoUrlManager() { document.execCommand('copy'); document.body.removeChild(ta); } - setMessage('Пример формата скопирован'); - setMessageType('success'); - setTimeout(() => setMessage(''), 2000); + setSuccess('Пример формата скопирован'); + setTimeout(() => setSuccess(''), 2000); } catch (e) { - setMessage('Не удалось скопировать пример'); - setMessageType('error'); - setTimeout(() => setMessage(''), 2000); + setError('Не удалось скопировать пример'); + setTimeout(() => setError(''), 2000); } }; + // Статистика + const stats = useMemo(() => { + const valid = urls.filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)).length; + const invalid = urls.length - valid; + return { total: urls.length, valid, invalid }; + }, [urls]); + + // Фильтрация и сортировка + const filteredAndSortedUrls = useMemo(() => { + let filtered = urls.filter(u => { + if (!u) return false; + const matchesSearch = !searchTerm || + u.url.toLowerCase().includes(searchTerm.toLowerCase()) || + String(u.community).toLowerCase().includes(searchTerm.toLowerCase()); + if (!matchesSearch) return false; + + if (filterValid === 'valid') { + return isValidHttpUrl(u.url) && isValidCommunity(u.community); + } else if (filterValid === 'invalid') { + return !isValidHttpUrl(u.url) || !isValidCommunity(u.community); + } + return true; + }); + + if (sortField) { + filtered = [...filtered].sort((a, b) => { + const aVal = String(a[sortField] || '').toLowerCase(); + const bVal = String(b[sortField] || '').toLowerCase(); + const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + return sortDir === 'asc' ? comparison : -comparison; + }); + } + + return filtered; + }, [urls, searchTerm, filterValid, sortField, sortDir]); + + const toggleSort = (field) => { + if (sortField === field) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDir('asc'); + } + }; + + const toggleSelectRow = (originalIndex) => { + setSelectedRows(prev => { + const next = new Set(prev); + if (next.has(originalIndex)) next.delete(originalIndex); + else next.add(originalIndex); + return next; + }); + }; + + const toggleSelectAll = () => { + const allOriginalIndices = filteredAndSortedUrls.map(url => urls.findIndex(u => u === url)); + const allSelected = allOriginalIndices.every(idx => selectedRows.has(idx)); + + if (allSelected) { + setSelectedRows(new Set()); + } else { + setSelectedRows(new Set(allOriginalIndices)); + } + }; + + const deleteSelected = () => { + setUrls(prev => prev.filter((_, i) => !selectedRows.has(i))); + setSelectedRows(new Set()); + }; + + const exportUrls = () => { + const text = urls + .filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)) + .map(u => `${u.url.trim()} ${String(u.community).trim()}`) + .join('\n'); + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `auto-urls-${new Date().toISOString().split('T')[0]}.txt`; + a.click(); + URL.revokeObjectURL(url); + }; + + // Горячие клавиши + useEffect(() => { + const handleKeyDown = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + if (hasAtLeastOneValidRow && !saving && !processing) { + saveUrls(); + } + } + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); + searchInputRef.current?.focus(); + } + if (e.key === 'Escape') { + setSearchTerm(''); + setFilterValid('all'); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [hasAtLeastOneValidRow, saving, processing, saveUrls]); + return (