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}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="card card-flat">
|
||||
<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>
|
||||
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||
@@ -666,149 +666,150 @@ function ASNsNewManager() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица без карточной подложки */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} flat />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3} flat>
|
||||
<EmptyState
|
||||
title="Нет ASN"
|
||||
description="Импортируйте или добавьте записи, чтобы начать."
|
||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
||||
const header = ['asn','community'];
|
||||
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 url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'asns_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={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selectAll();
|
||||
} else {
|
||||
deselectAll();
|
||||
}
|
||||
}}
|
||||
title="Выбрать все на странице"
|
||||
/>
|
||||
</th>
|
||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconHash size={16} className="me-1 text-muted" />
|
||||
Номер AS
|
||||
{sortField === 'asn' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
Имя AS
|
||||
</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.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>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedItems.has(item.asn)}
|
||||
onChange={() => toggleSelectItem(item.asn)}
|
||||
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}
|
||||
<div className="card-body p-0">
|
||||
{/* Таблица на лёгкой карточной подложке */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} flat />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3} flat>
|
||||
<EmptyState
|
||||
title="Нет ASN"
|
||||
description="Импортируйте или добавьте записи, чтобы начать."
|
||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
|
||||
const header = ['asn','community'];
|
||||
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 url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'asns_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={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selectAll();
|
||||
} else {
|
||||
deselectAll();
|
||||
}
|
||||
}}
|
||||
title="Выбрать все на странице"
|
||||
/>
|
||||
</th>
|
||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconHash size={16} className="me-1 text-muted" />
|
||||
Номер AS
|
||||
{sortField === 'asn' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
Имя AS
|
||||
</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.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>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedItems.has(item.asn)}
|
||||
onChange={() => toggleSelectItem(item.asn)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<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>
|
||||
</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)">
|
||||
<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>
|
||||
{/* Пагинация */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
|
||||
+170
-169
@@ -572,7 +572,7 @@ function DomainsNewManager() {
|
||||
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="row align-items-center w-100 g-2">
|
||||
<div className="col-auto">
|
||||
@@ -674,179 +674,180 @@ function DomainsNewManager() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица без карточной подложки (только горизонтальные разделители) */}
|
||||
{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={editingDomain === item.domain ? 'table-active' : (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">
|
||||
{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}
|
||||
<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={editingDomain === item.domain ? 'table-active' : (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()}
|
||||
/>
|
||||
<Tooltip content="Сохранить (Enter)">
|
||||
<button
|
||||
className="btn btn-success btn-icon btn-sm"
|
||||
onClick={() => handleSaveEdit(item.domain)}
|
||||
disabled={!isValidCommunity(editingValue)}
|
||||
aria-label="Сохранить"
|
||||
</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="Копировать домен"
|
||||
>
|
||||
<IconCheck size={16} />
|
||||
<IconCopy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Отмена (Esc)">
|
||||
<button
|
||||
className="btn btn-secondary btn-icon btn-sm"
|
||||
onClick={handleCancelEdit}
|
||||
aria-label="Отмена"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip content="Редактировать" position="top">
|
||||
<button
|
||||
className="btn btn-outline-primary btn-icon btn-sm"
|
||||
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"
|
||||
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>
|
||||
</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)">
|
||||
<button
|
||||
className="btn btn-success btn-icon btn-sm"
|
||||
onClick={() => handleSaveEdit(item.domain)}
|
||||
disabled={!isValidCommunity(editingValue)}
|
||||
aria-label="Сохранить"
|
||||
>
|
||||
<IconCheck size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Отмена (Esc)">
|
||||
<button
|
||||
className="btn btn-secondary btn-icon btn-sm"
|
||||
onClick={handleCancelEdit}
|
||||
aria-label="Отмена"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip content="Редактировать" position="top">
|
||||
<button
|
||||
className="btn btn-outline-primary btn-icon btn-sm"
|
||||
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"
|
||||
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>
|
||||
{/* Пагинация */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
|
||||
+127
-126
@@ -845,7 +845,7 @@ function IPRangesManager() {
|
||||
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">
|
||||
<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">
|
||||
@@ -918,133 +918,134 @@ function IPRangesManager() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица без карточной подложки */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} flat />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3} flat>
|
||||
<EmptyState
|
||||
title="Нет IP-диапазонов"
|
||||
description="Импортируйте или добавьте записи, чтобы начать."
|
||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон 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={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selectAll();
|
||||
} else {
|
||||
deselectAll();
|
||||
}
|
||||
}}
|
||||
title="Выбрать все на странице"
|
||||
/>
|
||||
</th>
|
||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconNetwork size={16} className="me-1 text-muted" />
|
||||
IP-диапазон
|
||||
{sortField === 'ipRange' && (
|
||||
<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.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>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedItems.has(item.ipRange)}
|
||||
onChange={() => toggleSelectItem(item.ipRange)}
|
||||
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}
|
||||
<div className="card-body p-0">
|
||||
{/* Таблица на лёгкой карточной подложке */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} flat />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3} flat>
|
||||
<EmptyState
|
||||
title="Нет IP-диапазонов"
|
||||
description="Импортируйте или добавьте записи, чтобы начать."
|
||||
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
|
||||
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон 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={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selectAll();
|
||||
} else {
|
||||
deselectAll();
|
||||
}
|
||||
}}
|
||||
title="Выбрать все на странице"
|
||||
/>
|
||||
</th>
|
||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconNetwork size={16} className="me-1 text-muted" />
|
||||
IP-диапазон
|
||||
{sortField === 'ipRange' && (
|
||||
<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.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>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedItems.has(item.ipRange)}
|
||||
onChange={() => toggleSelectItem(item.ipRange)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<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>
|
||||
</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)">
|
||||
<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>
|
||||
{/* Пагинация */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
|
||||
Reference in New Issue
Block a user