refactor(Tables): implement flat table styling across ASNs, Domains, and IPRanges managers for improved visual consistency and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s
This commit is contained in:
@@ -674,27 +674,29 @@ function ASNsNewManager() {
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<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>
|
||||
|
||||
{/* Таблица без карточной подложки */}
|
||||
{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 card-table table-vcenter table-nowrap mb-0">
|
||||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: '40px'}}>
|
||||
@@ -824,7 +826,6 @@ function ASNsNewManager() {
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
<S3MetaBar keys={["asns"]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -684,27 +684,29 @@ function DomainsNewManager() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={4} hasCheckbox={true} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<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>
|
||||
|
||||
{/* Таблица без карточной подложки (только горизонтальные разделители) */}
|
||||
{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 card-table table-vcenter table-nowrap mb-0">
|
||||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>
|
||||
@@ -854,9 +856,9 @@ function DomainsNewManager() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{/* Пагинация */}
|
||||
<Pagination
|
||||
)}
|
||||
{/* Пагинация */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems || filtered.length}
|
||||
|
||||
@@ -923,21 +923,23 @@ function IPRangesManager() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<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>
|
||||
|
||||
{/* Таблица без карточной подложки */}
|
||||
{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 card-table table-vcenter table-nowrap mb-0">
|
||||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: '40px'}}>
|
||||
@@ -1057,7 +1059,6 @@ function IPRangesManager() {
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
<S3MetaBar keys={["ipRanges"]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import Tooltip from './Tooltip.jsx';
|
||||
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
|
||||
* @param {boolean} props.striped - Чередование фона строк (Tabler UX, default: true)
|
||||
* @param {boolean} props.compact - Уменьшенные отступы ячеек (default: false)
|
||||
* @param {boolean} props.flat - Таблица без карточной подложки: только горизонтальные разделители (default: false)
|
||||
*/
|
||||
function DataTable({
|
||||
columns = [],
|
||||
@@ -60,6 +61,7 @@ function DataTable({
|
||||
skeletonRows = 10,
|
||||
striped = true,
|
||||
compact = false,
|
||||
flat = false,
|
||||
}) {
|
||||
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
|
||||
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
|
||||
@@ -220,8 +222,9 @@ function DataTable({
|
||||
|
||||
const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
|
||||
const tableClass = [
|
||||
'table card-table table-vcenter table-nowrap mb-0',
|
||||
striped && 'table-striped',
|
||||
'table table-vcenter table-nowrap mb-0',
|
||||
flat ? 'table-flat' : 'card-table',
|
||||
!flat && striped && 'table-striped',
|
||||
compact && 'table-sm',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Skeleton loader для таблиц с анимацией pulse
|
||||
* @param {boolean} flat - без карточной подложки (table-flat)
|
||||
*/
|
||||
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false }) {
|
||||
export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false, flat = false }) {
|
||||
const tableClass = flat
|
||||
? 'table table-flat table-vcenter table-nowrap mb-0'
|
||||
: 'table card-table table-vcenter table-nowrap mb-0';
|
||||
const Row = () => (
|
||||
<tr>
|
||||
{hasCheckbox && (
|
||||
@@ -18,7 +22,7 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
|
||||
);
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<table className={tableClass}>
|
||||
<thead>
|
||||
<tr>
|
||||
{hasCheckbox && <th style={{ width: '40px' }}></th>}
|
||||
@@ -35,10 +39,13 @@ export default function TableSkeleton({ rows = 10, cols = 3, hasCheckbox = false
|
||||
);
|
||||
}
|
||||
|
||||
export function TableEmpty({ cols = 3, children }) {
|
||||
export function TableEmpty({ cols = 3, flat = false, children }) {
|
||||
const tableClass = flat
|
||||
? 'table table-flat table-vcenter table-nowrap mb-0'
|
||||
: 'table card-table table-vcenter table-nowrap mb-0';
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<table className={tableClass}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={cols} className="text-center text-muted py-5">
|
||||
|
||||
@@ -332,6 +332,55 @@ button:focus-visible,
|
||||
--tblr-table-bg-type: rgba(32, 107, 196, 0.12);
|
||||
}
|
||||
|
||||
/* ===== Table flat (без карточной подложки, как UniFi) ===== */
|
||||
/* Только горизонтальные разделители, thead темнее, без вертикальных границ */
|
||||
.table-flat {
|
||||
--tblr-table-border-color: var(--tblr-border-color);
|
||||
background: transparent;
|
||||
}
|
||||
.table-flat thead th {
|
||||
font-weight: 600;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--tblr-table-border-color);
|
||||
background: var(--tblr-bg-surface-secondary, rgba(0, 0, 0, 0.03));
|
||||
color: var(--tblr-body-color);
|
||||
padding: 0.75rem 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-flat tbody td {
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--tblr-table-border-color);
|
||||
background: transparent;
|
||||
padding: 0.75rem 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.table-flat tbody tr:hover td {
|
||||
background: var(--tblr-bg-surface-tertiary, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
.table-flat tbody tr.table-selected td,
|
||||
.table-flat tbody tr.table-active td {
|
||||
background: rgba(32, 107, 196, 0.08);
|
||||
}
|
||||
.table-flat tbody tr.table-selected:hover td,
|
||||
.table-flat tbody tr.table-active:hover td {
|
||||
background: rgba(32, 107, 196, 0.12);
|
||||
}
|
||||
/* В тёмной теме — чуть другие оттенки для thead */
|
||||
[data-bs-theme="dark"] .table-flat thead th {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
[data-bs-theme="dark"] .table-flat tbody tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
[data-bs-theme="dark"] .table-flat tbody tr.table-selected td,
|
||||
[data-bs-theme="dark"] .table-flat tbody tr.table-active td {
|
||||
background: rgba(32, 107, 196, 0.15);
|
||||
}
|
||||
|
||||
.user-select-none {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
Reference in New Issue
Block a user