import { useState, useEffect, useRef, useMemo } from 'react'; import api from './lib/api.js'; import { IconPlus, IconTrash, IconDownload, IconAlertCircle, IconCheck, IconUpload, IconDeviceFloppy, IconInfoCircle, IconCircleCheck, IconCopy, IconSearch, IconLink, IconHash, IconWorld, IconFileExport, IconSortAscending, IconSortDescending, IconX } from '@tabler/icons-react'; import PageHeader from './components/PageHeader.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 [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(); }, []); const fetchUrls = async () => { try { setLoading(true); setError(''); const response = await api.get('/auto-urls'); setUrls(response.data || []); } catch (err) { console.error('Error fetching URLs:', err); setError('Ошибка при загрузке URL-адресов'); } finally { setLoading(false); } }; const addUrl = () => { setUrls([...urls, { url: '', community: '' }]); }; const clearEmptyRows = () => { setUrls(urls.filter(u => u != null && (u.url || u.community))); }; const removeUrl = (index) => { setUrls(urls.filter((_, i) => i !== index)); }; const updateUrl = (index, field, value) => { const newUrls = [...urls]; newUrls[index][field] = value; setUrls(newUrls); setTouched(prev => ({ ...prev, [index]: true })); }; const isValidHttpUrl = (value) => { try { const u = new URL(value); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; } }; const isValidCommunity = (value) => { return /^[0-9]+$/.test(String(value).trim()); }; const getRowValidity = (row) => ({ url: row.url ? isValidHttpUrl(row.url) : false, community: row.community ? isValidCommunity(row.community) : false }); const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community)); const saveUrls = async () => { try { setSaving(true); 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() })); await api.post('/auto-urls', { urls: validUrls }); setUrls(validUrls); setLastSaved(new Date().toISOString()); setSuccess(validUrls.length === 0 ? 'Список URL очищен' : 'URL-адреса сохранены успешно'); setSelectedRows(new Set()); setTimeout(() => setSuccess(''), 3000); } catch (err) { console.error('Error saving URLs:', err); setError('Ошибка при сохранении URL-адресов'); } finally { setSaving(false); } }; const processUrls = async () => { try { setProcessing(true); setError(''); setSuccess(''); const response = await api.post('/auto-urls/process'); 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); } }; const copyExample = async () => { const text = 'https://test.com/ips.txt 555\nhttps://example.com/blacklist.txt 666'; try { if (navigator.clipboard) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } setSuccess('Пример формата скопирован'); setTimeout(() => setSuccess(''), 2000); } catch (e) { 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(); // Разрешить сохранение: либо пустой список, либо есть валидные строки const canSave = urls.length === 0 || hasAtLeastOneValidRow; if (canSave && !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); }, [urls.length, hasAtLeastOneValidRow, saving, processing, saveUrls]); return (
| 0 && filteredAndSortedUrls.every(url => { const idx = urls.findIndex(u => u === url); return selectedRows.has(idx); })} onChange={toggleSelectAll} title="Выбрать все" /> | toggleSort('url')}
style={{ minWidth: 300 }}
>
|
toggleSort('community')}
style={{ minWidth: 150 }}
>
|
Статус | Действия |
|---|---|---|---|---|
| toggleSelectRow(originalIndex)} /> |
{v.url ? (
{isInvalid && !v.url && (
Укажите корректный http/https URL
)}
|
{v.community ? (
{isInvalid && !v.community && (
Только цифры, например 555
)}
|
{isValid ? (
|
bgp_data/ips.txt, домены — в bgp_data/domains_community.txt
{`https://test.com/ips.txt 555\nhttps://example.com/blacklist.txt 666`}