refactor(Managers): apply flat card styling across ASNs, Domains, and IPRanges managers for enhanced visual consistency and improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
This commit is contained in:
+143
-142
@@ -585,7 +585,7 @@ function ASNsNewManager() {
|
|||||||
onExport={selectedItems.size > 0 ? handleBulkExport : null}
|
onExport={selectedItems.size > 0 ? handleBulkExport : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card card-flat">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||||
<div className="d-flex gap-2 flex-wrap align-items-center">
|
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||||
@@ -666,149 +666,150 @@ function ASNsNewManager() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="card-body p-0">
|
||||||
|
{/* Таблица на лёгкой карточной подложке */}
|
||||||
{/* Таблица без карточной подложки */}
|
{loading ? (
|
||||||
{loading ? (
|
<TableSkeleton rows={10} cols={3} flat />
|
||||||
<TableSkeleton rows={10} cols={3} flat />
|
) : paginatedItems.length === 0 ? (
|
||||||
) : paginatedItems.length === 0 ? (
|
<TableEmpty cols={3} flat>
|
||||||
<TableEmpty cols={3} flat>
|
<EmptyState
|
||||||
<EmptyState
|
title="Нет ASN"
|
||||||
title="Нет ASN"
|
description="Импортируйте или добавьте записи, чтобы начать."
|
||||||
description="Импортируйте или добавьте записи, чтобы начать."
|
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
||||||
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
const header = ['asn','community'];
|
||||||
const header = ['asn','community'];
|
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||||
const lines = [header, ['12345','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 blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
const url = URL.createObjectURL(blob);
|
||||||
const url = URL.createObjectURL(blob);
|
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
||||||
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
}}>Шаблон CSV</button>}
|
||||||
}}>Шаблон CSV</button>}
|
/>
|
||||||
/>
|
</TableEmpty>
|
||||||
</TableEmpty>
|
) : (
|
||||||
) : (
|
<div className="table-responsive">
|
||||||
<div className="table-responsive">
|
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th style={{width: '40px'}}>
|
||||||
<th style={{width: '40px'}}>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
className="form-check-input"
|
||||||
className="form-check-input"
|
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))}
|
||||||
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
if (e.target.checked) {
|
||||||
if (e.target.checked) {
|
selectAll();
|
||||||
selectAll();
|
} else {
|
||||||
} else {
|
deselectAll();
|
||||||
deselectAll();
|
}
|
||||||
}
|
}}
|
||||||
}}
|
title="Выбрать все на странице"
|
||||||
title="Выбрать все на странице"
|
/>
|
||||||
/>
|
</th>
|
||||||
</th>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
|
<div className="d-flex align-items-center">
|
||||||
<div className="d-flex align-items-center">
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
<IconHash size={16} className="me-1 text-muted" />
|
Номер AS
|
||||||
Номер AS
|
{sortField === 'asn' && (
|
||||||
{sortField === 'asn' && (
|
<span className="ms-1">
|
||||||
<span className="ms-1">
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</th>
|
||||||
</th>
|
<th>
|
||||||
<th>
|
Имя AS
|
||||||
Имя AS
|
</th>
|
||||||
</th>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
<div className="d-flex align-items-center">
|
||||||
<div className="d-flex align-items-center">
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
<IconHash size={16} className="me-1 text-muted" />
|
Community
|
||||||
Community
|
{sortField === 'community' && (
|
||||||
{sortField === 'community' && (
|
<span className="ms-1">
|
||||||
<span className="ms-1">
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</th>
|
||||||
</th>
|
<th className="text-end">Действия</th>
|
||||||
<th className="text-end">Действия</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{paginatedItems.map((item, idx) => (
|
||||||
{paginatedItems.map((item, idx) => (
|
<tr key={item.asn} className={editingAsn === item.asn ? 'table-active' : (selectedItems.has(item.asn) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||||
<tr key={item.asn} className={editingAsn === item.asn ? 'table-active' : (selectedItems.has(item.asn) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
<td>
|
||||||
<td>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
className="form-check-input"
|
||||||
className="form-check-input"
|
checked={selectedItems.has(item.asn)}
|
||||||
checked={selectedItems.has(item.asn)}
|
onChange={() => toggleSelectItem(item.asn)}
|
||||||
onChange={() => toggleSelectItem(item.asn)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="font-monospace" title="ASN">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<IconHash size={16} className="me-2 text-muted" />
|
|
||||||
<span>{item.asn}</span>
|
|
||||||
<Tooltip content="Копировать ASN" position="top">
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
|
||||||
onClick={() => copyToClipboard(item.asn)}
|
|
||||||
aria-label="Копировать"
|
|
||||||
title="Копировать ASN"
|
|
||||||
>
|
|
||||||
<IconCopy size={14} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td title={asnNameMap[item.asn] || ''}>
|
|
||||||
{asnNameMap[item.asn] ? (
|
|
||||||
<span>{asnNameMap[item.asn]}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{renderCommunityBadge(item.community)}</td>
|
|
||||||
<td className="text-end">
|
|
||||||
{editingAsn === item.asn ? (
|
|
||||||
<>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={editingValue}
|
|
||||||
onChange={setEditingValue}
|
|
||||||
communities={communities}
|
|
||||||
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
|
|
||||||
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
|
||||||
style={{ width: '150px' }}
|
|
||||||
useShortValue={true}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip content="Сохранить (Enter)">
|
</td>
|
||||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
<td className="font-monospace" title="ASN">
|
||||||
</Tooltip>
|
<div className="d-flex align-items-center">
|
||||||
<Tooltip content="Отмена (Esc)">
|
<IconHash size={16} className="me-2 text-muted" />
|
||||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
<span>{item.asn}</span>
|
||||||
</Tooltip>
|
<Tooltip content="Копировать ASN" position="top">
|
||||||
</>
|
<button
|
||||||
) : (
|
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||||
<>
|
onClick={() => copyToClipboard(item.asn)}
|
||||||
<Tooltip content="Редактировать" position="top">
|
aria-label="Копировать"
|
||||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
|
title="Копировать ASN"
|
||||||
</Tooltip>
|
>
|
||||||
<Tooltip content="Копировать" position="top">
|
<IconCopy size={14} />
|
||||||
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.asn} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Удалить" position="top">
|
</div>
|
||||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
|
</td>
|
||||||
</Tooltip>
|
<td title={asnNameMap[item.asn] || ''}>
|
||||||
</>
|
{asnNameMap[item.asn] ? (
|
||||||
)}
|
<span>{asnNameMap[item.asn]}</span>
|
||||||
</td>
|
) : (
|
||||||
</tr>
|
<span className="text-muted">—</span>
|
||||||
))}
|
)}
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td>{renderCommunityBadge(item.community)}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
{editingAsn === item.asn ? (
|
||||||
|
<>
|
||||||
|
<CommunityAutocompleteInput
|
||||||
|
value={editingValue}
|
||||||
|
onChange={setEditingValue}
|
||||||
|
communities={communities}
|
||||||
|
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
|
||||||
|
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
|
style={{ width: '150px' }}
|
||||||
|
useShortValue={true}
|
||||||
|
/>
|
||||||
|
<Tooltip content="Сохранить (Enter)">
|
||||||
|
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Отмена (Esc)">
|
||||||
|
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Tooltip content="Редактировать" position="top">
|
||||||
|
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Копировать" position="top">
|
||||||
|
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.asn} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Удалить" position="top">
|
||||||
|
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|||||||
+170
-169
@@ -572,7 +572,7 @@ function DomainsNewManager() {
|
|||||||
onExport={handleBulkExport}
|
onExport={handleBulkExport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card animate-in" style={{animationDelay: '0.3s'}}>
|
<div className="card card-flat animate-in" style={{animationDelay: '0.3s'}}>
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="row align-items-center w-100 g-2">
|
<div className="row align-items-center w-100 g-2">
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
@@ -674,179 +674,180 @@ function DomainsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="card-body p-0">
|
||||||
|
{/* Таблица с лёгкой карточной подложкой (плоский стиль) */}
|
||||||
{/* Таблица без карточной подложки (только горизонтальные разделители) */}
|
{loading ? (
|
||||||
{loading ? (
|
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
|
||||||
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
|
) : paginatedItems.length === 0 ? (
|
||||||
) : paginatedItems.length === 0 ? (
|
<TableEmpty cols={3} flat>
|
||||||
<TableEmpty cols={3} flat>
|
<EmptyState
|
||||||
<EmptyState
|
title="Нет доменов"
|
||||||
title="Нет доменов"
|
description="Импортируйте или добавьте записи, чтобы начать."
|
||||||
description="Импортируйте или добавьте записи, чтобы начать."
|
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
||||||
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
const header = ['domain','community'];
|
||||||
const header = ['domain','community'];
|
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||||
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 blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
|
const url = URL.createObjectURL(blob);
|
||||||
const url = URL.createObjectURL(blob);
|
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
||||||
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
|
}}>Шаблон CSV</button>}
|
||||||
}}>Шаблон CSV</button>}
|
/>
|
||||||
/>
|
</TableEmpty>
|
||||||
</TableEmpty>
|
) : (
|
||||||
) : (
|
<div className="table-responsive">
|
||||||
<div className="table-responsive">
|
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th style={{ width: '40px' }}>
|
||||||
<th style={{ width: '40px' }}>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
className="form-check-input"
|
||||||
className="form-check-input"
|
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
|
||||||
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
|
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
||||||
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
/>
|
||||||
/>
|
</th>
|
||||||
</th>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
|
<div className="d-flex align-items-center">
|
||||||
<div className="d-flex align-items-center">
|
<IconWorld size={16} className="me-1 text-muted" />
|
||||||
<IconWorld size={16} className="me-1 text-muted" />
|
Домен
|
||||||
Домен
|
{sortField === 'domain' && (
|
||||||
{sortField === 'domain' && (
|
<span className="ms-1">
|
||||||
<span className="ms-1">
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</th>
|
||||||
</th>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
<div className="d-flex align-items-center">
|
||||||
<div className="d-flex align-items-center">
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
<IconHash size={16} className="me-1 text-muted" />
|
Community
|
||||||
Community
|
{sortField === 'community' && (
|
||||||
{sortField === 'community' && (
|
<span className="ms-1">
|
||||||
<span className="ms-1">
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</th>
|
||||||
</th>
|
<th className="text-end">Действия</th>
|
||||||
<th className="text-end">Действия</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{paginatedItems.map((item, idx) => (
|
||||||
{paginatedItems.map((item, idx) => (
|
<tr
|
||||||
<tr
|
key={item.domain}
|
||||||
key={item.domain}
|
className={editingDomain === item.domain ? 'table-active' : (selectedDomains.has(item.domain) ? 'table-selected' : '')}
|
||||||
className={editingDomain === item.domain ? 'table-active' : (selectedDomains.has(item.domain) ? 'table-selected' : '')}
|
style={{
|
||||||
style={{
|
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
|
||||||
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<td>
|
||||||
<td>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
className="form-check-input"
|
||||||
className="form-check-input"
|
checked={selectedDomains.has(item.domain)}
|
||||||
checked={selectedDomains.has(item.domain)}
|
onChange={() => handleToggleSelect(item.domain)}
|
||||||
onChange={() => handleToggleSelect(item.domain)}
|
aria-label={`Выбрать ${item.domain}`}
|
||||||
aria-label={`Выбрать ${item.domain}`}
|
onClick={(e) => e.stopPropagation()}
|
||||||
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">
|
|
||||||
{editingDomain === item.domain ? (
|
|
||||||
<>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={editingValue}
|
|
||||||
onChange={setEditingValue}
|
|
||||||
communities={communities}
|
|
||||||
onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
|
|
||||||
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
|
||||||
style={{ width: '150px' }}
|
|
||||||
useShortValue={true}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip content="Сохранить (Enter)">
|
</td>
|
||||||
<button
|
<td>
|
||||||
className="btn btn-success btn-icon btn-sm"
|
<div className="d-flex align-items-center">
|
||||||
onClick={() => handleSaveEdit(item.domain)}
|
<IconWorld size={16} className="me-2 text-muted" />
|
||||||
disabled={!isValidCommunity(editingValue)}
|
<span className="font-monospace">{item.domain}</span>
|
||||||
aria-label="Сохранить"
|
<Tooltip content="Копировать домен" position="top">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||||
|
onClick={() => copyToClipboard(item.domain)}
|
||||||
|
aria-label="Копировать"
|
||||||
|
title="Копировать домен"
|
||||||
>
|
>
|
||||||
<IconCheck size={16} />
|
<IconCopy size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Отмена (Esc)">
|
</div>
|
||||||
<button
|
</td>
|
||||||
className="btn btn-secondary btn-icon btn-sm"
|
<td>
|
||||||
onClick={handleCancelEdit}
|
<div className="d-flex align-items-center">
|
||||||
aria-label="Отмена"
|
{renderCommunityBadge(item.community)}
|
||||||
>
|
</div>
|
||||||
<IconX size={16} />
|
</td>
|
||||||
</button>
|
<td className="text-end">
|
||||||
</Tooltip>
|
<div className="btn-group">
|
||||||
</>
|
{editingDomain === item.domain ? (
|
||||||
) : (
|
<>
|
||||||
<>
|
<CommunityAutocompleteInput
|
||||||
<Tooltip content="Редактировать" position="top">
|
value={editingValue}
|
||||||
<button
|
onChange={setEditingValue}
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
communities={communities}
|
||||||
aria-label="Редактировать"
|
onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
|
||||||
onClick={() => handleEdit(item)}
|
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
title="Редактировать"
|
style={{ width: '150px' }}
|
||||||
>
|
useShortValue={true}
|
||||||
<IconEdit size={16} />
|
/>
|
||||||
</button>
|
<Tooltip content="Сохранить (Enter)">
|
||||||
</Tooltip>
|
<button
|
||||||
<Tooltip content="Копировать" position="top">
|
className="btn btn-success btn-icon btn-sm"
|
||||||
<button
|
onClick={() => handleSaveEdit(item.domain)}
|
||||||
className="btn btn-outline-secondary btn-icon btn-sm"
|
disabled={!isValidCommunity(editingValue)}
|
||||||
aria-label="Копировать"
|
aria-label="Сохранить"
|
||||||
onClick={() => copyToClipboard(`${item.domain} ${item.community}`)}
|
>
|
||||||
title="Копировать"
|
<IconCheck size={16} />
|
||||||
>
|
</button>
|
||||||
<IconCopy size={16} />
|
</Tooltip>
|
||||||
</button>
|
<Tooltip content="Отмена (Esc)">
|
||||||
</Tooltip>
|
<button
|
||||||
<Tooltip content="Удалить" position="top">
|
className="btn btn-secondary btn-icon btn-sm"
|
||||||
<button
|
onClick={handleCancelEdit}
|
||||||
className="btn btn-outline-danger btn-icon btn-sm"
|
aria-label="Отмена"
|
||||||
aria-label="Удалить"
|
>
|
||||||
onClick={() => confirmDelete(item)}
|
<IconX size={16} />
|
||||||
title="Удалить"
|
</button>
|
||||||
>
|
</Tooltip>
|
||||||
<IconTrash size={16} />
|
</>
|
||||||
</button>
|
) : (
|
||||||
</Tooltip>
|
<>
|
||||||
</>
|
<Tooltip content="Редактировать" position="top">
|
||||||
)}
|
<button
|
||||||
</div>
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
</td>
|
aria-label="Редактировать"
|
||||||
</tr>
|
onClick={() => handleEdit(item)}
|
||||||
))}
|
title="Редактировать"
|
||||||
</tbody>
|
>
|
||||||
</table>
|
<IconEdit size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Копировать" position="top">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-icon btn-sm"
|
||||||
|
aria-label="Копировать"
|
||||||
|
onClick={() => copyToClipboard(`${item.domain} ${item.community}`)}
|
||||||
|
title="Копировать"
|
||||||
|
>
|
||||||
|
<IconCopy size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Удалить" position="top">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-icon btn-sm"
|
||||||
|
aria-label="Удалить"
|
||||||
|
onClick={() => confirmDelete(item)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|||||||
+127
-126
@@ -845,7 +845,7 @@ function IPRangesManager() {
|
|||||||
onExport={selectedItems.size > 0 ? handleBulkExport : null}
|
onExport={selectedItems.size > 0 ? handleBulkExport : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card card-flat">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<h3 className="card-title mb-0">Список IP-диапазонов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
<h3 className="card-title mb-0">Список IP-диапазонов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||||
<div className="d-flex gap-2 flex-wrap align-items-center">
|
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||||
@@ -918,133 +918,134 @@ function IPRangesManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="card-body p-0">
|
||||||
|
{/* Таблица на лёгкой карточной подложке */}
|
||||||
{/* Таблица без карточной подложки */}
|
{loading ? (
|
||||||
{loading ? (
|
<TableSkeleton rows={10} cols={3} flat />
|
||||||
<TableSkeleton rows={10} cols={3} flat />
|
) : paginatedItems.length === 0 ? (
|
||||||
) : paginatedItems.length === 0 ? (
|
<TableEmpty cols={3} flat>
|
||||||
<TableEmpty cols={3} flat>
|
<EmptyState
|
||||||
<EmptyState
|
title="Нет IP-диапазонов"
|
||||||
title="Нет IP-диапазонов"
|
description="Импортируйте или добавьте записи, чтобы начать."
|
||||||
description="Импортируйте или добавьте записи, чтобы начать."
|
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>}
|
||||||
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>}
|
/>
|
||||||
/>
|
</TableEmpty>
|
||||||
</TableEmpty>
|
) : (
|
||||||
) : (
|
<div className="table-responsive">
|
||||||
<div className="table-responsive">
|
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th style={{width: '40px'}}>
|
||||||
<th style={{width: '40px'}}>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
className="form-check-input"
|
||||||
className="form-check-input"
|
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))}
|
||||||
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
if (e.target.checked) {
|
||||||
if (e.target.checked) {
|
selectAll();
|
||||||
selectAll();
|
} else {
|
||||||
} else {
|
deselectAll();
|
||||||
deselectAll();
|
}
|
||||||
}
|
}}
|
||||||
}}
|
title="Выбрать все на странице"
|
||||||
title="Выбрать все на странице"
|
/>
|
||||||
/>
|
</th>
|
||||||
</th>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
|
<div className="d-flex align-items-center">
|
||||||
<div className="d-flex align-items-center">
|
<IconNetwork size={16} className="me-1 text-muted" />
|
||||||
<IconNetwork size={16} className="me-1 text-muted" />
|
IP-диапазон
|
||||||
IP-диапазон
|
{sortField === 'ipRange' && (
|
||||||
{sortField === 'ipRange' && (
|
<span className="ms-1">
|
||||||
<span className="ms-1">
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</th>
|
||||||
</th>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
<div className="d-flex align-items-center">
|
||||||
<div className="d-flex align-items-center">
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
<IconHash size={16} className="me-1 text-muted" />
|
Community
|
||||||
Community
|
{sortField === 'community' && (
|
||||||
{sortField === 'community' && (
|
<span className="ms-1">
|
||||||
<span className="ms-1">
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</th>
|
||||||
</th>
|
<th className="text-end">Действия</th>
|
||||||
<th className="text-end">Действия</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{paginatedItems.map((item, idx) => (
|
||||||
{paginatedItems.map((item, idx) => (
|
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-active' : (selectedItems.has(item.ipRange) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||||
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-active' : (selectedItems.has(item.ipRange) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
<td>
|
||||||
<td>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
className="form-check-input"
|
||||||
className="form-check-input"
|
checked={selectedItems.has(item.ipRange)}
|
||||||
checked={selectedItems.has(item.ipRange)}
|
onChange={() => toggleSelectItem(item.ipRange)}
|
||||||
onChange={() => toggleSelectItem(item.ipRange)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="font-monospace" title="CIDR">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<IconNetwork size={16} className="me-2 text-muted" />
|
|
||||||
<span>{item.ipRange}</span>
|
|
||||||
<Tooltip content="Копировать IP-диапазон" position="top">
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
|
||||||
onClick={() => copyToClipboard(item.ipRange)}
|
|
||||||
aria-label="Копировать"
|
|
||||||
title="Копировать IP-диапазон"
|
|
||||||
>
|
|
||||||
<IconCopy size={14} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{renderCommunityBadge(item.community)}</td>
|
|
||||||
<td className="text-end">
|
|
||||||
{editingIp === item.ipRange ? (
|
|
||||||
<>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={editingValue}
|
|
||||||
onChange={setEditingValue}
|
|
||||||
communities={communities}
|
|
||||||
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
|
||||||
style={{ width: '150px' }}
|
|
||||||
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
|
|
||||||
useShortValue={true}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip content="Сохранить (Enter)">
|
</td>
|
||||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
<td className="font-monospace" title="CIDR">
|
||||||
</Tooltip>
|
<div className="d-flex align-items-center">
|
||||||
<Tooltip content="Отмена (Esc)">
|
<IconNetwork size={16} className="me-2 text-muted" />
|
||||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
<span>{item.ipRange}</span>
|
||||||
</Tooltip>
|
<Tooltip content="Копировать IP-диапазон" position="top">
|
||||||
</>
|
<button
|
||||||
) : (
|
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||||
<>
|
onClick={() => copyToClipboard(item.ipRange)}
|
||||||
<Tooltip content="Редактировать" position="top">
|
aria-label="Копировать"
|
||||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
|
title="Копировать IP-диапазон"
|
||||||
</Tooltip>
|
>
|
||||||
<Tooltip content="Копировать" position="top">
|
<IconCopy size={14} />
|
||||||
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.ipRange} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Удалить" position="top">
|
</div>
|
||||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
|
</td>
|
||||||
</Tooltip>
|
<td>{renderCommunityBadge(item.community)}</td>
|
||||||
</>
|
<td className="text-end">
|
||||||
)}
|
{editingIp === item.ipRange ? (
|
||||||
</td>
|
<>
|
||||||
</tr>
|
<CommunityAutocompleteInput
|
||||||
))}
|
value={editingValue}
|
||||||
</tbody>
|
onChange={setEditingValue}
|
||||||
</table>
|
communities={communities}
|
||||||
|
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
|
style={{ width: '150px' }}
|
||||||
|
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
|
||||||
|
useShortValue={true}
|
||||||
|
/>
|
||||||
|
<Tooltip content="Сохранить (Enter)">
|
||||||
|
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Отмена (Esc)">
|
||||||
|
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Tooltip content="Редактировать" position="top">
|
||||||
|
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Копировать" position="top">
|
||||||
|
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.ipRange} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Удалить" position="top">
|
||||||
|
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|||||||
Reference in New Issue
Block a user