feat: Add new API endpoints for managing domains and integrate DomainsNewManager in the frontend for enhanced domain management functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m24s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m24s
This commit is contained in:
+53
-1
@@ -126,6 +126,56 @@ app.post('/api/asns', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Domains New Routes ---
|
||||||
|
|
||||||
|
// Get domains-new from S3
|
||||||
|
app.get('/api/domains-new', async (req, res) => {
|
||||||
|
const params = {
|
||||||
|
Bucket: BUCKET_NAME,
|
||||||
|
Key: 'domains_comunity.txt',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await s3.getObject(params).promise();
|
||||||
|
const fileContent = data.Body.toString('utf-8');
|
||||||
|
const domains = fileContent.split('\n').filter(line => line).map(line => {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
const domain = parts[0] || '';
|
||||||
|
const community = parts[1] || '';
|
||||||
|
return { domain, community };
|
||||||
|
});
|
||||||
|
res.json(domains);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'NoSuchKey') {
|
||||||
|
res.json([]); // Return empty array if file does not exist
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send('Error reading from S3');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update domains-new in S3
|
||||||
|
app.post('/api/domains-new', async (req, res) => {
|
||||||
|
const { domains } = req.body;
|
||||||
|
const fileContent = domains.map(d => `${d.domain} ${d.community}`).join('\n');
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
Bucket: BUCKET_NAME,
|
||||||
|
Key: 'domains_comunity.txt',
|
||||||
|
Body: fileContent,
|
||||||
|
ContentType: 'text/plain',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3.putObject(params).promise();
|
||||||
|
res.send('File updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send('Error writing to S3');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Servers Routes (JSON format) ---
|
// --- Servers Routes (JSON format) ---
|
||||||
|
|
||||||
// Get servers from S3
|
// Get servers from S3
|
||||||
@@ -267,14 +317,16 @@ app.post('/api/filters', async (req, res) => {
|
|||||||
// Новый эндпоинт для получения дат последнего изменения файлов S3
|
// Новый эндпоинт для получения дат последнего изменения файлов S3
|
||||||
app.get('/api/s3/last-modified', async (req, res) => {
|
app.get('/api/s3/last-modified', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [domainsHead, asnsHead, serversHead, filtersHead] = await Promise.all([
|
const [domainsHead, domainsNewHead, asnsHead, serversHead, filtersHead] = await Promise.all([
|
||||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'domains.txt' }).promise(),
|
s3.headObject({ Bucket: BUCKET_NAME, Key: 'domains.txt' }).promise(),
|
||||||
|
s3.headObject({ Bucket: BUCKET_NAME, Key: 'domains_comunity.txt' }).promise(),
|
||||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'asns.txt' }).promise(),
|
s3.headObject({ Bucket: BUCKET_NAME, Key: 'asns.txt' }).promise(),
|
||||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise(),
|
s3.headObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise(),
|
||||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'filters.json' }).promise()
|
s3.headObject({ Bucket: BUCKET_NAME, Key: 'filters.json' }).promise()
|
||||||
]);
|
]);
|
||||||
res.json({
|
res.json({
|
||||||
domainsLastModified: domainsHead.LastModified ? domainsHead.LastModified.toISOString() : null,
|
domainsLastModified: domainsHead.LastModified ? domainsHead.LastModified.toISOString() : null,
|
||||||
|
domainsNewLastModified: domainsNewHead.LastModified ? domainsNewHead.LastModified.toISOString() : null,
|
||||||
asnsLastModified: asnsHead.LastModified ? asnsHead.LastModified.toISOString() : null,
|
asnsLastModified: asnsHead.LastModified ? asnsHead.LastModified.toISOString() : null,
|
||||||
serversLastModified: serversHead.LastModified ? serversHead.LastModified.toISOString() : null,
|
serversLastModified: serversHead.LastModified ? serversHead.LastModified.toISOString() : null,
|
||||||
filtersLastModified: filtersHead.LastModified ? filtersHead.LastModified.toISOString() : null
|
filtersLastModified: filtersHead.LastModified ? filtersHead.LastModified.toISOString() : null
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import DataManager from './DataManager';
|
import DataManager from './DataManager';
|
||||||
import ServerManager from './ServerManager';
|
import ServerManager from './ServerManager';
|
||||||
import FilterManager from './FilterManager';
|
import FilterManager from './FilterManager';
|
||||||
|
import DomainsNewManager from './DomainsNewManager';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ function MainLayout() {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'domains', title: 'Домены', icon: IconWorld, path: '/domains' },
|
{ id: 'domains', title: 'Домены', icon: IconWorld, path: '/domains' },
|
||||||
|
{ id: 'domains-new', title: 'Домены New', icon: IconWorld, path: '/domains-new' },
|
||||||
{ id: 'asns', title: 'AS', icon: IconNetwork, path: '/asns' },
|
{ id: 'asns', title: 'AS', icon: IconNetwork, path: '/asns' },
|
||||||
{ id: 'servers', title: 'Серверы', icon: IconServer, path: '/servers' },
|
{ id: 'servers', title: 'Серверы', icon: IconServer, path: '/servers' },
|
||||||
{ id: 'filters', title: 'Фильтры', icon: IconFilter, path: '/filters' },
|
{ id: 'filters', title: 'Фильтры', icon: IconFilter, path: '/filters' },
|
||||||
@@ -73,7 +75,7 @@ function MainLayout() {
|
|||||||
<span className="fw-bold">S3 Lists Manager</span>
|
<span className="fw-bold">S3 Lists Manager</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="nav nav-tabs ms-4">
|
<nav className="nav nav-tabs ms-4">
|
||||||
{navItems.slice(0, 4).map(item => (
|
{navItems.slice(0, 5).map(item => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
@@ -95,6 +97,7 @@ function MainLayout() {
|
|||||||
placeholder="example.com"
|
placeholder="example.com"
|
||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/domains-new" element={<DomainsNewManager />} />
|
||||||
<Route path="/asns" element={
|
<Route path="/asns" element={
|
||||||
<DataManager
|
<DataManager
|
||||||
entityName="AS"
|
entityName="AS"
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconSearch,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconCheck,
|
||||||
|
IconX,
|
||||||
|
IconDatabase,
|
||||||
|
IconRefresh
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
const API_URL = '/api';
|
||||||
|
|
||||||
|
function DomainsNewManager() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [editingDomain, setEditingDomain] = useState(null);
|
||||||
|
const [editingValue, setEditingValue] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [sortField, setSortField] = useState('domain');
|
||||||
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
|
const [filterCommunity, setFilterCommunity] = useState('');
|
||||||
|
const editInputRef = useRef(null);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/domains-new`);
|
||||||
|
setItems(response.data);
|
||||||
|
setError('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching domains-new:', error);
|
||||||
|
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
if (newItem.domain.trim() === '') {
|
||||||
|
setError('Имя домена не может быть пустым.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newItem.community.trim() === '') {
|
||||||
|
setError('Community не может быть пустым.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setItems([...items, newItem]);
|
||||||
|
setNewItem({ domain: '', community: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (item) => {
|
||||||
|
setEditingDomain(item.domain);
|
||||||
|
setEditingValue(item.community);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = (domainName) => {
|
||||||
|
const updatedItems = items.map(i =>
|
||||||
|
i.domain === domainName ? { ...i, community: editingValue } : i
|
||||||
|
);
|
||||||
|
setItems(updatedItems);
|
||||||
|
setEditingDomain(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingDomain(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteItem = (domainNameToDelete) => {
|
||||||
|
setItems(items.filter(i => i.domain !== domainNameToDelete));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (item) => {
|
||||||
|
setItemToDelete(item);
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeDelete = () => {
|
||||||
|
if (itemToDelete) {
|
||||||
|
handleDeleteItem(itemToDelete.domain);
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setItemToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChanges = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/domains-new`, { domains: items });
|
||||||
|
setSuccess('Изменения успешно сохранены!');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving changes:', error);
|
||||||
|
setError('Не удалось сохранить изменения.');
|
||||||
|
} finally {
|
||||||
|
setLoading(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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтрация по community
|
||||||
|
const filteredByCommunity = filterCommunity
|
||||||
|
? sortedItems.filter(i => i.community === filterCommunity)
|
||||||
|
: sortedItems;
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
const filteredItems = filteredByCommunity.filter(i =>
|
||||||
|
i.domain.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
const totalPages = Math.ceil(filteredItems.length / pageSize);
|
||||||
|
const paginatedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
|
|
||||||
|
// Для фильтра - список всех уникальных community
|
||||||
|
const allCommunities = Array.from(new Set(items.map(i => i.community)));
|
||||||
|
|
||||||
|
// Сортировка по клику
|
||||||
|
const handleSort = (field) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Улучшенный инлайн-редакт
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingDomain && editInputRef.current) {
|
||||||
|
editInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [editingDomain]);
|
||||||
|
|
||||||
|
const handleEditKeyDown = (e, domainName) => {
|
||||||
|
if (e.key === 'Enter') handleSaveEdit(domainName);
|
||||||
|
if (e.key === 'Escape') handleCancelEdit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||||
|
<div className="d-flex">
|
||||||
|
<div>
|
||||||
|
<IconX className="icon alert-icon" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a className="btn-close" onClick={() => setError('')}></a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<a className="btn-close" onClick={() => setSuccess('')}></a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-3">
|
||||||
|
{/* Add New Item Card */}
|
||||||
|
<div className="card card-md">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
<IconPlus className="icon me-2" />
|
||||||
|
Добавить новый домен
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Домен</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="example.com"
|
||||||
|
value={newItem.domain}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Community</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="65000:100"
|
||||||
|
value={newItem.community}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, community: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-footer">
|
||||||
|
<button type="submit" className="btn btn-primary w-100">
|
||||||
|
<IconPlus className="icon me-2" />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Card */}
|
||||||
|
<div className="card card-md">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
<IconDatabase className="icon me-2" />
|
||||||
|
Действия
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSaveChanges}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
Сохранение...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconDatabase className="icon me-2" />
|
||||||
|
Сохранить в S3
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={fetchItems}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconRefresh className="icon me-2" />
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-9">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h3 className="card-title mb-0">Список доменов</h3>
|
||||||
|
<div className="d-flex gap-2 w-50">
|
||||||
|
{/* Фильтр по community */}
|
||||||
|
<select className="form-select w-auto" value={filterCommunity} onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}>
|
||||||
|
<option value="">Все community</option>
|
||||||
|
{allCommunities.map(community => (
|
||||||
|
<option key={community} value={community}>{community}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="input-icon flex-grow-1">
|
||||||
|
<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); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('domain')}>
|
||||||
|
Домен
|
||||||
|
{sortField === 'domain' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('community')}>
|
||||||
|
Community
|
||||||
|
{sortField === 'community' && (
|
||||||
|
<span className="ms-1">
|
||||||
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
<th className="text-end"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedItems.map((item) => (
|
||||||
|
<tr key={item.domain} className={editingDomain === item.domain ? 'table-info' : ''}>
|
||||||
|
<td>{item.domain}</td>
|
||||||
|
<td><span className="badge bg-blue-lt text-blue">{item.community}</span></td>
|
||||||
|
<td className="text-end">
|
||||||
|
{editingDomain === item.domain ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control d-inline-block w-auto me-2"
|
||||||
|
value={editingValue}
|
||||||
|
ref={editInputRef}
|
||||||
|
onChange={e => setEditingValue(e.target.value)}
|
||||||
|
onKeyDown={e => handleEditKeyDown(e, item.domain)}
|
||||||
|
style={{maxWidth: 120}}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.domain)}><IconCheck size={18} /></button>
|
||||||
|
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(item)}><IconEdit size={18} /></button>
|
||||||
|
<button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(item)}><IconTrash size={18} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Пагинация */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="card-footer d-flex justify-content-center">
|
||||||
|
<ul className="pagination m-0">
|
||||||
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>«</button>
|
||||||
|
</li>
|
||||||
|
{Array.from({length: totalPages}, (_, i) => i + 1).map(page => (
|
||||||
|
<li key={page} className={`page-item${currentPage === page ? ' active' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(page)}>{page}</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} tabIndex={-1} disabled={currentPage === totalPages}>»</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteModal && (
|
||||||
|
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||||
|
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||||
|
<div className="modal-content">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
></button>
|
||||||
|
<div className="modal-status bg-danger"></div>
|
||||||
|
<div className="modal-body text-center py-4">
|
||||||
|
<IconTrash className="icon mb-2 text-danger icon-lg" />
|
||||||
|
<h3>Удалить домен?</h3>
|
||||||
|
<div className="text-muted">
|
||||||
|
Вы уверены, что хотите удалить "{itemToDelete?.domain}"? Это действие необратимо.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<div className="w-100">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col">
|
||||||
|
<button
|
||||||
|
className="btn w-100"
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger w-100"
|
||||||
|
onClick={executeDelete}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DomainsNewManager;
|
||||||
Reference in New Issue
Block a user