feat: Integrate @tabler/core for UI enhancements and refactor App component with new sidebar and dashboard features
Publish Tabler Docker image / build-and-push-tabler (push) Has been cancelled

This commit is contained in:
2025-07-08 23:02:32 +07:00
parent 0c1c156bb7
commit 1eb3e9d3f6
6 changed files with 466 additions and 1160 deletions
+37
View File
@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@tabler/core": "^1.3.2",
"@tabler/icons-react": "^3.34.0",
"axios": "^1.10.0",
"bootstrap": "^5.3.7",
@@ -642,6 +643,42 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tabler/core": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.3.2.tgz",
"integrity": "sha512-QDVJbv48YJrahBLdxYkLi6NutQv4jGbkUWyzxE2NcNJ3s3GGpRx98JmbAoN92NZKNmf26vZdW6k2Q5haVKlS4A==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "5.3.6"
},
"engines": {
"node": ">=20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/core/node_modules/bootstrap": {
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
"integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/@tabler/icons": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.34.0.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tabler/core": "^1.3.2",
"@tabler/icons-react": "^3.34.0",
"axios": "^1.10.0",
"bootstrap": "^5.3.7",
+34 -931
View File
File diff suppressed because it is too large Load Diff
+284 -60
View File
@@ -6,36 +6,96 @@ import {
IconNetwork,
IconSettings,
IconMenu2,
IconX
IconX,
IconHome,
IconDatabase,
IconUsers,
IconBell,
IconSearch,
IconChevronDown,
IconLogout,
IconUser,
IconDashboard,
IconFileText,
IconCloud,
IconShield,
IconActivity
} from '@tabler/icons-react';
import DataManager from './DataManager';
import './App.css';
function App() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [activeTab, setActiveTab] = useState('domains');
const [activeTab, setActiveTab] = useState('dashboard');
const [notifications] = useState([
{ id: 1, title: 'Новый домен добавлен', message: 'example.com был добавлен в список', time: '2 мин назад' },
{ id: 2, title: 'Изменения сохранены', message: 'Список AS обновлен в S3', time: '5 мин назад' }
]);
const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
const sidebarItems = [
{ id: 'dashboard', title: 'Dashboard', icon: IconDashboard, badge: null },
{ id: 'domains', title: 'Домены', icon: IconWorld, badge: '12' },
{ id: 'asns', title: 'AS', icon: IconNetwork, badge: '8' },
{ id: 'files', title: 'Файлы', icon: IconFileText, badge: null },
{ id: 'cloud', title: 'Облако', icon: IconCloud, badge: null },
{ id: 'security', title: 'Безопасность', icon: IconShield, badge: null },
{ id: 'activity', title: 'Активность', icon: IconActivity, badge: '3' },
{ id: 'settings', title: 'Настройки', icon: IconSettings, badge: null }
];
const renderContent = () => {
switch (activeTab) {
case 'dashboard':
return <Dashboard />;
case 'domains':
return (
<DataManager
entityName="домен"
entityKey="domains"
placeholder="example.com"
/>
);
case 'asns':
return (
<DataManager
entityName="AS"
entityKey="asns"
placeholder="AS12345"
/>
);
default:
return <Dashboard />;
}
};
const getPageTitle = () => {
const item = sidebarItems.find(item => item.id === activeTab);
return item ? item.title : 'Dashboard';
};
return (
<div className="page">
{/* Header */}
<header className="navbar navbar-expand-md navbar-light d-print-none">
<div className="container-xl">
{/* Sidebar */}
<aside className={`navbar navbar-vertical navbar-expand-lg ${sidebarOpen ? 'navbar-show' : ''}`}>
<div className="container-fluid">
<button
className="navbar-toggler"
type="button"
onClick={toggleSidebar}
>
<IconMenu2 className="icon" />
<IconX className="icon" />
</button>
<h1 className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<h1 className="navbar-brand navbar-brand-autodark">
<IconBrandTabler className="navbar-brand-icon me-2" />
S3 Lists Manager
</h1>
<div className="navbar-nav flex-row order-md-last">
<div className="navbar-nav flex-row d-lg-none">
<div className="nav-item dropdown">
<a href="#" className="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
<a href="#" className="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown">
<span className="avatar avatar-sm" style={{backgroundImage: 'url(./static/avatars/000m.jpg)'}}></span>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
@@ -46,83 +106,164 @@ function App() {
</div>
</div>
</div>
</div>
</header>
{/* Sidebar */}
<div className={`navbar-expand-md ${sidebarOpen ? 'show' : ''}`}>
<div className="collapse navbar-collapse" id="navbar-menu">
<div className="navbar navbar-light">
<div className="container-xl">
<ul className="navbar-nav">
<li className="nav-item">
<div className="collapse navbar-collapse" id="sidebar-menu">
<ul className="navbar-nav pt-lg-auto">
{sidebarItems.map((item) => (
<li key={item.id} className="nav-item">
<a
className={`nav-link ${activeTab === 'domains' ? 'active' : ''}`}
className={`nav-link ${activeTab === item.id ? 'active' : ''}`}
href="#"
onClick={(e) => { e.preventDefault(); setActiveTab('domains'); }}
onClick={(e) => { e.preventDefault(); setActiveTab(item.id); }}
>
<IconWorld className="nav-link-icon" />
<span className="nav-link-title">Домены</span>
<item.icon className="nav-link-icon" />
<span className="nav-link-title">{item.title}</span>
{item.badge && (
<span className="badge badge-sm bg-green-lt text-green ms-auto">
{item.badge}
</span>
)}
</a>
</li>
<li className="nav-item">
<a
className={`nav-link ${activeTab === 'asns' ? 'active' : ''}`}
href="#"
onClick={(e) => { e.preventDefault(); setActiveTab('asns'); }}
>
<IconNetwork className="nav-link-icon" />
<span className="nav-link-title">AS</span>
</a>
</li>
<li className="nav-item">
<a className="nav-link" href="#">
<IconSettings className="nav-link-icon" />
<span className="nav-link-title">Настройки</span>
</a>
</li>
</ul>
))}
</ul>
</div>
</div>
</aside>
{/* Header */}
<header className="navbar navbar-expand-md navbar-light d-none d-lg-flex d-print-none">
<div className="container-xl">
<button
className="navbar-toggler"
type="button"
onClick={toggleSidebar}
>
<IconMenu2 className="icon" />
</button>
<div className="navbar-nav flex-row order-md-last">
<div className="nav-item dropdown d-none d-md-flex me-3">
<a href="#" className="nav-link px-0" data-bs-toggle="dropdown" tabIndex="-1">
<IconBell className="icon" />
<span className="badge badge-sm bg-red"></span>
</a>
<div className="dropdown-menu dropdown-menu-arrow dropdown-menu-end dropdown-menu-card">
<div className="card">
<div className="card-header">
<h3 className="card-title">Уведомления</h3>
</div>
<div className="list-group list-group-flush">
{notifications.map((notification) => (
<div key={notification.id} className="list-group-item">
<div className="row align-items-center">
<div className="col-auto">
<span className="status-dot status-dot-animated bg-red d-block"></span>
</div>
<div className="col text-truncate">
<a href="#" className="text-body d-block">{notification.title}</a>
<div className="d-block text-muted text-truncate mt-n1">
{notification.message}
</div>
</div>
<div className="col-auto">
<a href="#" className="list-group-item-actions">
<IconX className="icon" />
</a>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="nav-item dropdown">
<a href="#" className="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown">
<span className="avatar avatar-sm" style={{backgroundImage: 'url(./static/avatars/000m.jpg)'}}></span>
<span className="d-none d-xl-block ps-2">
<span>Admin User</span>
<IconChevronDown className="icon" />
</span>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a href="#" className="dropdown-item">
<IconUser className="icon me-2" />
Profile
</a>
<a href="#" className="dropdown-item">
<IconSettings className="icon me-2" />
Settings
</a>
<div className="dropdown-divider"></div>
<a href="#" className="dropdown-item">
<IconLogout className="icon me-2" />
Logout
</a>
</div>
</div>
</div>
<div className="navbar-nav flex-row">
<div className="nav-item d-none d-md-flex me-3">
<div className="input-icon">
<IconSearch className="icon" />
<input type="text" className="form-control" placeholder="Поиск..." />
</div>
</div>
</div>
</div>
</div>
</header>
{/* Main Content */}
<div className="page-wrapper">
<div className="container-xl">
{/* Page Header */}
<div className="page-header d-print-none">
<div className="row align-items-center">
<div className="col">
<div className="page-pretitle">
S3 Lists Manager
</div>
<h2 className="page-title">
{activeTab === 'domains' ? 'Управление доменами' : 'Управление AS'}
{getPageTitle()}
</h2>
<div className="text-muted mt-1">
{activeTab === 'domains'
? 'Добавляйте, редактируйте и управляйте списками доменов'
: 'Добавляйте, редактируйте и управляйте списками AS'
}
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
<span className="d-none d-sm-inline">
<a href="#" className="btn btn-white">
Новый домен
</a>
</span>
<a href="#" className="btn btn-primary d-none d-sm-inline-block">
<IconDatabase className="icon me-2" />
Сохранить в S3
</a>
</div>
</div>
</div>
{/* Breadcrumbs */}
<div className="page-header-breadcrumb">
<ol className="breadcrumb">
<li className="breadcrumb-item">
<a href="#" onClick={(e) => { e.preventDefault(); setActiveTab('dashboard'); }}>
<IconHome className="icon me-2" />
Главная
</a>
</li>
<li className="breadcrumb-item active" aria-current="page">
{getPageTitle()}
</li>
</ol>
</div>
</div>
</div>
<div className="page-body">
<div className="container-xl">
{activeTab === 'domains' && (
<DataManager
entityName="домен"
entityKey="domains"
placeholder="example.com"
/>
)}
{activeTab === 'asns' && (
<DataManager
entityName="AS"
entityKey="asns"
placeholder="AS12345"
/>
)}
{renderContent()}
</div>
</div>
@@ -147,4 +288,87 @@ function App() {
);
}
// Dashboard Component
function Dashboard() {
const stats = [
{ title: 'Всего доменов', value: '1,234', icon: IconWorld, color: 'blue' },
{ title: 'Всего AS', value: '567', icon: IconNetwork, color: 'green' },
{ title: 'Активные шлюзы', value: '12', icon: IconDatabase, color: 'orange' },
{ title: 'Последние изменения', value: '3', icon: IconActivity, color: 'red' }
];
return (
<div className="row row-deck row-cards">
{/* Stats Cards */}
{stats.map((stat, index) => (
<div key={index} className="col-sm-6 col-lg-3">
<div className="card">
<div className="card-body">
<div className="d-flex align-items-center">
<div className="subheader">{stat.title}</div>
</div>
<div className="h1 mb-3">{stat.value}</div>
<div className="d-flex mb-2">
<stat.icon className={`icon icon-sm text-${stat.color}`} />
</div>
</div>
</div>
</div>
))}
{/* Main Content Card */}
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Добро пожаловать в S3 Lists Manager</h3>
</div>
<div className="card-body">
<p>Выберите раздел в боковом меню для управления списками доменов и AS.</p>
<div className="row">
<div className="col-md-6">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<IconWorld className="icon icon-lg text-blue" />
</div>
<div className="col">
<div className="font-weight-medium">
Управление доменами
</div>
<div className="text-muted">
Добавляйте, редактируйте и управляйте списками доменов
</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<IconNetwork className="icon icon-lg text-green" />
</div>
<div className="col">
<div className="font-weight-medium">
Управление AS
</div>
<div className="text-muted">
Добавляйте, редактируйте и управляйте списками AS
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default App;
+109 -169
View File
@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Table, Button, Form, Row, Col, Card, Alert } from 'react-bootstrap';
import {
IconPlus,
IconSearch,
@@ -199,7 +198,7 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div>
{/* Bulk Update Card */}
<div className="card card-md mt-3">
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconArrowsExchange className="icon me-2" />
@@ -207,110 +206,100 @@ function DataManager({ entityName, entityKey, placeholder }) {
</h3>
</div>
<div className="card-body">
<div className="mb-2">
<div className="mb-3">
<label className="form-label">Заменить с</label>
<input
type="text"
className="form-control"
value={bulkFrom}
onChange={e => setBulkFrom(e.target.value)}
<input
type="text"
className="form-control"
value={bulkFrom}
onChange={(e) => setBulkFrom(e.target.value)}
/>
</div>
<div className="mb-3">
<label className="form-label">Заменить на</label>
<input
type="text"
className="form-control"
value={bulkTo}
onChange={e => setBulkTo(e.target.value)}
<input
type="text"
className="form-control"
value={bulkTo}
onChange={(e) => setBulkTo(e.target.value)}
/>
</div>
<div className="form-footer">
<button
type="button"
className="btn btn-warning w-100"
className="btn btn-warning w-100"
onClick={handleBulkUpdate}
>
<IconArrowsExchange className="icon me-2" />
Выполнить замену
Обновить все
</button>
</div>
</div>
</div>
{/* Stats Card */}
<div className="card card-md mt-3">
{/* 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="row text-center">
<div className="col">
<div className="text-muted mb-1">Всего</div>
<div className="h3 mb-0">{items.length}</div>
</div>
<div className="col">
<div className="text-muted mb-1">Найдено</div>
<div className="h3 mb-0">{filteredItems.length}</div>
</div>
<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>
Сохранение...
</>
) : (
<>
<IconDatabase className="icon me-2" />
Сохранить в S3
</>
)}
</button>
<button
className="btn btn-outline-secondary"
onClick={fetchItems}
disabled={loading}
>
<IconRefresh className="icon me-2" />
Обновить
</button>
</div>
</div>
</div>
</div>
<div className="col-lg-8">
{/* Main Data Card */}
{/* Search and Table Card */}
<div className="card">
<div className="card-header">
<div className="d-flex justify-content-between align-items-center">
<h3 className="card-title">
<IconDatabase className="icon me-2" />
Список: {entityName}
</h3>
<div className="card-actions">
<button
className="btn btn-outline-secondary me-2"
onClick={fetchItems}
disabled={loading}
>
<IconRefresh className="icon me-2" />
Обновить
</button>
<button
className="btn btn-success"
onClick={handleSaveChanges}
disabled={items.length === 0 || loading}
>
<IconUpload className="icon me-2" />
Сохранить в S3
</button>
</div>
</div>
</div>
<div className="card-body">
{/* Search */}
<div className="mb-3">
<h3 className="card-title">Список {entityName}</h3>
<div className="card-actions">
<div className="input-icon">
<IconSearch className="icon" />
<input
type="text"
className="form-control"
placeholder={`Поиск по ${entityName}...`}
placeholder={`Поиск ${entityName}...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Table */}
</div>
<div className="card-body">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>#</th>
<th>{entityName}</th>
<th>Шлюз</th>
<th className="w-1"></th>
@@ -318,10 +307,18 @@ function DataManager({ entityName, entityKey, placeholder }) {
</thead>
<tbody>
{filteredItems.map((item, index) => (
<tr key={item.domain}>
<td className="text-muted">{index + 1}</td>
<tr key={index}>
<td>
<div className="font-weight-medium">{item.domain}</div>
{editingDomain === item.domain ? (
<input
type="text"
className="form-control form-control-sm"
value={editingDomain}
onChange={(e) => setEditingDomain(e.target.value)}
/>
) : (
item.domain
)}
</td>
<td>
{editingDomain === item.domain ? (
@@ -330,7 +327,6 @@ function DataManager({ entityName, entityKey, placeholder }) {
className="form-control form-control-sm"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
autoFocus
/>
) : (
<span className="badge bg-blue-lt">{item.type}</span>
@@ -339,14 +335,14 @@ function DataManager({ entityName, entityKey, placeholder }) {
<td>
{editingDomain === item.domain ? (
<div className="btn-list">
<button
className="btn btn-sm btn-success"
<button
className="btn btn-sm btn-success"
onClick={() => handleSaveEdit(item.domain)}
>
<IconCheck className="icon" />
</button>
<button
className="btn btn-sm btn-secondary"
<button
className="btn btn-sm btn-secondary"
onClick={handleCancelEdit}
>
<IconX className="icon" />
@@ -354,14 +350,14 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div>
) : (
<div className="btn-list">
<button
className="btn btn-sm btn-outline-primary"
<button
className="btn btn-sm btn-outline-primary"
onClick={() => handleEdit(item)}
>
<IconEdit className="icon" />
</button>
<button
className="btn btn-sm btn-outline-danger"
<button
className="btn btn-sm btn-outline-danger"
onClick={() => confirmDelete(item)}
>
<IconTrash className="icon" />
@@ -374,39 +370,17 @@ function DataManager({ entityName, entityKey, placeholder }) {
</tbody>
</table>
</div>
{/* Empty States */}
{items.length > 0 && filteredItems.length === 0 && (
{filteredItems.length === 0 && (
<div className="empty">
<div className="empty-icon">
<IconSearch className="icon" />
<div className="empty-img">
<IconAlertTriangle className="icon" />
</div>
<p className="empty-title">Ничего не найдено</p>
<p className="empty-title">Нет {entityName} для отображения</p>
<p className="empty-subtitle text-muted">
По вашему запросу ничего не найдено.
Добавьте первый {entityName} используя форму слева.
</p>
</div>
)}
{items.length === 0 && !loading && (
<div className="empty">
<div className="empty-icon">
<IconDatabase className="icon" />
</div>
<p className="empty-title">Список пуст</p>
<p className="empty-subtitle text-muted">
Добавьте новый элемент, чтобы начать работу.
</p>
</div>
)}
{loading && (
<div className="text-center py-4">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
</div>
)}
</div>
</div>
</div>
@@ -414,77 +388,43 @@ function DataManager({ entityName, entityKey, placeholder }) {
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="modal-backdrop" style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1050,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div className="modal-dialog" style={{
maxWidth: '500px',
width: '90%',
margin: '1.75rem auto'
}}>
<div className="modal-content" style={{
backgroundColor: '#fff',
borderRadius: '0.5rem',
boxShadow: '0 0.5rem 1rem rgba(0, 0, 0, 0.15)'
}}>
<div className="modal-header" style={{
padding: '1rem 1.5rem',
borderBottom: '1px solid var(--tblr-border-color)',
display: 'flex',
alignItems: 'center'
}}>
<IconAlertTriangle className="icon me-2" style={{ color: 'var(--tblr-danger)' }} />
<h5 className="modal-title" style={{ margin: 0 }}>Подтверждение удаления</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowDeleteModal(false)}
style={{
background: 'none',
border: 'none',
fontSize: '1.5rem',
cursor: 'pointer',
marginLeft: 'auto'
}}
>
×
</button>
<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>Удалить {entityName}?</h3>
<div className="text-muted">
Вы уверены, что хотите удалить "{itemToDelete?.domain}"? Это действие необратимо.
</div>
</div>
<div className="modal-body" style={{ padding: '1.5rem' }}>
<p>Вы уверены, что хотите удалить <strong>{itemToDelete?.domain}</strong>?</p>
<p className="text-muted mb-0">Это действие нельзя отменить.</p>
</div>
<div className="modal-footer" style={{
padding: '1rem 1.5rem',
borderTop: '1px solid var(--tblr-border-color)',
display: 'flex',
gap: '0.5rem',
justifyContent: 'flex-end'
}}>
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowDeleteModal(false)}
>
Отмена
</button>
<button
type="button"
className="btn btn-danger"
onClick={executeDelete}
>
<IconTrash className="icon me-2" />
Удалить
</button>
<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>
+1
View File
@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import '@tabler/core/dist/css/tabler.min.css'
import 'bootstrap/dist/css/bootstrap.min.css';
ReactDOM.createRoot(document.getElementById('root')).render(