Publish Docker image / build-and-push (push) Successful in 2m6s
- Changed the main entry point from main.jsx to main.tsx for TypeScript support. - Removed Tabler JS import and integrated Tailwind CSS for styling. - Updated package.json and package-lock.json to include new dependencies such as @fontsource-variable/geist and tailwindcss, while removing unused ones. - Enhanced Vite configuration with path aliasing for improved import management. - Deleted unused App.css and App.jsx files to streamline the project structure. Made-with: Cursor
940 lines
38 KiB
React
940 lines
38 KiB
React
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import api from './lib/api.js';
|
||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||
import CommunityBadge from './components/CommunityBadge.jsx';
|
||
import ConfirmModal from './components/ConfirmModal.jsx';
|
||
import {
|
||
IconPlus,
|
||
IconSearch,
|
||
IconEdit,
|
||
IconTrash,
|
||
IconCheck,
|
||
IconX,
|
||
IconDatabase,
|
||
IconRefresh,
|
||
IconUpload,
|
||
IconDownload,
|
||
IconDeviceFloppy,
|
||
IconHash,
|
||
IconClock,
|
||
IconFileText,
|
||
IconFilter,
|
||
IconWorld,
|
||
IconTrendingUp,
|
||
IconUsers,
|
||
IconArrowUp,
|
||
IconArrowDown,
|
||
IconCopy
|
||
} from '@/lib/icons';
|
||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||
import PageHeader from './components/PageHeader.jsx';
|
||
import EmptyState from './components/EmptyState.jsx';
|
||
import { IconAlertTriangle } from '@/lib/icons';
|
||
import { PageHeaderActions } from '@/components/shared/navigation';
|
||
import { ConfirmDialog, ErrorAlert } from '@/components/shared/feedback';
|
||
import WsUpdateModal from './components/WsUpdateModal.jsx';
|
||
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
||
import HistoryModal from './components/HistoryModal.jsx';
|
||
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
||
import ImportModal from './components/ImportModal.jsx';
|
||
import QuickAddBar from './components/QuickAddBar.jsx';
|
||
import AccordionCard from './components/AccordionCard.jsx';
|
||
import SavedFilters from './components/SavedFilters.jsx';
|
||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||
import FormField from './components/FormField.jsx';
|
||
import Tooltip from './components/Tooltip.jsx';
|
||
import Pagination from './components/Pagination.jsx';
|
||
import AddItemModal from './components/AddItemModal.jsx';
|
||
import LastSaved from './components/LastSaved.jsx';
|
||
import FormModal from './components/FormModal.jsx';
|
||
import { useToast } from './components/ToastContainer.jsx';
|
||
import {
|
||
listCommunities,
|
||
fetchDomainsData,
|
||
saveDomainsData,
|
||
} from './lib/evobgpData.js';
|
||
|
||
function DomainsNewManager() {
|
||
const toast = useToast();
|
||
const [items, setItems] = useState([]);
|
||
const [originalItems, setOriginalItems] = useState([]);
|
||
const [, setEtag] = useState('');
|
||
const [lastModified, setLastModified] = useState('');
|
||
const [, setContentLength] = useState(null);
|
||
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
||
const [, setNewInvalid] = useState({ domain: false, community: false });
|
||
const [error, setError] = useState('');
|
||
const [success, setSuccess] = useState('');
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const _searchTimer = useRef(null);
|
||
const didInit = useRef(false);
|
||
const [editingValue, setEditingValue] = useState('');
|
||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||
const [itemBeingEdited, setItemBeingEdited] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||
const [itemToDelete, setItemToDelete] = useState(null);
|
||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [totalItems, setTotalItems] = useState(0);
|
||
const [sortField, setSortField] = useState('domain');
|
||
const [sortOrder, setSortOrder] = useState('asc');
|
||
const [filterCommunity, setFilterCommunity] = useState('');
|
||
const pageSize = 10;
|
||
|
||
// Bulk operations
|
||
const [selectedDomains, setSelectedDomains] = useState(new Set());
|
||
|
||
// Справочник community для подсказок
|
||
const [communities, setCommunities] = useState([]);
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
const signal = controller.signal;
|
||
(async () => {
|
||
try {
|
||
const list = await listCommunities();
|
||
setCommunities(list);
|
||
} catch (err) {
|
||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||
}
|
||
})();
|
||
(async () => {
|
||
try {
|
||
const r = await api.get('/ws/url', { signal });
|
||
setWsUrl(String(r.data?.url || ''));
|
||
} catch (err) {
|
||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||
}
|
||
})();
|
||
return () => controller.abort();
|
||
}, []);
|
||
|
||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||
|
||
const isValidDomain = (value) => {
|
||
const v = String(value).trim().toLowerCase();
|
||
// Простой валидатор FQDN: sub.domain.tld
|
||
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v);
|
||
};
|
||
|
||
// Допускаем как числовые, так и строковые (AS:NNN) community
|
||
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
||
|
||
const [wsOpen, setWsOpen] = useState(false);
|
||
const [wsUrl, setWsUrl] = useState('');
|
||
|
||
const fetchItems = useCallback(async (_abortSignal) => {
|
||
setLoading(true);
|
||
try {
|
||
const payload = await fetchDomainsData();
|
||
const total = payload.length;
|
||
setItems(payload);
|
||
setOriginalItems(payload);
|
||
setTotalItems(Number.isFinite(total) ? total : 0);
|
||
setEtag('');
|
||
setLastModified(new Date().toISOString());
|
||
setContentLength(null);
|
||
setError('');
|
||
} catch (error) {
|
||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||
console.error('Error fetching domains-new:', error);
|
||
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!didInit.current) {
|
||
didInit.current = true;
|
||
const controller = new AbortController();
|
||
fetchItems(controller.signal);
|
||
return () => controller.abort();
|
||
}
|
||
}, [fetchItems]);
|
||
|
||
const handleAddItem = (item) => {
|
||
// Если item передан (из модалки), используем его, иначе из state (старый способ)
|
||
const domain = item?.domain || newItem.domain;
|
||
const community = item?.community || newItem.community;
|
||
|
||
const domainOk = isValidDomain(domain);
|
||
const communityOk = isValidCommunity(community);
|
||
|
||
if (!domainOk || !communityOk) {
|
||
setError('Заполните корректный домен (example.com) и числовой community.');
|
||
return;
|
||
}
|
||
|
||
setError('');
|
||
const newDomain = { domain: domain.trim().toLowerCase(), community: String(community).trim() };
|
||
setItems([...items, newDomain]);
|
||
if (!item) setNewItem({ domain: '', community: '' }); // Сброс только если не из модалки
|
||
setNewInvalid({ domain: false, community: false });
|
||
};
|
||
|
||
const handleEdit = (item) => {
|
||
setItemBeingEdited(item);
|
||
setEditingValue(item.community);
|
||
setEditModalOpen(true);
|
||
};
|
||
|
||
const handleSaveEdit = () => {
|
||
if (!itemBeingEdited) return;
|
||
if (!isValidCommunity(editingValue)) {
|
||
setError('Community должен быть числом.');
|
||
return;
|
||
}
|
||
const updatedItems = items.map(i =>
|
||
i.domain === itemBeingEdited.domain ? { ...i, community: String(editingValue).trim() } : i
|
||
);
|
||
setItems(updatedItems);
|
||
setEditModalOpen(false);
|
||
setItemBeingEdited(null);
|
||
};
|
||
|
||
const handleCancelEdit = () => {
|
||
setEditModalOpen(false);
|
||
setItemBeingEdited(null);
|
||
};
|
||
|
||
const handleDeleteItem = (domainNameToDelete) => {
|
||
setItems(items.filter(i => i.domain !== domainNameToDelete));
|
||
setSelectedDomains(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(domainNameToDelete);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const copyToClipboard = (text) => {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
toast.success('Скопировано в буфер обмена');
|
||
}).catch(() => {
|
||
toast.error('Не удалось скопировать');
|
||
});
|
||
};
|
||
|
||
const confirmDelete = (item) => {
|
||
setItemToDelete(item);
|
||
setShowDeleteModal(true);
|
||
};
|
||
|
||
const executeDelete = () => {
|
||
if (itemToDelete) {
|
||
handleDeleteItem(itemToDelete.domain);
|
||
setShowDeleteModal(false);
|
||
setItemToDelete(null);
|
||
}
|
||
};
|
||
|
||
const deduplicate = (arr) => {
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const i of arr) {
|
||
const key = String(i.domain).trim().toLowerCase();
|
||
if (!key) continue;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
out.push({ domain: key, community: String(i.community || '').trim() });
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const computeDiff = (before, after) => {
|
||
const mapBefore = new Map(before.map(i => [i.domain, i]));
|
||
const mapAfter = new Map(after.map(i => [i.domain, i]));
|
||
const added = [];
|
||
const removed = [];
|
||
const changed = [];
|
||
for (const [k, v] of mapAfter) {
|
||
if (!mapBefore.has(k)) { added.push(v); continue; }
|
||
const prev = mapBefore.get(k);
|
||
if (String(prev.community) !== String(v.community)) changed.push({ from: prev, to: v });
|
||
}
|
||
for (const [k, v] of mapBefore) {
|
||
if (!mapAfter.has(k)) removed.push(v);
|
||
}
|
||
return { added, removed, changed };
|
||
};
|
||
|
||
const [showDiff, setShowDiff] = useState(false);
|
||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
||
|
||
const handlePreviewDiff = () => {
|
||
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
|
||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||
const unique = deduplicate(valid);
|
||
setDiff(computeDiff(originalItems, unique));
|
||
setShowDiff(true);
|
||
};
|
||
|
||
const performSave = useCallback(async () => {
|
||
setConfirmSaveOpen(false);
|
||
setLoading(true);
|
||
try {
|
||
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||
const unique = deduplicate(valid);
|
||
|
||
await saveDomainsData(unique, originalItems);
|
||
setEtag('');
|
||
// После сохранения перезагрузим текущую страницу для консистентности
|
||
await fetchItems();
|
||
setSuccess('Изменения успешно сохранены!');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
notifyMutationSuccess('Изменения сохранены');
|
||
} catch (error) {
|
||
console.error('Error saving changes:', error);
|
||
setError('Не удалось сохранить изменения.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [items, originalItems, fetchItems]);
|
||
|
||
const handleSaveChanges = useCallback(async () => {
|
||
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
|
||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||
const unique = deduplicate(valid);
|
||
const diffData = computeDiff(originalItems, unique);
|
||
setDiff(diffData);
|
||
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
|
||
if (totalChanges > 10) {
|
||
setConfirmSaveOpen(true);
|
||
} else {
|
||
performSave();
|
||
}
|
||
}, [items, originalItems, performSave]);
|
||
|
||
// Drag&Drop импорт
|
||
const _onDropImport = async (e) => {
|
||
e.preventDefault();
|
||
const file = e.dataTransfer?.files?.[0];
|
||
if (!file) return;
|
||
const text = await file.text();
|
||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||
const parsed = lines.map(l => {
|
||
const [d, c] = l.split(/\s+/);
|
||
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
|
||
}).filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community));
|
||
setItems(prev => {
|
||
const merged = [...prev, ...parsed];
|
||
const map = new Map();
|
||
for (const it of merged) {
|
||
const key = String(it.domain).trim().toLowerCase();
|
||
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
||
}
|
||
return Array.from(map.values());
|
||
});
|
||
};
|
||
|
||
const handleImport = () => {
|
||
setImportOpen(true);
|
||
};
|
||
|
||
const handleExport = async () => {
|
||
try {
|
||
const all = filtered;
|
||
const header = ['domain', 'community'];
|
||
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.community || ''])]
|
||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||
.join('\n');
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `domains_${new Date().toISOString().split('T')[0]}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch (e) {
|
||
window.notify?.error('Экспорт доменов не удался', String(e?.message || e));
|
||
}
|
||
};
|
||
|
||
const clearInvalid = () => {
|
||
setItems(prev => prev.filter(i => i != null && (i.domain || i.community))
|
||
.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||
);
|
||
};
|
||
|
||
// ===== Импорт через модалку =====
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
const parseLine = (line) => {
|
||
const [d, c] = String(line).split(/\s+/);
|
||
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
|
||
};
|
||
const validateItem = (obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community);
|
||
const applyImportedItems = (list) => {
|
||
setItems(prev => {
|
||
const merged = [...prev, ...list];
|
||
const map = new Map();
|
||
for (const it of merged) {
|
||
const key = String(it.domain).trim().toLowerCase();
|
||
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
||
}
|
||
return Array.from(map.values());
|
||
});
|
||
setImportOpen(false);
|
||
};
|
||
|
||
// Сортировка
|
||
const sortedItems = [...items].sort((a, b) => {
|
||
let valA = a[sortField] || '';
|
||
let valB = b[sortField] || '';
|
||
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
|
||
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
// Локальная фильтрация и пагинация
|
||
const filtered = sortedItems.filter(i => {
|
||
if (!i) return false;
|
||
const byCommunity = !filterCommunity || i.community === filterCommunity;
|
||
const term = String(searchTerm || '').toLowerCase();
|
||
const bySearch = !term || String(i.domain || '').toLowerCase().includes(term);
|
||
return byCommunity && bySearch;
|
||
});
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
|
||
|
||
// Для фильтра - список всех уникальных community
|
||
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => i.community).filter(c => c != null)));
|
||
|
||
// Сортировка по клику
|
||
const handleSort = (field) => {
|
||
if (sortField === field) {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField(field);
|
||
setSortOrder('asc');
|
||
}
|
||
};
|
||
|
||
// Глобальные хоткеи: Ctrl+S — сохранить изменения
|
||
useEffect(() => {
|
||
const onKey = (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||
e.preventDefault();
|
||
if (!loading) handleSaveChanges();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [loading, handleSaveChanges]);
|
||
|
||
const handleEditKeyDown = (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
handleSaveEdit();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
handleCancelEdit();
|
||
}
|
||
};
|
||
|
||
// Bulk operations handlers
|
||
const handleSelectAll = () => {
|
||
setSelectedDomains(new Set(paginatedItems.map(i => i.domain)));
|
||
};
|
||
|
||
const handleDeselectAll = () => {
|
||
setSelectedDomains(new Set());
|
||
};
|
||
|
||
const handleToggleSelect = (domain) => {
|
||
const newSet = new Set(selectedDomains);
|
||
if (newSet.has(domain)) {
|
||
newSet.delete(domain);
|
||
} else {
|
||
newSet.add(domain);
|
||
}
|
||
setSelectedDomains(newSet);
|
||
};
|
||
|
||
const handleBulkDelete = () => {
|
||
if (selectedDomains.size === 0) return;
|
||
const count = selectedDomains.size;
|
||
const newItems = items.filter(i => !selectedDomains.has(i.domain));
|
||
setItems(newItems);
|
||
setSelectedDomains(new Set());
|
||
toast.success(`Удалено ${count} доменов`);
|
||
};
|
||
|
||
const handleBulkExport = () => {
|
||
if (selectedDomains.size === 0) return;
|
||
const selectedItems = items.filter(i => i != null && selectedDomains.has(i.domain));
|
||
const text = selectedItems.map(i => `${i.domain || ''} ${i.community || ''}`).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 = `domains-selected-${selectedDomains.size}.txt`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
toast.success(`Экспортировано ${selectedDomains.size} доменов`);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{error && (
|
||
<ErrorAlert message={error} onClose={() => setError('')} />
|
||
)}
|
||
|
||
{success && (
|
||
<div className="alert alert-success alert-dismissible" role="alert">
|
||
<div className="d-flex">
|
||
<div>
|
||
<IconCheck className="icon alert-icon" />
|
||
</div>
|
||
<div>
|
||
{success}
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||
</div>
|
||
)}
|
||
|
||
<PageHeader
|
||
title="Домены"
|
||
icon={<IconWorld size={24} />}
|
||
meta={<LastSaved timestamp={lastModified} variant="compact" />}
|
||
actions={(
|
||
<PageHeaderActions
|
||
loading={loading}
|
||
onPreview={handlePreviewDiff}
|
||
disablePreview={loading || items.length === 0}
|
||
onRefresh={fetchItems}
|
||
disableRefresh={loading}
|
||
onImport={handleImport}
|
||
onExport={handleExport}
|
||
disableExport={items.length === 0}
|
||
onClear={clearInvalid}
|
||
disableClear={items.length === 0}
|
||
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
|
||
disableClearCommunities={items.length === 0 || !filterCommunity}
|
||
onSave={handleSaveChanges}
|
||
disableSave={loading}
|
||
onHistory={() => setHistoryOpen(true)}
|
||
lastModified={lastModified}
|
||
onOnlineUpdate={() => setWsOpen(true)}
|
||
onBackgroundUpdate={async () => {
|
||
try {
|
||
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || data.ok === false) {
|
||
throw new Error(data?.message || `HTTP ${res.status}`);
|
||
}
|
||
notifyMutationSuccess('Фоновое обновление запущено', data);
|
||
} catch (e) {
|
||
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
|
||
<AddItemModal
|
||
open={addModalOpen}
|
||
onClose={() => setAddModalOpen(false)}
|
||
onSubmit={handleAddItem}
|
||
type="domain"
|
||
communities={communities}
|
||
loading={loading}
|
||
/>
|
||
|
||
<div className="row">
|
||
<div className="col-12">
|
||
{/* Bulk Actions Bar */}
|
||
<BulkActionsBar
|
||
selectedCount={selectedDomains.size}
|
||
totalCount={paginatedItems.length}
|
||
onSelectAll={handleSelectAll}
|
||
onDeselectAll={handleDeselectAll}
|
||
onDelete={handleBulkDelete}
|
||
onExport={handleBulkExport}
|
||
/>
|
||
|
||
<div className="card card-flat animate-in" style={{animationDelay: '0.3s'}}>
|
||
<div className="card-header">
|
||
<div className="row align-items-center w-100 g-2">
|
||
<div className="col">
|
||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<IconWorld className="icon text-muted" size={18} />
|
||
<span className="text-muted text-uppercase small">Список</span>
|
||
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
|
||
</div>
|
||
<div className="d-none d-md-block text-muted small">
|
||
Всего записей: {filtered.length}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-12 mt-2"></div>
|
||
<div className="col">
|
||
<div className="d-flex gap-2 flex-wrap">
|
||
{/* Сохранённые фильтры */}
|
||
<SavedFilters
|
||
pageKey="domains"
|
||
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
||
onApplyFilter={(filters) => {
|
||
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
||
if (filters.filterCommunity !== undefined) setFilterCommunity(filters.filterCommunity);
|
||
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
||
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
||
setCurrentPage(1);
|
||
}}
|
||
/>
|
||
|
||
{/* Фильтр по community */}
|
||
<div className="input-icon">
|
||
<span className="input-icon-addon">
|
||
<IconFilter size={18} />
|
||
</span>
|
||
<select
|
||
className="form-select"
|
||
style={{ minWidth: '200px' }}
|
||
value={filterCommunity}
|
||
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
||
title={(() => {
|
||
const meta = communities.find(c => c.value === filterCommunity);
|
||
if (!meta) return 'Все community';
|
||
const name = meta.name ? meta.name + ' — ' : '';
|
||
const desc = meta.description || '';
|
||
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
||
return name + desc + tags;
|
||
})()}
|
||
>
|
||
<option value="">Все community</option>
|
||
{allCommunities.map(community => {
|
||
const meta = communities.find(c => c.value === community);
|
||
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
||
return (
|
||
<option key={community} value={community} title={title}>{label}</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Поиск */}
|
||
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
||
<span className="input-icon-addon">
|
||
<IconSearch size={18} />
|
||
</span>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
placeholder="Поиск домена..."
|
||
value={searchTerm}
|
||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||
aria-label="Поиск домена"
|
||
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
||
/>
|
||
{searchTerm && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||
style={{
|
||
position: 'absolute',
|
||
right: '0.25rem',
|
||
top: '50%',
|
||
transform: 'translateY(-50%)',
|
||
zIndex: 2
|
||
}}
|
||
onClick={() => { setSearchTerm(''); setCurrentPage(1); }}
|
||
aria-label="Очистить поиск"
|
||
>
|
||
<IconX size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-auto">
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => setAddModalOpen(true)}
|
||
title="Добавить новый домен"
|
||
>
|
||
<IconPlus className="me-1" size={18} />
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card-body p-0">
|
||
{/* Таблица с лёгкой карточной подложкой (плоский стиль) */}
|
||
{loading ? (
|
||
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
|
||
) : paginatedItems.length === 0 ? (
|
||
<TableEmpty cols={3} flat>
|
||
<EmptyState
|
||
title="Нет доменов"
|
||
description="Импортируйте или добавьте записи, чтобы начать."
|
||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
||
const header = ['domain','community'];
|
||
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
|
||
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
||
}}>Шаблон CSV</button>}
|
||
/>
|
||
</TableEmpty>
|
||
) : (
|
||
<div className="table-responsive">
|
||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: '40px' }}>
|
||
<input
|
||
type="checkbox"
|
||
className="form-check-input"
|
||
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
|
||
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
||
/>
|
||
</th>
|
||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
|
||
<div className="d-flex align-items-center">
|
||
<IconWorld size={16} className="me-1 text-muted" />
|
||
Домен
|
||
{sortField === 'domain' && (
|
||
<span className="ms-1">
|
||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||
<div className="d-flex align-items-center">
|
||
<IconHash size={16} className="me-1 text-muted" />
|
||
Community
|
||
{sortField === 'community' && (
|
||
<span className="ms-1">
|
||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th className="text-end">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginatedItems.map((item, idx) => (
|
||
<tr
|
||
key={item.domain}
|
||
className={selectedDomains.has(item.domain) ? 'table-selected' : ''}
|
||
style={{
|
||
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
|
||
}}
|
||
>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
className="form-check-input"
|
||
checked={selectedDomains.has(item.domain)}
|
||
onChange={() => handleToggleSelect(item.domain)}
|
||
aria-label={`Выбрать ${item.domain}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="d-flex align-items-center">
|
||
<IconWorld size={16} className="me-2 text-muted" />
|
||
<span className="font-monospace">{item.domain}</span>
|
||
<Tooltip content="Копировать домен" position="top">
|
||
<button
|
||
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||
onClick={() => copyToClipboard(item.domain)}
|
||
aria-label="Копировать"
|
||
title="Копировать домен"
|
||
>
|
||
<IconCopy size={14} />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div className="d-flex align-items-center">
|
||
{renderCommunityBadge(item.community)}
|
||
</div>
|
||
</td>
|
||
<td className="text-end">
|
||
<div className="btn-group btn-group-sm" role="group" aria-label={`Действия с доменом ${item.domain}`}>
|
||
<Tooltip content="Редактировать" position="top">
|
||
<button
|
||
className="btn btn-icon btn-outline-primary"
|
||
aria-label="Редактировать"
|
||
onClick={() => handleEdit(item)}
|
||
title="Редактировать"
|
||
>
|
||
<IconEdit size={16} />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip content="Удалить" position="top">
|
||
<button
|
||
className="btn btn-icon btn-outline-danger"
|
||
aria-label="Удалить"
|
||
onClick={() => confirmDelete(item)}
|
||
title="Удалить"
|
||
>
|
||
<IconTrash size={16} />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="card-footer border-top-0">
|
||
<Pagination
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
totalItems={totalItems || filtered.length}
|
||
pageSize={pageSize}
|
||
onPageChange={setCurrentPage}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ConfirmModal
|
||
show={showDeleteModal}
|
||
onClose={() => setShowDeleteModal(false)}
|
||
onConfirm={executeDelete}
|
||
title="Удалить домен?"
|
||
message={
|
||
itemToDelete ? (
|
||
<>
|
||
<p>Вы уверены, что хотите удалить <strong>"{itemToDelete.domain}"</strong>?</p>
|
||
<p className="text-muted mb-0">Это действие необратимо.</p>
|
||
</>
|
||
) : null
|
||
}
|
||
confirmLabel="Удалить"
|
||
variant="danger"
|
||
icon={IconTrash}
|
||
/>
|
||
{/* Confirm Save Modal */}
|
||
<ConfirmDiffModal
|
||
show={confirmSaveOpen}
|
||
diff={diff}
|
||
onConfirm={performSave}
|
||
onClose={() => setConfirmSaveOpen(false)}
|
||
/>
|
||
<ImportModal
|
||
show={importOpen}
|
||
title={'Импорт доменов'}
|
||
description={'Формат: DOMAIN ПРОБЕЛ COMMUNITY'}
|
||
parseLine={parseLine}
|
||
validateItem={validateItem}
|
||
onConfirm={applyImportedItems}
|
||
onClose={() => setImportOpen(false)}
|
||
sampleHeader={['domain','community']}
|
||
placeholder={'example.com 65000:100'}
|
||
/>
|
||
<HistoryModal
|
||
resource="domains-new"
|
||
show={historyOpen}
|
||
onClose={() => setHistoryOpen(false)}
|
||
onRolledBack={() => fetchItems()}
|
||
/>
|
||
<WsUpdateModal
|
||
show={wsOpen}
|
||
url={wsUrl}
|
||
onClose={() => setWsOpen(false)}
|
||
/>
|
||
{/* Модальное окно редактирования community */}
|
||
<FormModal
|
||
show={editModalOpen && !!itemBeingEdited}
|
||
onClose={handleCancelEdit}
|
||
onSubmit={handleSaveEdit}
|
||
title="Редактировать community"
|
||
submitLabel="Сохранить"
|
||
submitIcon={IconCheck}
|
||
size="md"
|
||
centered
|
||
disabled={!isValidCommunity(editingValue)}
|
||
>
|
||
{itemBeingEdited && (
|
||
<>
|
||
<p className="text-muted mb-2">
|
||
Домен: <span className="font-monospace">{itemBeingEdited.domain}</span>
|
||
</p>
|
||
<div className="mb-3">
|
||
<label className="form-label">Community</label>
|
||
<CommunityAutocompleteInput
|
||
value={editingValue}
|
||
onChange={setEditingValue}
|
||
communities={communities}
|
||
onKeyDown={handleEditKeyDown}
|
||
className={`form-control${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||
useShortValue={true}
|
||
/>
|
||
{!isValidCommunity(editingValue) && (
|
||
<div className="invalid-feedback d-block">
|
||
Введите корректное значение community (число или N:N).
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</FormModal>
|
||
<ConfirmModal
|
||
show={clearCommunitiesOpen}
|
||
onClose={() => setClearCommunitiesOpen(false)}
|
||
onConfirm={() => {
|
||
setItems(prev => prev.filter(i => i != null && i.community !== filterCommunity));
|
||
setClearCommunitiesOpen(false);
|
||
}}
|
||
title="Удалить записи community?"
|
||
message={
|
||
<>
|
||
<p>Будут удалены все домены с community <strong>"{filterCommunity}"</strong>.</p>
|
||
<p className="text-warning mb-0">
|
||
<IconAlertTriangle size={16} className="me-1" />
|
||
Действие НЕ сохраняет изменения автоматически.
|
||
</p>
|
||
</>
|
||
}
|
||
confirmLabel="Удалить"
|
||
variant="warning"
|
||
/>
|
||
{/* Diff Modal */}
|
||
{showDiff && (
|
||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||
<div className="modal-content">
|
||
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
|
||
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
|
||
<div className="modal-body">
|
||
<div className="row g-2 g-md-3">
|
||
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
|
||
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
|
||
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn" onClick={() => setShowDiff(false)}>Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default DomainsNewManager; |