feat: Заменить рендеринг бейджей сообщества на компонент CommunityBadge в менеджерах ASNs, Domains и IPRanges, улучшив читаемость кода и унифицировав отображение. Добавить возможность скачивания шаблонов CSV для пустых состояний в этих менеджерах, улучшая пользовательский опыт. Обновить модальные окна подтверждения удаления для более удобного взаимодействия с пользователем.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||
import CommunityBadge from './components/CommunityBadge.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -78,17 +79,7 @@ function ASNsNewManager() {
|
||||
|
||||
// Подгружаем имена ASN из кэша/внешних API (эффект будет размещен ниже после определения paginatedItems)
|
||||
|
||||
const renderCommunityBadge = (value) => {
|
||||
const v = String(value ?? '').trim();
|
||||
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
|
||||
const meta = communities.find(c => c.value === v);
|
||||
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
|
||||
const cls = `badge bg-${color}-lt text-${color}`;
|
||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
|
||||
return (
|
||||
<span className={cls} title={title}>{v}</span>
|
||||
);
|
||||
};
|
||||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||||
|
||||
const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim());
|
||||
// Допускаем как числовые, так и строковые (AS:NNN) community
|
||||
@@ -635,7 +626,18 @@ function ASNsNewManager() {
|
||||
<TableSkeleton rows={10} cols={3} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<EmptyState title="Нет ASN" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} />
|
||||
<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">
|
||||
@@ -748,50 +750,17 @@ function ASNsNewManager() {
|
||||
</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>Удалить ASN?</h3>
|
||||
<div className="text-muted">
|
||||
Вы уверены, что хотите удалить "{itemToDelete?.asn}"? Это действие необратимо.
|
||||
</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>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={showDeleteModal}
|
||||
title={'Удалить ASN?'}
|
||||
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.asn}"? Это действие необратимо.`}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
destructive
|
||||
size={'sm'}
|
||||
onConfirm={executeDelete}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
<ConfirmDiffModal
|
||||
show={confirmSaveOpen}
|
||||
diff={diff}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||
import CommunityBadge from './components/CommunityBadge.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -74,17 +75,7 @@ function DomainsNewManager() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const renderCommunityBadge = (value) => {
|
||||
const v = String(value ?? '').trim();
|
||||
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
|
||||
const meta = communities.find(c => c.value === v);
|
||||
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
|
||||
const cls = `badge bg-${color}-lt text-${color}`;
|
||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
|
||||
return (
|
||||
<span className={cls} title={title}>{v}</span>
|
||||
);
|
||||
};
|
||||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||||
|
||||
const isValidDomain = (value) => {
|
||||
const v = String(value).trim().toLowerCase();
|
||||
@@ -623,7 +614,18 @@ function DomainsNewManager() {
|
||||
<TableSkeleton rows={10} cols={3} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<EmptyState title="Нет доменов" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} />
|
||||
<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">
|
||||
@@ -726,50 +728,17 @@ function DomainsNewManager() {
|
||||
</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>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={showDeleteModal}
|
||||
title={'Удалить домен?'}
|
||||
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.domain}"? Это действие необратимо.`}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
destructive
|
||||
size={'sm'}
|
||||
onConfirm={executeDelete}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
{/* Confirm Save Modal */}
|
||||
<ConfirmDiffModal
|
||||
show={confirmSaveOpen}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||
import CommunityBadge from './components/CommunityBadge.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -74,17 +75,7 @@ function IPRangesManager() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const renderCommunityBadge = (value) => {
|
||||
const v = String(value ?? '').trim();
|
||||
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
|
||||
const meta = communities.find(c => c.value === v);
|
||||
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
|
||||
const cls = `badge bg-${color}-lt text-${color}`;
|
||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
|
||||
return (
|
||||
<span className={cls} title={title}>{v}</span>
|
||||
);
|
||||
};
|
||||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||||
|
||||
const isValidIPv4 = (ip) => {
|
||||
const octets = ip.split('.');
|
||||
@@ -364,6 +355,21 @@ function IPRangesManager() {
|
||||
setImportOpen(false);
|
||||
};
|
||||
|
||||
// Шаблон CSV для пустого состояния
|
||||
const downloadSampleCsv = () => {
|
||||
const header = ['ipRange','community'];
|
||||
const lines = [header, ['192.168.1.0/24', '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 = 'ip_ranges_sample.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Сортировка
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
let valA = a[sortField] || '';
|
||||
@@ -878,7 +884,12 @@ function IPRangesManager() {
|
||||
<TableSkeleton rows={10} cols={3} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<EmptyState title="Нет IP-диапазонов" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} />
|
||||
<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">
|
||||
@@ -986,50 +997,17 @@ function IPRangesManager() {
|
||||
</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>Удалить IP-диапазон?</h3>
|
||||
<div className="text-muted">
|
||||
Вы уверены, что хотите удалить "{itemToDelete?.ipRange}"? Это действие необратимо.
|
||||
</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>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={showDeleteModal}
|
||||
title={'Удалить IP-диапазон?'}
|
||||
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.ipRange}"? Это действие необратимо.`}
|
||||
confirmText={'Удалить'}
|
||||
cancelText={'Отмена'}
|
||||
destructive
|
||||
size={'sm'}
|
||||
onConfirm={executeDelete}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
<ConfirmDiffModal
|
||||
show={confirmSaveOpen}
|
||||
diff={diff}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function normalizeTablerColor(input) {
|
||||
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']);
|
||||
const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
|
||||
return allowed.has(c) ? c : 'blue';
|
||||
}
|
||||
|
||||
function CommunityBadge({ value, communities = [] }) {
|
||||
const v = String(value ?? '').trim();
|
||||
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
|
||||
const meta = communities.find(c => String(c?.value) === v);
|
||||
const color = normalizeTablerColor(meta?.color || 'blue');
|
||||
const cls = `badge bg-${color}-lt text-${color}`;
|
||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
|
||||
return (
|
||||
<span className={cls} title={title}>{v}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunityBadge;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export default function ConfirmDialog({ open, title, message, confirmText = 'Подтвердить', cancelText = 'Отмена', onConfirm, onCancel }) {
|
||||
export default function ConfirmDialog({ open, title, message, confirmText = 'Подтвердить', cancelText = 'Отмена', onConfirm, onCancel, destructive = false, size = 'md' }) {
|
||||
const ref = useRef(null)
|
||||
useEffect(() => {
|
||||
if (open && ref.current) {
|
||||
@@ -10,7 +10,7 @@ export default function ConfirmDialog({ open, title, message, confirmText = 'П
|
||||
if (!open) return null
|
||||
return (
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.() }}>
|
||||
<div className="modal-dialog" role="document">
|
||||
<div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''}`} role="document">
|
||||
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
||||
@@ -30,7 +30,7 @@ export default function ConfirmDialog({ open, title, message, confirmText = 'П
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>{cancelText}</button>
|
||||
<button type="button" className="btn btn-primary" data-primary onClick={onConfirm}>{confirmText}</button>
|
||||
<button type="button" className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`} data-primary onClick={onConfirm}>{confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ function EmptyState({
|
||||
title = 'Нет данных',
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
}) {
|
||||
return (
|
||||
<div className="empty">
|
||||
@@ -17,9 +18,12 @@ function EmptyState({
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
{(action || secondaryAction) && (
|
||||
<div className="empty-action">
|
||||
{action}
|
||||
{secondaryAction && (
|
||||
<span className="ms-2">{secondaryAction}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ function PageHeaderActions({
|
||||
onOnlineUpdate,
|
||||
onBackgroundUpdate,
|
||||
}) {
|
||||
const secondaryButtons = Boolean(onImport || onExport || onClear || onClearCommunities || onHistory);
|
||||
const secondaryButtons = Boolean(onPreview || onImport || onExport || onClear || onClearCommunities || onHistory);
|
||||
// Рендер плейсхолдеров во время загрузки/перезагрузки страницы
|
||||
if (loading) {
|
||||
const showUpdateGroup = Boolean(onBackgroundUpdate || onOnlineUpdate);
|
||||
@@ -176,6 +176,11 @@ function PageHeaderActions({
|
||||
align="left"
|
||||
buttonContent={<><IconDots className="me-1" /> Ещё</>}
|
||||
>
|
||||
{onPreview && (
|
||||
<button className="dropdown-item" type="button" onClick={onPreview} disabled={disablePreview}>
|
||||
<IconEye className="me-1" /> Предпросмотр
|
||||
</button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button className="dropdown-item" type="button" onClick={onRefresh} disabled={disableRefresh}>
|
||||
<IconRefresh className="me-1" /> Обновить
|
||||
|
||||
+52
-1
@@ -2,4 +2,55 @@
|
||||
|
||||
/* Sticky footer helpers */
|
||||
.page { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.footer { margin-top: auto; }
|
||||
.footer { margin-top: auto; }
|
||||
|
||||
/* ===== Tabler visual consistency (global overrides) ===== */
|
||||
:root {
|
||||
--app-gap-xs: .25rem;
|
||||
--app-gap-sm: .5rem;
|
||||
--app-gap-md: .75rem;
|
||||
--app-gap-lg: 1rem;
|
||||
}
|
||||
|
||||
/* Header actions under page header */
|
||||
.page-header .header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--app-gap-sm);
|
||||
}
|
||||
.page-header .header-actions .btn-group { margin-right: var(--app-gap-sm); }
|
||||
.page-header .vr { margin: 0 var(--app-gap-sm); }
|
||||
|
||||
/* Buttons: icon spacing left */
|
||||
.btn .icon { margin-right: .375rem; }
|
||||
.btn.btn-icon .icon { margin-right: 0; }
|
||||
|
||||
/* Tables: remove hover background for rows (as per project preference) */
|
||||
.table tbody tr:hover { background-color: transparent; }
|
||||
/* Центровка контента ячеек и компактный вид */
|
||||
.table.card-table th, .table.card-table td { vertical-align: middle; }
|
||||
|
||||
/* Compact card spacing */
|
||||
.card.card-md { margin-bottom: var(--app-gap-lg); }
|
||||
|
||||
/* Utilities used in components */
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.border-dashed { border: 2px dashed rgba(0,0,0,.08); }
|
||||
.transition { transition: all .2s ease; }
|
||||
.rotate-180 { transform: rotate(180deg); }
|
||||
|
||||
/* Input icon alignment tweaks */
|
||||
.input-icon .input-icon-addon { display: flex; align-items: center; }
|
||||
|
||||
/* Modal close button click area */
|
||||
.modal .btn-close { position: absolute; right: .75rem; top: .75rem; }
|
||||
|
||||
/* Фильтры: удобная ширина селектов */
|
||||
.form-select.w-auto { min-width: 180px; }
|
||||
|
||||
/* Икон-кнопки одинакового размера */
|
||||
.btn.btn-icon.btn-sm { width: 2rem; height: 2rem; display: inline-flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* Отступы между бейджами и в empty-action */
|
||||
.badge + .badge { margin-left: .25rem; }
|
||||
.empty .empty-action .btn + .btn { margin-left: .5rem; }
|
||||
Reference in New Issue
Block a user