feat: Introduce cursor utility class for pointer interactions and refactor action buttons into PageHeaderActions component across multiple managers for improved UI consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m54s

This commit is contained in:
2025-08-12 13:29:19 +07:00
parent f06a433d77
commit cdf42aa589
10 changed files with 162 additions and 260 deletions
+21 -73
View File
@@ -15,11 +15,13 @@ import {
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText
IconFileText,
IconEye
} from '@tabler/icons-react';
import LockBanner from './components/LockBanner.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
import HistoryModal from './components/HistoryModal.jsx';
@@ -393,9 +395,21 @@ function ASNsNewManager() {
<div className="page-pretitle">Главная / Данные / ASNs</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
<button className="btn btn-outline-secondary" onClick={() => setHistoryOpen(true)}>История</button>
</div>
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
/>
</div>
</div>
</div>
@@ -467,73 +481,7 @@ function ASNsNewManager() {
</div>
</div>
{/* Actions Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconDatabase className="icon me-2" />
Действия
</h3>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<button
className="btn btn-primary"
onClick={handleSaveChanges}
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Сохранение...
</>
) : (
<>
<IconDeviceFloppy className="icon me-2" />
Сохранить в S3
</>
)}
</button>
<button
className="btn btn-outline-secondary"
onClick={fetchItems}
disabled={loading}
>
<IconRefresh className="icon me-2" />
Обновить
</button>
<button
className="btn btn-outline-primary"
onClick={handleImport}
>
<IconUpload className="icon me-2" />
Импорт из буфера
</button>
<button
className="btn btn-outline-primary"
onClick={handleExport}
disabled={items.length === 0}
>
<IconDownload className="icon me-2" />
Экспорт CSV
</button>
<button
className="btn btn-outline-secondary"
onClick={handlePreviewDiff}
disabled={loading}
>
Предпросмотр изменений
</button>
<button
className="btn btn-outline-secondary"
onClick={clearInvalid}
disabled={items.length === 0}
>
Очистить пустые/невалидные
</button>
</div>
</div>
</div>
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
</div>
<div className="col-lg-9">
@@ -572,7 +520,7 @@ function ASNsNewManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('asn')}>
<th className="cursor-pointer" onClick={() => handleSort('asn')}>
Номер AS
{sortField === 'asn' && (
<span className="ms-1">
@@ -580,7 +528,7 @@ function ASNsNewManager() {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('community')}>
<th className="cursor-pointer" onClick={() => handleSort('community')}>
Community
{sortField === 'community' && (
<span className="ms-1">
+5
View File
@@ -219,6 +219,11 @@
}
}
/* Utility: pointer cursor */
.cursor-pointer {
cursor: pointer;
}
/* Custom responsive adjustments */
@media (max-width: 992px) {
.page-wrapper {
+7 -28
View File
@@ -683,64 +683,43 @@ function BillingManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th
className="cursor-pointer"
onClick={() => handleSort('hostName')}
>
<th className="cursor-pointer" onClick={() => handleSort('hostName')}>
Имя хостера
{sortField === 'hostName' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer"
onClick={() => handleSort('purpose')}
>
<th className="cursor-pointer" onClick={() => handleSort('purpose')}>
Назначение
{sortField === 'purpose' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer"
onClick={() => handleSort('country')}
>
<th className="cursor-pointer" onClick={() => handleSort('country')}>
Страна
{sortField === 'country' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer"
onClick={() => handleSort('provider')}
>
<th className="cursor-pointer" onClick={() => handleSort('provider')}>
Провайдер
{sortField === 'provider' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer"
onClick={() => handleSort('monthlyCost')}
>
<th className="cursor-pointer" onClick={() => handleSort('monthlyCost')}>
Месячная стоимость
{sortField === 'monthlyCost' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer"
onClick={() => handleSort('nextPaymentDate')}
>
<th className="cursor-pointer" onClick={() => handleSort('nextPaymentDate')}>
Следующий платеж
{sortField === 'nextPaymentDate' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer"
onClick={() => handleSort('status')}
>
<th className="cursor-pointer" onClick={() => handleSort('status')}>
Статус
{sortField === 'status' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
+2 -2
View File
@@ -260,8 +260,8 @@ function CommunitiesManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={()=>changeSort('value')}>Value {sortField==='value' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
<th style={{cursor:'pointer'}} onClick={()=>changeSort('name')}>Название {sortField==='name' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
<th className="cursor-pointer" onClick={()=>changeSort('value')}>Value {sortField==='value' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
<th className="cursor-pointer" onClick={()=>changeSort('name')}>Название {sortField==='name' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
+2 -2
View File
@@ -364,7 +364,7 @@ function DataManager({ entityName, entityKey, placeholder }) {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('domain')}>
<th className="cursor-pointer" onClick={() => handleSort('domain')}>
Домен
{sortField === 'domain' && (
<span className="ms-1">
@@ -372,7 +372,7 @@ function DataManager({ entityName, entityKey, placeholder }) {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('type')}>
<th className="cursor-pointer" onClick={() => handleSort('type')}>
Шлюз
{sortField === 'type' && (
<span className="ms-1">
+21 -73
View File
@@ -15,11 +15,13 @@ import {
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText
IconFileText,
IconEye
} from '@tabler/icons-react';
import LockBanner from './components/LockBanner.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
import HistoryModal from './components/HistoryModal.jsx';
@@ -400,9 +402,21 @@ function DomainsNewManager() {
<div className="page-pretitle">Главная / Данные / Домены</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
<button className="btn btn-outline-secondary" onClick={() => setHistoryOpen(true)}>История</button>
</div>
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
/>
</div>
</div>
</div>
@@ -465,73 +479,7 @@ function DomainsNewManager() {
</div>
</div>
{/* Actions Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconDatabase className="icon me-2" />
Действия
</h3>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<button
className="btn btn-primary"
onClick={handleSaveChanges}
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Сохранение...
</>
) : (
<>
<IconDeviceFloppy className="icon me-2" />
Сохранить в S3
</>
)}
</button>
<button
className="btn btn-outline-secondary"
onClick={handlePreviewDiff}
disabled={loading}
>
Предпросмотр изменений
</button>
<button
className="btn btn-outline-secondary"
onClick={fetchItems}
disabled={loading}
>
<IconRefresh className="icon me-2" />
Обновить
</button>
<button
className="btn btn-outline-primary"
onClick={handleImport}
>
<IconUpload className="icon me-2" />
Импорт из буфера
</button>
<button
className="btn btn-outline-primary"
onClick={handleExport}
disabled={items.length === 0}
>
<IconDownload className="icon me-2" />
Экспорт CSV
</button>
<button
className="btn btn-outline-secondary"
onClick={clearInvalid}
disabled={items.length === 0}
>
Очистить пустые/невалидные
</button>
</div>
</div>
</div>
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
{/* Drag & Drop импорт */}
<div
className="card card-md border-dashed"
@@ -580,7 +528,7 @@ function DomainsNewManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('domain')}>
<th className="cursor-pointer" onClick={() => handleSort('domain')}>
Домен
{sortField === 'domain' && (
<span className="ms-1">
@@ -588,7 +536,7 @@ function DomainsNewManager() {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('community')}>
<th className="cursor-pointer" onClick={() => handleSort('community')}>
Community
{sortField === 'community' && (
<span className="ms-1">
+3 -3
View File
@@ -1218,15 +1218,15 @@ function FilterManager() {
onChange={toggleSelectAllProcessed}
/>
</th>
<th style={{ width: '25%', cursor: 'pointer' }} onClick={() => toggleSort('community')}>
<th style={{ width: '25%' }} className="cursor-pointer" onClick={() => toggleSort('community')}>
<span className="me-1">COMMUNITY</span>
{sortBy === 'community' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
<th style={{ width: '25%', cursor: 'pointer' }} onClick={() => toggleSort('gateway')}>
<th style={{ width: '25%' }} className="cursor-pointer" onClick={() => toggleSort('gateway')}>
<span className="me-1">GATEWAY</span>
{sortBy === 'gateway' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
<th style={{ width: '35%', cursor: 'pointer' }} onClick={() => toggleSort('description')}>
<th style={{ width: '35%' }} className="cursor-pointer" onClick={() => toggleSort('description')}>
<span className="me-1">ОПИСАНИЕ</span>
{sortBy === 'description' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
+21 -73
View File
@@ -15,11 +15,13 @@ import {
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText
IconFileText,
IconEye
} from '@tabler/icons-react';
import LockBanner from './components/LockBanner.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
import HistoryModal from './components/HistoryModal.jsx';
@@ -409,9 +411,21 @@ function IPRangesManager() {
<div className="page-pretitle">Главная / Данные / IP ranges</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
<button className="btn btn-outline-secondary" onClick={() => setHistoryOpen(true)}>История</button>
</div>
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
/>
</div>
</div>
</div>
@@ -483,73 +497,7 @@ function IPRangesManager() {
</div>
</div>
{/* Actions Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconDatabase className="icon me-2" />
Действия
</h3>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<button
className="btn btn-primary"
onClick={handleSaveChanges}
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Сохранение...
</>
) : (
<>
<IconDeviceFloppy className="icon me-2" />
Сохранить в S3
</>
)}
</button>
<button
className="btn btn-outline-secondary"
onClick={fetchItems}
disabled={loading}
>
<IconRefresh className="icon me-2" />
Обновить
</button>
<button
className="btn btn-outline-primary"
onClick={handleImport}
>
<IconUpload className="icon me-2" />
Импорт из буфера
</button>
<button
className="btn btn-outline-primary"
onClick={handleExport}
disabled={items.length === 0}
>
<IconDownload className="icon me-2" />
Экспорт CSV
</button>
<button
className="btn btn-outline-secondary"
onClick={handlePreviewDiff}
disabled={loading}
>
Предпросмотр изменений
</button>
<button
className="btn btn-outline-secondary"
onClick={clearInvalid}
disabled={items.length === 0}
>
Очистить пустые/невалидные
</button>
</div>
</div>
</div>
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
</div>
<div className="col-lg-9">
@@ -588,7 +536,7 @@ function IPRangesManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('ipRange')}>
<th className="cursor-pointer" onClick={() => handleSort('ipRange')}>
IP-диапазон
{sortField === 'ipRange' && (
<span className="ms-1">
@@ -596,7 +544,7 @@ function IPRangesManager() {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('community')}>
<th className="cursor-pointer" onClick={() => handleSort('community')}>
Community
{sortField === 'community' && (
<span className="ms-1">
+6 -6
View File
@@ -558,7 +558,7 @@ function ServerManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('ip')}>
<th className="cursor-pointer" onClick={() => handleSort('ip')}>
IP адрес
{sortField === 'ip' && (
<span className="ms-1">
@@ -566,7 +566,7 @@ function ServerManager() {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('dns')}>
<th className="cursor-pointer" onClick={() => handleSort('dns')}>
DNS имя
{sortField === 'dns' && (
<span className="ms-1">
@@ -574,7 +574,7 @@ function ServerManager() {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('country')}>
<th className="cursor-pointer" onClick={() => handleSort('country')}>
Страна
{sortField === 'country' && (
<span className="ms-1">
@@ -582,7 +582,7 @@ function ServerManager() {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('provider')}>
<th className="cursor-pointer" onClick={() => handleSort('provider')}>
Провайдер
{sortField === 'provider' && (
<span className="ms-1">
@@ -590,7 +590,7 @@ function ServerManager() {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('tunnel')}>
<th className="cursor-pointer" onClick={() => handleSort('tunnel')}>
Туннель
{sortField === 'tunnel' && (
<span className="ms-1">
@@ -598,7 +598,7 @@ function ServerManager() {
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('gateway')}>
<th className="cursor-pointer" onClick={() => handleSort('gateway')}>
Основной шлюз
{sortField === 'gateway' && (
<span className="ms-1">
@@ -0,0 +1,74 @@
import {
IconEye,
IconRefresh,
IconUpload,
IconDownload,
IconDeviceFloppy
} from '@tabler/icons-react';
function PageHeaderActions({
loading = false,
onPreview,
disablePreview = false,
onRefresh,
disableRefresh = false,
onImport,
onExport,
disableExport = false,
onClear,
disableClear = false,
onSave,
disableSave = false,
onHistory,
}) {
return (
<div className="btn-list">
{onPreview && (
<button className="btn btn-outline-secondary" onClick={onPreview} disabled={disablePreview} title="Предпросмотр изменений">
<IconEye className="me-1" /> Предпросмотр
</button>
)}
{onRefresh && (
<button className="btn btn-outline-secondary" onClick={onRefresh} disabled={disableRefresh} title="Обновить данные">
<IconRefresh className={loading ? 'spin me-1' : 'me-1'} /> Обновить
</button>
)}
{onImport && (
<button className="btn btn-outline-primary" onClick={onImport} title="Импорт">
<IconUpload className="me-1" /> Импорт
</button>
)}
{onExport && (
<button className="btn btn-outline-primary" onClick={onExport} disabled={disableExport} title="Экспорт">
<IconDownload className="me-1" /> Экспорт
</button>
)}
{onClear && (
<button className="btn btn-outline-secondary" onClick={onClear} disabled={disableClear} title="Очистить пустые/невалидные">
Очистить
</button>
)}
{onSave && (
<button className="btn btn-primary" onClick={onSave} disabled={disableSave} title="Сохранить">
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Сохранение...
</>
) : (
<>
<IconDeviceFloppy className="me-1" /> Сохранить
</>
)}
</button>
)}
{onHistory && (
<button className="btn btn-outline-secondary" onClick={onHistory} title="История версий">История</button>
)}
</div>
);
}
export default PageHeaderActions;